3D: check an object's solids after the geometry effects before the effect

Validation built the Extrude & Bevel, Inflate and Rotate solids from the
stored shape, while drawing, Export 3D Object and Expand as Wireframes
build them after the geometry effects before the 3D effect. A Transform
effect with copies before Extrude & Bevel could pass the check and then
draw the source art. Validation now reshapes the object, the fill or
stroke, or a group's members with those effects first.

validate_three_d_at and validate_three_d_at_in take the effect's index
for an effect on the object too, so an edit checks the effects before
that effect, and effect.apply checks a new effect after the others.
This commit is contained in:
dotCooCoo
2026-11-12 21:47:53 -08:00
parent 145ac9d30a
commit af7c5d0432
5 changed files with 85 additions and 26 deletions
+1 -1
View File
@@ -1351,7 +1351,7 @@ mod tests {
group.appearance = Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0).into();
for id in [REVOLVE, crate::EXTRUDE] {
assert!(crate::validate_three_d(&group, id, &json!({})).is_ok(), "{id} on the group itself");
let refused = crate::validate_three_d_at(&group, id, &json!({}), Some((0, 0))).unwrap_err();
let refused = crate::validate_three_d_at(&group, id, &json!({}), (Some(0), 0)).unwrap_err();
assert!(refused.contains("on the group itself"), "{id}: {refused}");
}
}
+40 -21
View File
@@ -250,33 +250,50 @@ pub fn validate_solid(n: &Node, id: &str, p: &Value) -> Result<(), String> {
/// [`validate_solid`] with the symbols of `doc`: a symbol instance, a blend and a clipping group
/// build from the art they show ([`resolve`]).
pub fn validate_solid_in(doc: Option<&Document>, n: &Node, id: &str, p: &Value) -> Result<(), String> {
validate_solid_for(doc, n, id, p, None)
validate_solid_for(doc, n, id, p, (None, None))
}
/// [`validate_solid_in`] for the effect on the object (`item` `None`) or on its appearance item
/// `item`, whose shadow is cast by that item's solid alone.
fn validate_solid_for(doc: Option<&Document>, n: &Node, id: &str, p: &Value, item: Option<usize>) -> Result<(), String> {
/// [`validate_solid_in`] for the effect at `(item, index)`: index `index` of the effects of the
/// object (`item` `None`) or of its appearance item `item`, whose shadow is cast by that item's
/// solid alone. The index is the effects' length for a new effect. The solids are checked as
/// [`solid_art_in`] draws them: from the shapes after the geometry effects before the 3D effect.
/// On the object (or group) with `index` `None`, that effect is its first visible Extrude & Bevel,
/// Inflate or Rotate, as drawn, or a new one after its other effects when it has none.
fn validate_solid_for(doc: Option<&Document>, n: &Node, id: &str, p: &Value, at: (Option<usize>, Option<usize>)) -> Result<(), String> {
validate_solid_params(id, p)?;
if let Some(doc) = doc {
graphics::validate_symbols(doc, p)?;
}
let label = label_of(id);
let n = &*source::resolve_for(doc, n, label, is_plane(id))?;
let effects = &n.appearance.effects;
let index = match at {
(None, Some(index)) => index,
_ => solid(effects).map_or(effects.len(), |(index, _)| index),
};
let before = effects.get(..index).unwrap_or(effects);
if is_container(n) {
let shapes = members(n, label)?;
let mut shapes = members(n, label)?;
reshape_members(n, before, &mut shapes);
let b = union_bounds(&shapes).ok_or_else(|| format!("{label} requires a group holding a shape with at least two anchors"))?;
shapes.iter().try_for_each(|s| facets(id, &s.path, s.rule, b, p).map(|_| ()))?;
check_solids(id, p, &shapes)?;
return cast_shadow(id, p, &shapes).map(|_| ());
}
let (path, rule) = object_profile(n, label)?;
let b = path.bounds().ok_or_else(|| format!("{label} requires a shape with at least two anchors"))?;
facets(id, &path, rule, b, p)?;
let anchors = || format!("{label} requires a shape with at least two anchors");
path.bounds().ok_or_else(anchors)?;
let ctx = GeomContext::of(n);
// With its shadow on, the shadow of the object's solids (or of the item's) must fit too.
let items = match item.and_then(|i| n.appearance.items.get(i)) {
Some(item) => vec![item.clone()],
None => solid_paints(n),
let (path, items) = match at.0.and_then(|i| Some((n.appearance.items.get(i)?, at.1.unwrap_or(usize::MAX)))) {
Some((item, index)) => {
let path = geometry(&path, effects, &ctx);
(geometry(&path, item.effects().get(..index).unwrap_or(item.effects()), &ctx.item(item)), vec![item.clone()])
}
None => (geometry(&path, before, &ctx), solid_paints(n)),
};
let b = path.bounds().ok_or_else(anchors)?;
facets(id, &path, rule, b, p)?;
let shapes = [Profile { path, rule, items, opacity: 1.0, scopes: vec![] }];
check_solids(id, p, &shapes)?;
cast_shadow(id, p, &shapes).map(|_| ())
@@ -468,26 +485,28 @@ pub fn validate_three_d_in(doc: Option<&Document>, n: &Node, id: &str, p: &Value
if crate::revolve::is_revolve(id) { crate::revolve::validate_in(doc, n, id, p) } else { validate_solid_in(doc, n, id, p) }
}
/// [`validate_three_d`] for an effect at `(item, index)`, index `index` of the effects of
/// appearance item `item` of `n` (the effects' length for a new effect), or on the object itself
/// when `at` is `None`. A Revolve on a fill or stroke is checked in that item's paint, after the
/// item's geometry effects before it, together with the object's other items that carry a Revolve.
/// A group's or layer's fills and strokes take no 3D effect: only the group's own effect draws.
pub fn validate_three_d_at(n: &Node, id: &str, p: &Value, at: Option<(usize, usize)>) -> Result<(), String> {
/// [`validate_three_d`] for an effect at `(item, index)`: index `index` of the effects of
/// appearance item `item` of `n`, or of the object itself when `item` is `None` (the effects'
/// length for a new effect). An Extrude & Bevel, Inflate or Rotate is checked on the shapes after
/// the geometry effects before that index. A Revolve on a fill or stroke is checked in that item's
/// paint, after the item's geometry effects before it, together with the object's other items that
/// carry a Revolve. A group's or layer's fills and strokes take no 3D effect: only the group's own
/// effect draws.
pub fn validate_three_d_at(n: &Node, id: &str, p: &Value, at: (Option<usize>, usize)) -> Result<(), String> {
validate_three_d_at_in(None, n, id, p, at)
}
/// [`validate_three_d_at`] with the symbols of `doc` ([`validate_solid_in`]). A symbol instance, a
/// blend and a clipping group take a 3D effect as groups do: on the object itself.
pub fn validate_three_d_at_in(doc: Option<&Document>, n: &Node, id: &str, p: &Value, at: Option<(usize, usize)>) -> Result<(), String> {
pub fn validate_three_d_at_in(doc: Option<&Document>, n: &Node, id: &str, p: &Value, at: (Option<usize>, usize)) -> Result<(), String> {
graphics::validate(id, p)?;
match at {
Some(_) if is_container(n) || source::resolves(n) => {
(Some(_), _) if is_container(n) || source::resolves(n) => {
Err("A 3D effect on a group or layer goes on the group itself, not on its fill or stroke".into())
}
Some((item, index)) if crate::revolve::is_revolve(id) => crate::revolve::validate_item_of(n, id, p, item, index),
Some((item, _)) => validate_solid_for(doc, n, id, p, Some(item)),
None => validate_three_d_in(doc, n, id, p),
(Some(item), index) if crate::revolve::is_revolve(id) => crate::revolve::validate_item_of(n, id, p, item, index),
(None, _) if crate::revolve::is_revolve(id) => crate::revolve::validate_in(doc, n, id, p),
(item, index) => validate_solid_for(doc, n, id, p, (item, Some(index))),
}
}
+39
View File
@@ -450,6 +450,45 @@ fn a_fill_and_strokes_together_past_the_limit_draw_the_source() {
assert!(solid_art(&n).is_none(), "the source art draws");
}
/// The check builds the solids from the shapes the art draws them from: after the geometry effects
/// before the 3D effect, on an object, on its fill and on a group. Copies a Transform effect before
/// it makes that take the solids past the limit fail the check, as the art then draws the source.
#[test]
fn the_check_counts_the_faces_after_the_geometry_effects_before() {
let mut alone = polygon_node(1, 16_384);
alone.appearance.effects.push(fx(EXTRUDE, json!({})));
let [Ok(Some(each))] = solid_faces(EXTRUDE, &alone, MAX_OBJECT_FACES)[..] else { panic!("one solid") };
// Side by side, each copy builds the same solid.
let copies = |k: usize| fx("distort.transform", json!({"moveH": 30_000.0, "copies": k}));
for (k, fits) in [(1, true), (MAX_OBJECT_FACES / each, false)] {
let mut n = polygon_node(1, 16_384);
n.appearance.effects = vec![copies(k), fx(EXTRUDE, json!({}))];
assert_eq!(validate_solid(&n, EXTRUDE, &json!({})).is_ok(), fits, "on the object, {k} copies");
assert_eq!(solid_art(&n).is_some(), fits, "on the object, {k} copies");
let mut f = polygon_node(1, 16_384);
f.appearance.items[0].effects_mut().extend([copies(k), fx(EXTRUDE, json!({}))]);
assert_eq!(validate_three_d_at(&f, EXTRUDE, &json!({}), (Some(0), 1)).is_ok(), fits, "on the fill, {k} copies");
assert_eq!(solid_art(&f).is_some(), fits, "on the fill, {k} copies");
let mut g = Node::group(NodeId(9999), vec![Arc::new(polygon_node(1, 16_384))]);
g.appearance.effects = vec![copies(k), fx(EXTRUDE, json!({}))];
assert_eq!(validate_solid(&g, EXTRUDE, &json!({})).is_ok(), fits, "on a group, {k} copies");
assert_eq!(solid_art(&g).is_some(), fits, "on a group, {k} copies");
// A new effect goes after the copies.
let mut new = polygon_node(1, 16_384);
new.appearance.effects = vec![copies(k)];
assert_eq!(validate_three_d_at(&new, EXTRUDE, &json!({}), (None, 1)).is_ok(), fits, "a new effect, {k} copies");
}
// A hidden effect checked at its own index counts only the effects before it: copies after it
// copy the drawn art once it is shown again.
let mut hidden = polygon_node(1, 16_384);
let mut extrude = fx(EXTRUDE, json!({}));
extrude.visible = false;
hidden.appearance.effects = vec![extrude, copies(MAX_OBJECT_FACES / each)];
assert!(validate_three_d_at(&hidden, EXTRUDE, &json!({}), (None, 0)).is_ok());
hidden.appearance.effects[0].visible = true;
assert!(solid_art(&hidden).is_some());
}
/// At no volume Inflate's front is flat: turned, the fill's cells are all drawn before the stroke's,
/// whatever their depths, as the extrusion's caps are. When the fill puffs, a stroke too narrow to
/// puff on the grid stays flat and keeps its depths: the fill's puff is drawn over its far side.
+4 -3
View File
@@ -457,9 +457,10 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result<Value> {
for (target, at) in targets.iter().copied().zip(item.per_target(s, &targets)) {
let d = &s.doc()?.doc;
let n = d.node(target).ok_or(EngineError::NoNode(target))?;
// A new effect goes after the item's others.
// A new effect goes after the item's (or the object's) others.
let on = at.and_then(|at| at.effects_item(&n.appearance, C).ok()).flatten();
let on = on.and_then(|i| Some((i, n.appearance.items.get(i)?.effects().len())));
let on = on.and_then(|i| Some((Some(i), n.appearance.items.get(i)?.effects().len())));
let on = on.unwrap_or((None, n.appearance.effects.len()));
effects::validate_three_d_at_in(Some(d), n, id, &effect.params, on).map_err(|e| bad(C, e))?;
if let Some(name) = effects::missing_symbol(d, &effect.params, None) {
return Err(bad(C, format!("no symbol named `{name}`")));
@@ -772,7 +773,7 @@ fn set_params(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(C, format!("no symbol named `{name}`")));
}
if visible != Some(false) {
effects::validate_three_d_at_in(Some(d), n, to, &prospective, target.map(|item| (item, index))).map_err(|e| bad(C, e))?;
effects::validate_three_d_at_in(Some(d), n, to, &prospective, (target, index)).map_err(|e| bad(C, e))?;
} else {
// Hidden, the effect draws nothing, so its object need not fit. The values given are
// checked, and after a change of 3D type so is every value it keeps.
+1 -1
View File
@@ -131,7 +131,7 @@ fn map_graphic(s: &mut Session, p: &Value) -> Result<Value> {
graphic.scale = frame.fitting_scale(art);
}
let params = effects::with_graphic(&effects::merged_params(&effect.id, &effect.params), &graphic);
effects::validate_three_d_at_in(Some(d), n, &effect.id, &params, item.map(|i| (i, index))).map_err(|e| bad(C, e))?;
effects::validate_three_d_at_in(Some(d), n, &effect.id, &params, (item, index)).map_err(|e| bad(C, e))?;
edits.push((id, item, index, graphic));
}
let first = edits.first().map(|(.., g)| g.to_value());