Render: 3-D sizes in a scaled group stay in points

A group's child coordinates scale its shapes' boxes, but extrusion
depths, contour widths and bevel sizes are points, as PowerPoint draws
them. The renderer applied them in the child space, so the group's scale
multiplied them: a 1 pt contour on a small extruded cube in a legacy
group (1360 child units across a 61 pt group) was drawn hundreds of
points wide, a band across the whole slide, and the cube's extrusion ran
off towards the vanishing point.

per_point() gives the shape-space units per point (the inverse of the
child-coordinate scale that line_space already separates for line
widths); the extrusion depth, contour width and bevel width and height
are converted with it before drawing. Outside a group it is 1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dominik Strebel
2026-10-21 20:47:33 +02:00
co-authored by Claude Opus 5.5
parent 5a76518ddd
commit 48dbdffe07
3 changed files with 61 additions and 2 deletions
+2
View File
@@ -131,6 +131,7 @@ app's JSON control channel (`deckcraft --control PORT`).
| Date | What landed |
|---|---|
| 2026-10-21 | 3-D contours, extrusion depths and bevels keep their size in points inside a group whose child coordinates scale its shapes: a 1 pt contour on a small extruded cube in a legacy group drew hundreds of points wide, a band across the slide |
| 2026-10-18 | Native chart sidecar bytes move from JSON arrays into referenced ZIP entries, with shared media byte/item limits and bounded reads; older inline sidecars still load. Synthetic format regressions cover large workbooks and malformed members |
| 2026-10-17 | Opening a file caps absurd effect sizes (glow, soft edges, shadows, reflections, on shapes, text and table text) at what Format Shape allows: a glow of 10^18 pt in a .pptx made rendering ask for 34 GB and abort |
| 2026-10-17 | Opening a .deckcraft file whose shapes repeat an id gives the repeats fresh ids (groups, layouts and masters too), so the deck validates and edits by id find one shape |
@@ -245,6 +246,7 @@ app's JSON control channel (`deckcraft --control PORT`).
| 2026-10-15 | trivial | Progress log: PPTX trigger click-step round trip. Feature scores and milestone names unchanged |
| 2026-10-16 | trivial | Progress log: PNG export with a transparent background (#784) |
| 2026-10-16 | trivial | Progress log: a `.ppsx` from the system starts the slide show (#862) |
| 2026-10-21 | trivial | Progress log: 3-D sizes in scaled groups stay in points |
| 2026-10-14 | trivial | remote MCP checks reply IDs, envelopes and bounded newline framing (#718) |
| 2026-10-14 | minor | Gray list markers on empty paragraphs while editing; caret keeps the list indent before typing |
| 2026-10-14 | minor | Community PR wave folded: headline breadth 84% over 191 features (P0 92%, P1 82%, P2 55%) and the feature-area breadths from `cargo xtask parity`; one row per area; Upcoming item 2 merged; per-PR count notes dropped from the progress log. Ready stays ~55%, stage alpha |
+14 -2
View File
@@ -833,6 +833,15 @@ impl Renderer {
if depth > 0 && view.determinant().abs() > 1e-12 && view.is_finite() { (view, view.inverse() * m) } else { (m, Affine::IDENTITY) }
}
/// Shape-space units per point: a group's child coordinates can scale a shape's box (a legacy
/// 1360-unit child space makes one unit hundreds of EMUs), but 3-D sizes kept in points
/// (contour width, extrusion depth, bevel) stay points, as PowerPoint draws them. 1 outside a
/// group.
fn per_point(&self, m: Affine, depth: usize) -> f64 {
let k = scale_of(self.line_space(m, depth).1);
if k.is_finite() && k > 1e-12 { 1.0 / k } else { 1.0 }
}
/// The size and transform a shape's text is set in: inside a group whose child coordinates
/// scale the shape, the shape's size in slide points and `m` with that scale taken out, so
/// insets and font sizes stay in points; otherwise `w`, `h` and `m` themselves.
@@ -1060,7 +1069,8 @@ impl Renderer {
};
let pre = m * turn.inverse();
let lit = light::Lighting::from_raw(raw);
extrude::draw(ctx, &outline, w, h, &cam, ex.depth, pre, turn, extrude::side_color(rctx, &ex, face), &lit);
let d = ex.depth * self.per_point(m, depth);
extrude::draw(ctx, &outline, w, h, &cam, d, pre, turn, extrude::side_color(rctx, &ex, face), &lit);
}
// A 3-D contour: a band around the outline, behind the face (and in front of any sides).
if let Some(raw) = effects.as_ref().and_then(|e| e.raw3d.as_deref())
@@ -1071,7 +1081,7 @@ impl Renderer {
_ => None,
};
let col = c.color.as_ref().map(|k| rctx.color(k, face)).or(face).unwrap_or(Rgba::rgb(0xff, 0xff, 0xff));
extrude::draw_contour(ctx, &outline, m, c.width, col, &light::Lighting::from_raw(raw));
extrude::draw_contour(ctx, &outline, m, c.width * self.per_point(m, depth), col, &light::Lighting::from_raw(raw));
}
// Fill.
match &s.kind {
@@ -1166,6 +1176,8 @@ impl Renderer {
};
// A 3-D bevel: the face and a band inside the outline lit as surfaces.
if let Some(bv) = effects.as_ref().and_then(|e| e.raw3d.as_deref()).and_then(bevel::from_raw) {
let k = self.per_point(m, depth);
let bv = bevel::Bevel { w: bv.w * k, h: bv.h * k, ..bv };
let solid = match &fill {
Some(Fill::Solid { color: c }) => Some(rctx.color(c, fill_ph)),
_ => None,
+45
View File
@@ -2265,6 +2265,51 @@ fn extrusions_show_their_sides_under_a_turned_camera() {
assert_eq!(ink(&flat, 0, 960), 0);
}
#[test]
fn group_child_scale_does_not_scale_3d_depth_contours_or_bevels() {
// A legacy group child space (here 100 points per unit) scales the shapes' boxes, but
// extrusion depths, contour widths and bevels are points: a 1 pt contour on a small extruded
// cube in such a group was drawn hundreds of points wide, a band across the whole slide.
let mut deck = extruded_deck();
let bevelled = Shape {
id: ShapeId(704),
xfrm: Some(Xfrm::new(40.0, 340.0, 170.0, 130.0)),
geom: Geom::preset("rect"),
style: Some(ShapeStyle::accent(deckcraft_color::SchemeSlot::Accent1)),
effects: Some(deckcraft_model::Effects {
raw3d: Some(r#"<a:scene3d><a:camera prst="orthographicFront"/><a:lightRig rig="threePt" dir="t"/></a:scene3d><a:sp3d><a:bevelT w="254000" h="254000"/></a:sp3d>"#.into()),
..Default::default()
}),
..Default::default()
};
std::sync::Arc::make_mut(&mut deck.slides[0]).shapes.push(bevelled);
let shapes = deck.slides[0].shapes.clone();
let grouped = |k: f64| {
let children = shapes
.iter()
.map(|s| {
let x = s.xfrm.unwrap_or_default();
Shape { xfrm: Some(Xfrm::new(x.x / k, x.y / k, x.w / k, x.h / k)), ..s.clone() }
})
.collect();
let group = Shape {
id: ShapeId(10),
xfrm: Some(Xfrm::new(0.0, 0.0, 960.0, 540.0)),
kind: ShapeKind::Group { children, child: Xfrm::new(0.0, 0.0, 960.0 / k, 540.0 / k) },
..Default::default()
};
render_slide(&deck_with(vec![group]), 0, &RenderOpts { scale: 1.0, ..Default::default() })
};
let reference = grouped(1.0);
let ink = |img: &Image| img.pixels.as_chunks::<4>().0.iter().filter(|p| **p != [255, 255, 255, 255]).count();
let drawn = ink(&reference);
assert!(drawn > 20_000, "the reference draws the shapes: {drawn}");
let scaled = grouped(100.0);
let (n, d) = (ink(&scaled), differing(&reference, &scaled));
assert!(n.abs_diff(drawn) * 20 < drawn, "{n} inked pixels against {drawn}");
assert!(d * 20 < drawn, "{d} differing values against {drawn} inked pixels");
}
#[test]
fn table_rows_below_the_slide_are_not_laid_out_and_the_slide_looks_the_same() {
// A fuzzed deck's 200 x 200 table of long text took 70 s to draw, most of it off the slide.