From 48dbdffe076c9cce2b677a9b30463d710e78e2b1 Mon Sep 17 00:00:00 2001 From: Dominik Strebel Date: Wed, 21 Oct 2026 20:47:33 +0200 Subject: [PATCH 1/2] 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 --- ROADMAP.md | 2 ++ crates/render/src/lib.rs | 16 ++++++++++++-- crates/render/src/tests.rs | 45 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index f5daa2d3..0dcba203 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 | diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index cf124a9f..f88d492a 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -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, diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 4ee1c889..d7122f05 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -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#""#.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. From 79194296527cbe7fccc6b49069e9e08be820a74d Mon Sep 17 00:00:00 2001 From: Dominik Strebel Date: Wed, 21 Oct 2026 21:02:17 +0200 Subject: [PATCH 2/2] Render: measure 3-D point sizes without the camera's turn shape_at folds a 3-D camera's turn into the shape's transform, so per_point() measured its foreshortening as if it were a group's child-coordinate scale: in a 1:1 group, a shape under a turned camera got a factor other than 1 and drew its extrusion depth, contour and bevel at the wrong size. The factor is now measured on the transform before the turn (m with the camera's affine taken back out), and the turned transform still draws the geometry. Co-Authored-By: Claude Opus 5.5 --- crates/render/src/lib.rs | 18 ++++++++++++++---- crates/render/src/tests.rs | 30 ++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index f88d492a..7c78c411 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -1056,6 +1056,17 @@ impl Renderer { ctx.push_layer(None, None, None, None, None); let _ = r; } + // Shape-space units per point for the 3-D sizes, measured without the camera's turn (which + // `shape_at` puts in `m`): its foreshortening is not a child-coordinate scale. + let pt = { + let turn = effects + .as_ref() + .and_then(|e| e.raw3d.as_deref()) + .and_then(camera::from_raw) + .and_then(|c| camera::affine(&c, w, h)) + .filter(|t| t.determinant().abs() > 1e-9); + self.per_point(turn.map_or(m, |t| m * t.inverse()), depth) + }; // A 3-D extrusion under a turned camera: the sides, swept behind the face. if let Some(raw) = effects.as_ref().and_then(|e| e.raw3d.as_deref()) && let Some(ex) = extrude::from_raw(raw) @@ -1069,7 +1080,7 @@ impl Renderer { }; let pre = m * turn.inverse(); let lit = light::Lighting::from_raw(raw); - let d = ex.depth * self.per_point(m, depth); + let d = ex.depth * pt; 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). @@ -1081,7 +1092,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 * self.per_point(m, depth), col, &light::Lighting::from_raw(raw)); + extrude::draw_contour(ctx, &outline, m, c.width * pt, col, &light::Lighting::from_raw(raw)); } // Fill. match &s.kind { @@ -1176,8 +1187,7 @@ 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 bv = bevel::Bevel { w: bv.w * pt, h: bv.h * pt, ..bv }; let solid = match &fill { Some(Fill::Solid { color: c }) => Some(rctx.color(c, fill_ph)), _ => None, diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index d7122f05..348b89c3 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -2310,6 +2310,36 @@ fn group_child_scale_does_not_scale_3d_depth_contours_or_bevels() { assert!(d * 20 < drawn, "{d} differing values against {drawn} inked pixels"); } +#[test] +fn a_turned_camera_in_a_one_to_one_group_draws_its_3d_sizes_as_outside_one() { + // The camera's turn is part of the shape's transform; its foreshortening is not a group scale, + // so a 1:1 group must not change extrusion depths, contours or bevels under a turned camera. + let mut deck = extruded_deck(); + let bevelled = Shape { + id: ShapeId(705), + 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#""#.into()), + ..Default::default() + }), + ..Default::default() + }; + std::sync::Arc::make_mut(&mut deck.slides[0]).shapes.push(bevelled); + let opts = RenderOpts { scale: 1.0, ..Default::default() }; + let alone = render_slide(&deck, 0, &opts); + let group = Shape { + id: ShapeId(10), + xfrm: Some(Xfrm::new(0.0, 0.0, 960.0, 540.0)), + kind: ShapeKind::Group { children: deck.slides[0].shapes.clone(), child: Xfrm::new(0.0, 0.0, 960.0, 540.0) }, + ..Default::default() + }; + let grouped = render_slide(&deck_with(vec![group]), 0, &opts); + let d = differing(&alone, &grouped); + assert!(d < 200, "{d} values differ between the shapes alone and in a 1:1 group"); +} + #[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.