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.