From 79194296527cbe7fccc6b49069e9e08be820a74d Mon Sep 17 00:00:00 2001 From: Dominik Strebel Date: Wed, 21 Oct 2026 21:02:17 +0200 Subject: [PATCH] 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.