Merge PR #600 from NikitkaRa: render: 3-D contours

This commit is contained in:
Brandon Thomas
2026-10-14 13:08:59 -04:00
4 changed files with 84 additions and 19 deletions
+46 -13
View File
@@ -44,22 +44,55 @@ pub fn from_raw(raw: &str) -> Option<Extrusion> {
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 `<a:{name}>` element of `raw` (an `srgbClr` or a `schemeClr`).
fn color_in(raw: &str, name: &str) -> Option<ColorRef> {
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<ColorRef>,
}
/// The contour in 3-D XML kept from the file, if it has one.
pub fn contour_from_raw(raw: &str) -> Option<Contour> {
let i = raw.find("sp3d ")?;
let tag = raw.get(i..)?;
let tag = tag.get(..tag.find('>')?)?;
let width = attr(tag, "contourW")?.parse::<f64>().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 {
+11
View File
@@ -971,6 +971,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 } => {
+22 -2
View File
@@ -1846,7 +1846,7 @@ fn extruded_deck() -> Presentation {
701,
280.0,
"ellipse",
r#"<a:scene3d><a:camera prst="isometricTopUp"/><a:lightRig rig="threePt" dir="t"/></a:scene3d><a:sp3d extrusionH="254000"><a:extrusionClr><a:srgbClr val="C00000"/></a:extrusionClr></a:sp3d>"#,
r#"<a:scene3d><a:camera prst="isometricTopUp"/><a:lightRig rig="threePt" dir="t"/></a:scene3d><a:sp3d extrusionH="254000" contourW="63500"><a:extrusionClr><a:srgbClr val="C00000"/></a:extrusionClr><a:contourClr><a:srgbClr val="FFC000"/></a:contourClr></a:sp3d>"#,
),
mk(
702,
@@ -1858,11 +1858,31 @@ fn extruded_deck() -> Presentation {
703,
760.0,
"rect",
r#"<a:scene3d><a:camera prst="orthographicFront"/><a:lightRig rig="threePt" dir="t"/></a:scene3d><a:sp3d extrusionH="381000"/>"#,
r#"<a:scene3d><a:camera prst="orthographicFront"/><a:lightRig rig="threePt" dir="t"/></a:scene3d><a:sp3d extrusionH="381000" contourW="76200" prstMaterial="metal"><a:contourClr><a:srgbClr val="00B050"/></a:contourClr></a:sp3d>"#,
),
])
}
#[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();
+5 -4
View File
@@ -255,10 +255,10 @@ Detail in [animation-parity.md](animation-parity.md). 10–16 h; the pane is a *
- **Estimate:** 1 h (edit points: segment types, open/close path), 2–4 h (warp gallery, command and handles), 6–10 h (3-D extrusion,
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
@@ -355,6 +355,7 @@ Detail in [localization-parity.md](localization-parity.md).
| 2026-10-14 | minor | Owned corpus `corpus_media.rs`: 3-D model parts and animation bounce end now round-trip. The real-deck corpus and #70/#74/#77 stay open |
| 2026-10-14 | trivial | Corpus slice: themes, masters, effects and links; text Mouse Over actions round-trip |
| 2026-10-14 | minor | Chart trendlines round-trip. The real-deck corpus gap stays open; secondary-axis export and drawing trendlines stay in the charts gap |
| 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) |