From ad7af605107a9137dcc2cfc7b8cc21805d51e762 Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Wed, 14 Oct 2026 01:29:23 +0300 Subject: [PATCH] render: 3-D contours --- crates/render/src/extrude.rs | 59 ++++++++++++++++++++++++++++-------- crates/render/src/lib.rs | 11 +++++++ crates/render/src/tests.rs | 24 +++++++++++++-- docs/gaps.md | 9 +++--- 4 files changed, 84 insertions(+), 19 deletions(-) diff --git a/crates/render/src/extrude.rs b/crates/render/src/extrude.rs index 9576bf60..08369f48 100644 --- a/crates/render/src/extrude.rs +++ b/crates/render/src/extrude.rs @@ -44,22 +44,55 @@ pub fn from_raw(raw: &str) -> Option { if !(depth.is_finite() && depth > 0.01) { return None; } - let color = raw.find("extrusionClr").and_then(|j| { - let body = raw.get(j..)?; - let body = body.get(..body.find("/a:extrusionClr").or_else(|| body.find("/extrusionClr")).unwrap_or(body.len()))?; - if let Some(k) = body.find("srgbClr ") { - let v = attr(body.get(k..)?, "val")?; - let n = u32::from_str_radix(v, 16).ok()?; - Some(ColorRef::rgb(Rgba::rgb((n >> 16) as u8, (n >> 8) as u8, n as u8))) - } else if let Some(k) = body.find("schemeClr ") { - SchemeSlot::from_xml(attr(body.get(k..)?, "val")?).map(ColorRef::scheme) - } else { - None - } - }); + let color = color_in(raw, "extrusionClr"); Some(Extrusion { depth: depth.min(4000.0), color }) } +/// The colour in the first `` element of `raw` (an `srgbClr` or a `schemeClr`). +fn color_in(raw: &str, name: &str) -> Option { + let j = raw.find(&format!("{name}>"))?; + let body = raw.get(j..)?; + let end = body.find(&format!("/a:{name}")).or_else(|| body.find(&format!("/{name}"))).unwrap_or(body.len()); + let body = body.get(..end)?; + if let Some(k) = body.find("srgbClr ") { + let v = attr(body.get(k..)?, "val")?; + let n = u32::from_str_radix(v, 16).ok()?; + Some(ColorRef::rgb(Rgba::rgb((n >> 16) as u8, (n >> 8) as u8, n as u8))) + } else if let Some(k) = body.find("schemeClr ") { + SchemeSlot::from_xml(attr(body.get(k..)?, "val")?).map(ColorRef::scheme) + } else { + None + } +} + +/// A 3-D contour: a band of `width` points around the shape's outline (`a:sp3d contourW`, +/// `a:contourClr`), drawn behind the face so only its outer half shows past the outline. +#[derive(Clone, Debug, PartialEq)] +pub struct Contour { + pub width: f64, + pub color: Option, +} + +/// The contour in 3-D XML kept from the file, if it has one. +pub fn contour_from_raw(raw: &str) -> Option { + let i = raw.find("sp3d ")?; + let tag = raw.get(i..)?; + let tag = tag.get(..tag.find('>')?)?; + let width = attr(tag, "contourW")?.parse::().ok()? / 12700.0; + if !(width.is_finite() && width > 0.01) { + return None; + } + Some(Contour { width: width.min(2000.0), color: color_in(raw, "contourClr") }) +} + +/// Draw `c` around `outline` (shape space, mapped by `m`), lit as a surface facing the viewer. +pub fn draw_contour(ctx: &mut RenderContext, outline: &BezPath, m: Affine, width: f64, color: Rgba, lit: &Lighting) { + ctx.set_transform(m); + ctx.set_paint(crate::color(lit.shade(color, [0.0, 0.0, 1.0]), 1.0)); + ctx.set_stroke(kurbo::Stroke::new(2.0 * width).with_join(kurbo::Join::Round)); + ctx.stroke_path(outline); +} + /// The camera's view of the depth axis: where the back face sits relative to the front face, in /// the turned shape's space (the space `camera::affine` maps into). pub fn back_offset(c: &Camera, depth: f64, w: f64, h: f64) -> Vec2 { diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 6f5faa38..fee85f02 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -943,6 +943,17 @@ impl Renderer { 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); } + // 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()) + && let Some(c) = extrude::contour_from_raw(raw) + { + let face = match &fill { + Some(Fill::Solid { color: col }) => Some(rctx.color(col, fill_ph)), + _ => 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)); + } // Fill. match &s.kind { ShapeKind::Picture { fill: pf } => { diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 4df0a6ea..4f05768a 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -1733,7 +1733,7 @@ fn extruded_deck() -> Presentation { 701, 280.0, "ellipse", - r#""#, + r#""#, ), mk( 702, @@ -1745,11 +1745,31 @@ fn extruded_deck() -> Presentation { 703, 760.0, "rect", - r#""#, + r#""#, ), ]) } +#[test] +fn contours_band_the_outline_in_their_colour() { + let img = render_slide(&extruded_deck(), 0, &RenderOpts { scale: 1.0, ..Default::default() }); + let n = |x0: u32, x1: u32, f: &dyn Fn([u8; 4]) -> bool| { + let mut k = 0; + for y in 0..img.height { + for x in x0..x1.min(img.width) { + let i = ((y * img.width + x) * 4) as usize; + if img.pixels.get(i..i + 4).and_then(|p| <[u8; 4]>::try_from(p).ok()).is_some_and(f) { + k += 1; + } + } + } + k + }; + // The ellipse's yellow contour and the flat rectangle's green one. + assert!(n(260, 480, &|p| p[0] > 200 && (150..230).contains(&p[1]) && p[2] < 80) > 200); + assert!(n(740, 960, &|p| p[0] < 80 && p[1] > 120 && p[2] < 120) > 300); +} + #[test] fn extrusions_show_their_sides_under_a_turned_camera() { let p = extruded_deck(); diff --git a/docs/gaps.md b/docs/gaps.md index f0ded9aa..f7aef79c 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -171,10 +171,10 @@ Detail in [animation-parity.md](animation-parity.md). 10–16 h; the pane is a * 1–3 % of the path length), "Bevel and 3-D rotation" (partial: bevels, camera presets and rotations and face lighting render as approximations and `scene3d`/`sp3d` round-trip as raw XML; extrusions draw their sides under turned cameras, each lit for its own normal; the preset light rigs, rig directions and - materials shade bevels and sides approximately (fitted to PowerPoint for three-point/matte only); no contours, - and no command or pane to apply 3-D). -- **Estimate:** 4–6 h (edit points), 2–4 h (warp gallery, command and handles), 2–4 h (3-D contours and - rig/material fidelity), plus the Format Shape page. + materials shade bevels and sides approximately (fitted to PowerPoint for three-point/matte only); contours draw as a + band round the face outline (not round the extruded silhouette); no command or pane to apply 3-D). +- **Estimate:** 4–6 h (edit points), 2–4 h (warp gallery, command and handles), 1–2 h (fitting the other rigs and materials + to PowerPoint renders), plus the Format Shape page. ### Proofing: spelling, thesaurus, AutoCorrect - **Evidence:** `review.spelling` checks a small built-in list of frequent misspellings @@ -258,6 +258,7 @@ Detail in [localization-parity.md](localization-parity.md). | Date | Change | Summary | |---|---|---| +| 2026-10-14 | trivial | 3-D contours | | 2026-10-14 | trivial | 3-D light rigs and materials; extrusion sides lit per side | | 2026-10-14 | trivial | 3-D extrusions draw their sides under turned cameras | | 2026-10-13 | minor | Shape, picture and table rendering checked against PowerPoint on ~150 synthetic edge-case slides; 3-D bevels, cameras and face lighting now render approximately (gap narrowed to extrusion, rigs and the 3-D pane) |