From f3cff033ca7ff780bb2063275caea477f41180b8 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 14:15:22 -0800 Subject: [PATCH 1/4] Edit: a command that ends a drag keeps its unapplied preview With Real-time Drawing off, a tool stores its latest preview and applies it only when the drag is released. A command that ends the drag first (Undo, Redo, a filter that calls commands, a plug-in command that calls commands, and the other callers of settle_interaction) committed the interaction without that preview, so the drag was dropped: Undo then took back the step before it, and a calling filter was given the objects where they were before the drag. settle_interaction now applies such a preview first, as the tool's own commit does, and cancels the drag when the preview fails. The re-prepare of a calling filter's input after settling is now reached, and tests cover it at both plugin.run and plugin... --- crates/engine/src/cmd/edit.rs | 8 +++-- crates/engine/src/tests/plugins.rs | 57 ++++++++++++++++++++++++++++++ crates/engine/src/tests/prefs.rs | 36 +++++++++++++++++++ docs/target-app-parity.md | 1 + 4 files changed, 100 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 7a8a91f0a..93b7ab12b 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -672,10 +672,14 @@ fn undo_group_end(s: &mut Session, p: &Value) -> Result { /// the step before. As any new edit it ends the steps to redo, so Redo never swaps the document /// under an interaction that would then preview on, or cancel to, the document from before (#1719). /// A typing session also ends in the Type tool, whose caret and marked text (IME) must follow the -/// document. +/// document. A preview that Real-time Drawing off left unapplied is applied first, as the tool's own +/// commit applies it; when it fails, the interaction is canceled. pub(crate) fn settle_interaction(s: &mut Session) -> Result<()> { let typing = typing_in_progress(s); - s.commit_interaction()?; + match s.unapplied_interaction_preview().filter(|_| !s.previewing) { + Some((cmd, p)) if s.preview(&cmd, &p).is_err() => s.cancel_interaction()?, + _ => s.commit_interaction()?, + } if typing { s.set_tool_option("endTyping", &Value::Bool(true)); } diff --git a/crates/engine/src/tests/plugins.rs b/crates/engine/src/tests/plugins.rs index a4be6e554..a4861e90c 100644 --- a/crates/engine/src/tests/plugins.rs +++ b/crates/engine/src/tests/plugins.rs @@ -3631,3 +3631,60 @@ fn a_filter_that_calls_commands_is_given_the_edit_in_progress() { assert_eq!(x, json!(17.0), "undoing the filter brings back the moved rectangle"); run(&mut s, "plugin.remove", json!({"id": id})); } + +/// Installs a filter `id` that calls commands and hands back what it is given, with `commands` (a +/// JSON list of command specs) in its manifest. +fn install_echo_calling(s: &mut Session, id: &str, commands: &str) { + let grant = json!(["doc.read", "doc.write", "commands"]); + let manifest = format!( + r#"{{"id":"{id}","name":"Test {id}","kind":"filter","api":2,"permissions":{grant},"commandAllowlist":["select.all"],"commands":{commands}}}"# + ); + let wasm = wat::parse_str(tpl::module(&manifest, tpl::ECHO, "", 1)).unwrap(); + run(s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&wasm), "grant": grant, "replace": true})); +} + +/// With Real-time Drawing off, a 50 × 50 rectangle at (10, 10) whose move by 7 pt is still being +/// dragged: the tool has stored the move, which the document does not hold yet. +fn deferred_move(s: &mut Session) -> NodeId { + let r = NodeId(run(s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50}))["id"].as_u64().unwrap()); + run(s, "prefs.set", json!({"key": "realTimeDrawing", "value": false})); + let move_by = vectorcraft_tools::Action::Preview("object.move".into(), json!({"dx": 7, "dy": 0})); + s.apply_actions(vec![vectorcraft_tools::Action::Begin("Move".into()), move_by]).unwrap(); + assert_eq!(node(s, r).geometric_bounds().unwrap().x0, 10.0, "the move is not applied while it is dragged"); + r +} + +/// The rectangle a run named `label` during [`deferred_move`] left: moved, with the interaction +/// ended and the move an undo step of its own before the run's. +fn assert_moved_once(s: &Session, r: NodeId, label: &str) { + assert!(!s.in_interaction()); + assert_eq!(node(s, r).geometric_bounds().unwrap().x0, 17.0, "the filter was given the moved rectangle"); + let labels: Vec<&str> = s.doc().unwrap().history.undo.iter().rev().take(2).map(|e| e.label.as_str()).collect(); + assert_eq!(labels, [label, "Move"]); +} + +/// With Real-time Drawing off, `plugin.run` of a filter that calls commands during a drag applies +/// the dragged move first and gives the filter the moved rectangle, which it hands back as it was. +#[test] +fn a_filter_that_calls_commands_is_given_a_deferred_move() { + let id = "test.engine.callsdeferred"; + let mut s = session(); + install_echo_calling(&mut s, id, "[]"); + let r = deferred_move(&mut s); + run(&mut s, "plugin.run", json!({"id": id})); + assert_moved_once(&s, r, &format!("Test {id}")); + run(&mut s, "plugin.remove", json!({"id": id})); +} + +/// A plug-in command that calls commands, run with Real-time Drawing off during a drag, is given +/// the moved rectangle as `plugin.run` is. +#[test] +fn a_plugin_command_that_calls_commands_is_given_a_deferred_move() { + let id = "test.engine.callsdeferredcmd"; + let mut s = session(); + install_echo_calling(&mut s, id, r#"[{"id":"same","label":"Same"}]"#); + let r = deferred_move(&mut s); + run(&mut s, &format!("plugin.{id}.same"), json!({})); + assert_moved_once(&s, r, "Same"); + run(&mut s, "plugin.remove", json!({"id": id})); +} diff --git a/crates/engine/src/tests/prefs.rs b/crates/engine/src/tests/prefs.rs index ed37d0969..14669fd35 100644 --- a/crates/engine/src/tests/prefs.rs +++ b/crates/engine/src/tests/prefs.rs @@ -2047,6 +2047,42 @@ fn real_time_drawing_turned_on_mid_drag_still_commits_deferred_preview() { assert_eq!(paths(&s), 1, "the deferred rectangle is committed"); } +/// With Real-time Drawing off, Undo during a drag keeps the dragged move as its own step and takes +/// back only that: the rectangle drawn before stays, and Redo brings the move back. +#[test] +fn undo_during_a_deferred_drag_takes_back_only_the_drag() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let r = NodeId(s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap()); + set_pref(&mut s, "realTimeDrawing", json!(false)); + let x = |s: &Session| s.doc().unwrap().doc.node(r).and_then(vectorcraft_doc::Node::geometric_bounds).map(|b| b.x0); + let move_by = vectorcraft_tools::Action::Preview("object.move".into(), json!({"dx": 7, "dy": 0})); + s.apply_actions(vec![vectorcraft_tools::Action::Begin("Move".into()), move_by]).unwrap(); + assert_eq!(x(&s), Some(10.0)); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(!s.in_interaction()); + assert_eq!(x(&s), Some(10.0), "the rectangle stays"); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(x(&s), Some(17.0), "Redo brings the move back"); +} + +/// Undo during a drag whose stored preview fails cancels the drag, as its release would, and then +/// takes back the step before it. +#[test] +fn undo_during_a_deferred_drag_that_fails_cancels_it() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap(); + set_pref(&mut s, "realTimeDrawing", json!(false)); + let bad = vectorcraft_tools::Action::Preview("no.such.command".into(), json!({})); + s.apply_actions(vec![vectorcraft_tools::Action::Begin("Bad".into()), bad]).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(!s.in_interaction()); + let d = s.doc().unwrap(); + assert_eq!(d.doc.layers.iter().map(|l| l.children().map_or(0, |c| c.len())).sum::(), 0, "the rectangle is undone"); + assert!(d.history.undo.iter().all(|e| e.label != "Bad")); +} + /// Repeated overlay redraws with Real-time Drawing off reuse the cached deferred feedback. #[test] fn deferred_preview_overlays_are_cached_across_redraws() { diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b4c949f74..609ce35a9 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1661,6 +1661,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **A plug-in with no permissions ends its id's grant (G17):** installing a module that lists no permissions under a granted id forgets the grant, in the process and in `grants.json`, so the formerly approved bytes need a new approval. Attaching a `Plug-ins` folder whose module for that id lists no permissions forgets it too, and plug-in folders forget the process's grant. A refused install leaves the grant. Regressions: `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_safe_mode.rs`. Scores and hours unchanged. - **Plug-in commands (G17):** a filter or live effect may declare up to 32 `commands` (`{id, label, params?}`); each is the command `plugin..` while the plug-in is installed, in a submenu named after the plug-in under Object › Plug-ins (Effect › Plug-ins for a live effect) whatever menu the manifest names, with its label escaped. Commands are recorded in actions, listed in Keyboard Shortcuts for the user to give a shortcut, and run through `run` and MCP with their parameters checked first (a command with parameters opens a dialog from its menu item or shortcut). A run uses the plug-in's permissions (a live effect with commands may also hold `doc.write` and `state`, and gets them without a `permissions` list), is one undo step named after the command, and on failure (a trap or an exhausted instruction budget) leaves the document and the undo history as they were with an error naming the plug-in and the command. For a command and for `plugin.run`, an `ids` that is not a list of object ids is an error rather than the selection. A malformed entry is refused by name when the module is installed. Regressions: `crates/plugins/src/manifest.rs`, `crates/plugins/src/permissions.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged. - **Filter plug-ins call commands (G17, #2186):** a filter at plug-in API level 2 with the `commands` permission names the commands it may run in its manifest's `commandAllowlist` and runs them with `env.vc_call`, reading each result with `env.vc_call_result`. Each call sees what the calls before it changed; the calls and the filter's output are one undo step, and a run that fails, is canceled or makes a call that fails leaves every document, its selection and its history as they were. Commands that read or write files, use the clipboard, change preferences or the view, run scripts, actions or plug-ins, switch documents or undo are never called, and a manifest that lists one is refused when the module is installed. The install dialog lists the allowed commands. A run makes at most 1,000 calls, and each call costs 10,000 instructions of its budget. Regressions: `crates/plugins/tests/call.rs`, `crates/engine/tests/plugin_call.rs`, `crates/engine/src/cmd/plugin_call.rs`. Scores and hours unchanged. +- **A drag ended by a command with Real-time Drawing off:** when Undo, Redo, a filter that calls commands or a plug-in command that calls commands ends a drag whose preview Real-time Drawing off has not applied yet, the preview is applied first and kept as an undo step of its own, as releasing the drag keeps it; a preview that fails cancels the drag. The filter or plug-in command is then given the objects where the drag left them. Regressions: `crates/engine/src/tests/plugins.rs`, `crates/engine/src/tests/prefs.rs`. Scores and hours unchanged. - **Plug-in progress, Cancel and log lines (G17):** at plug-in API level 2 a plug-in with the `progress` permission imports `env.vc_progress`, `env.vc_cancelled` and `env.vc_log`. A heavy filter's and an export's progress windows show its progress as a percentage bar, their Cancel reaches it through `vc_cancelled`, and its log lines go to the app's log with its id, capped per line and per run. Installing checks every import and names the one it refuses. Regressions: `crates/plugins/src/host.rs`, `crates/plugins/tests/abi.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugin_runs.rs`. Scores and hours unchanged. - **Live-effect plug-ins paint their output (G17, #121):** a live effect whose manifest has `"api": 2` and `"paintOutput": true` returns pieces, each drawn by itself in order with fills and strokes of its own in the filter's paint forms (a piece without them keeps the object's; an empty list paints none; an effect on a fill returns only fills, one on a stroke only strokes). The whole result is checked first: limits, numbers in range with nothing clamped, known keys only, and the swatches and patterns it names in the document; one fault draws that step of the stack unchanged, keeps the effect record and puts the plug-in's name and the field in `plugin.info`'s `lastError`. The pieces are drawn, measured and exported to PDF and SVG from the appearance stack without changing the document, and Expand Appearance writes them as ordinary paths in one undo step. Effects without the key behave as before. Regressions: `crates/plugins/src/painted/tests.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`. Scores and hours unchanged. - **Live-effect plug-ins while a document opens (G17):** the live-effect plug-in runs of a document's first view on screen share a 2,000 ms budget (VectorCraft's own value). A stage left out for time, or one that times out, fails, has no plug-in or meets Safe Mode, draws its input unchanged and is deferred; the status bar says how many, `effect.deferredStatus` lists them with their object, effect and reason, and Effect › Refresh Deferred Effects (`effect.refreshDeferred`) runs them again with a fresh budget. Each document has its own budget, an edit runs the stages it changes, and exports, Expand Appearance and commands run every stage under the 5 s limit of each run. The web build times the budget and each run's limit with the page's `performance.now()`. Regressions: `crates/plugins/src/deferred.rs`, `crates/plugins/src/effect.rs`, `crates/plugins/src/host.rs`, `crates/engine/src/tests/plugins.rs`, `crates/ui-egui/src/canvas/tests.rs`. Scores and hours unchanged. From a3545036834833840c525fae9031fff37a4bdf32 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 14:54:26 -0800 Subject: [PATCH 2/4] JavaScript: XML namespace lookups take fuel for each ancestor they climb `inScopeNamespaces()`, `namespaceDeclarations()` and `namespace()` walk from the node up to its root. The walk cloned each ancestor's whole node, children and attributes included, to read its namespaces, and charged fuel only for the namespaces it found, so a deep tree with no declared namespaces cost no fuel. It now takes one unit of fuel per ancestor, copies only the namespace list and parent link, and checks prefixes already seen with a set. `namespaceDeclarations()` compares the node's declarations with the ancestors' through a map instead of a scan. --- crates/js/src/xml_builtins.rs | 24 +++++++++++++++--------- crates/js/src/xml_tests.rs | 10 ++++++++++ 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/crates/js/src/xml_builtins.rs b/crates/js/src/xml_builtins.rs index 07e2a1d12..c74b9fac2 100644 --- a/crates/js/src/xml_builtins.rs +++ b/crates/js/src/xml_builtins.rs @@ -1,6 +1,7 @@ //! The E4X built-ins (ECMA-357 sections 13.1 to 13.5): `XML`, `XMLList`, `Namespace`, `QName`, //! `isXMLName` and `setDefaultXMLNamespace`. A realm has them only with [`crate::Options::e4x`]. +use std::collections::{HashMap, HashSet}; use std::rc::Rc; use crate::builtins::{arg, constructor, data, method, native}; @@ -662,19 +663,25 @@ fn has_simple_content(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> } } -/// The namespaces in scope at `x`, nearest declarations first, one for each prefix. +/// The namespaces in scope at `x`, nearest declarations first, one for each prefix. Takes one unit +/// of fuel for each node it climbs through and for each declaration it reads. fn scope_of(interp: &mut Interp, x: Option) -> JsResult> { let mut out: Vec = Vec::new(); + let mut seen: HashSet> = HashSet::new(); let mut cur = x; while let Some(y) = cur { - let node = interp.xml_node(y)?.clone(); - for ns in &node.ns { + interp.use_fuel()?; + let (list, parent) = { + let node = interp.xml_node(y)?; + (node.ns.clone(), node.parent) + }; + for ns in list { interp.use_fuel()?; - if !out.iter().any(|n| n.prefix == ns.prefix) { - out.push(ns.clone()); + if seen.insert(ns.prefix.clone()) { + out.push(ns); } } - cur = node.parent; + cur = parent; } Ok(out) } @@ -702,9 +709,8 @@ fn namespace_declarations(interp: &mut Interp, this: Value, _: &[Value], _: bool return namespaces_array(interp, Vec::new()); } let parent = interp.xml_node(x)?.parent; - let ancestors = scope_of(interp, parent)?; - let declared: Vec = - interp.xml_node(x)?.ns.iter().filter(|ns| !ancestors.iter().any(|a| a.prefix == ns.prefix && a.uri == ns.uri)).cloned().collect(); + let ancestors: HashMap, Key> = scope_of(interp, parent)?.into_iter().map(|a| (a.prefix, a.uri)).collect(); + let declared: Vec = interp.xml_node(x)?.ns.iter().filter(|ns| ancestors.get(&ns.prefix) != Some(&ns.uri)).cloned().collect(); namespaces_array(interp, declared) } diff --git a/crates/js/src/xml_tests.rs b/crates/js/src/xml_tests.rs index dce24d9d9..cfe508a48 100644 --- a/crates/js/src/xml_tests.rs +++ b/crates/js/src/xml_tests.rs @@ -740,6 +740,16 @@ fn insert_child_before_and_after_take_fuel_for_each_child_they_scan() { } } +#[test] +fn namespace_lookups_take_fuel_for_each_ancestor_they_climb() { + let shallow = "var x = ; var leaf = x.children()[0];"; + let deep = "var x = ; var leaf = x; for (var i = 0; i < 150; i++) { var c = ; leaf.appendChild(c); leaf = c; }"; + for action in ["leaf.inScopeNamespaces()", "leaf.namespaceDeclarations()", "leaf.namespace()", "leaf.namespace('p')"] { + let (s, d) = (fuel_of(shallow, action), fuel_of(deep, action)); + assert!(d >= s + 149, "{action}: {s} one level down, {d} 150 levels down"); + } +} + #[test] fn limits_cannot_be_caught_by_the_script() { assert_eq!(kind_of(TIGHT, "try { for (;;) ().toXMLString(); } catch (e) { 1 }"), RunErrorKind::Limit(LimitKind::Fuel)); From 5e78aa65759ec9e4e1d2278ea4932298a2b57a4f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 15:26:05 -0800 Subject: [PATCH 3/4] 3D: a stroke's walls cover the fill's walls at concave corners Extrude & Bevel (both kinds) and Inflate drew the faces of the fill's solid and the stroke's solid in one order by each face's center depth. At a concave corner a long fill wall could sort after a stroke wall in front of it, so stripes of the fill showed through the stroke. - Facets now record whether they are side faces (walls, bevels, Inflate's sides) and whether they are turned away from the viewer. - With caps on, each fill side face turned toward the viewer is drawn before every face of the same shape's stroke whose bounds overlap it, for strokes whose solid hides the fill's whole edge: a closed shape, a width, no dashes, width profile or brush, and a solid color or a gradient without transparent stops. Caps, other shapes in a group, hollow effects and translucent or blended surfaces keep their order. Docs: docs/three-d.md (Fills and strokes), docs/target-app-parity.md. --- crates/effects/src/solids.rs | 163 +++++++++++++++++++++++++---- crates/effects/src/solids/tests.rs | 131 +++++++++++++++++++++++ crates/three-d/src/solid.rs | 6 ++ crates/three-d/src/solid/build.rs | 7 ++ crates/three-d/src/solid/tests.rs | 27 +++++ docs/target-app-parity.md | 1 + docs/three-d.md | 7 +- 7 files changed, 319 insertions(+), 23 deletions(-) diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index be6c68ba7..51e906857 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -516,8 +516,8 @@ fn region(path: &PathData, rule: FillRule, item: &AppearanceItem) -> (PathData, } /// One part of [`solid_parts`]: a fill or stroke, its shape's opacity, the region it paints and its -/// fill rule, and its shape's scopes. -type Part<'a> = (&'a AppearanceItem, f32, PathData, FillRule, &'a [Scope]); +/// fill rule, its shape's scopes and the index of its shape. +type Part<'a> = (&'a AppearanceItem, f32, PathData, FillRule, &'a [Scope], usize); /// The parts Extrude & Bevel and Inflate build solids from: each shape's top fill and its strokes, /// a group's members from the back up, each with its shape's opacity, the region it paints and its @@ -526,18 +526,128 @@ type Part<'a> = (&'a AppearanceItem, f32, PathData, FillRule, &'a [Scope]); fn solid_parts(shapes: &[Profile], fallback: Rect) -> (Vec>, Rect) { let parts: Vec = shapes .iter() - .flat_map(|s| { + .enumerate() + .flat_map(|(member, s)| { let top_fill = s.items.iter().rposition(|i| matches!(i, AppearanceItem::Fill(_))); - s.items.iter().enumerate().filter(move |(k, i)| Some(*k) == top_fill || matches!(i, AppearanceItem::Stroke(_))).map(|(_, i)| { + s.items.iter().enumerate().filter(move |(k, i)| Some(*k) == top_fill || matches!(i, AppearanceItem::Stroke(_))).map(move |(_, i)| { let (area, rule) = region(&s.path, s.rule, i); - (i, s.opacity, area, rule, s.scopes.as_slice()) + (i, s.opacity, area, rule, s.scopes.as_slice(), member) }) }) .collect(); - let bounds = parts.iter().filter_map(|(_, _, area, _, _)| area.bounds()).reduce(|a, c| a.union(c)).unwrap_or(fallback); + let bounds = parts.iter().filter_map(|(_, _, area, _, _, _)| area.bounds()).reduce(|a, c| a.union(c)).unwrap_or(fallback); (parts, bounds) } +/// What a face of one of the solids [`art_for`] draws in one order was built from: the facet's +/// depth, whether it is a side face and whether it is turned away from the viewer, the bounds of +/// its polygon, the index of its shape, and whether its solid is that shape's fill or one of its +/// strokes that covers the fill's whole edge. +struct Placed { + depth: f64, + side: bool, + away: bool, + bounds: Rect, + member: usize, + paints: Paints, +} + +/// The part of its shape a face's solid is built from ([`Placed`]). +#[derive(Clone, Copy, PartialEq, Eq)] +enum Paints { + Fill, + /// A stroke whose solid holds the fill's whole edge and hides it: one with a width, without + /// dashes, a width profile or a brush, painted with a solid color or a gradient without + /// transparent stops, on a shape whose subpaths are all closed. + Stroke, + Other, +} + +impl Paints { + /// What `item` of a shape is, the shape's subpaths all closed when `closed`. + fn of(item: &AppearanceItem, closed: bool) -> Self { + match item { + AppearanceItem::Fill(_) => Paints::Fill, + AppearanceItem::Stroke(s) + if closed + && s.width > 0.0 + && s.width.is_finite() + && s.dash.is_none() + && s.profile.is_none() + && s.brush.is_none() + && match &s.paint { + Paint::Solid { .. } => true, + Paint::Gradient(_) => crate::lighting::opaque_paint(&s.paint).1.is_none(), + _ => false, + } => + { + Paints::Stroke + } + AppearanceItem::Stroke(_) => Paints::Other, + } + } +} + +impl Placed { + /// The face of `f`, of shape `member`'s part `paints`. + fn of(f: &Facet, member: usize, paints: Paints) -> Self { + let mut points = f.contours.iter().flatten(); + let bounds = points.next().map_or(Rect::ZERO, |p| points.fold(Rect::from_points(*p, *p), |r, q| r.union_pt(*q))); + Placed { depth: f.depth, side: f.side, away: f.away, bounds, member, paints } + } +} + +/// The most bounds comparisons [`draw_order`] makes; the fill walls it has not reached by then keep +/// their depths. +const MAX_OVERLAP_CHECKS: usize = 4_000_000; + +/// The order faces `faces` are drawn in, back to front, as indices into `faces`: by depth (faces +/// at the same depth in the order given), except that with `sealed` (solids with caps) a side face +/// of a shape's fill turned toward the viewer is drawn before each face of the same shape's +/// covering strokes ([`Paints::Stroke`]), turned toward the viewer, whose bounds overlap its own. +/// Such a stroke's closed solid holds the fill's walls, so they never show and can be drawn under +/// any other face. +fn draw_order(faces: &[Placed], sealed: bool) -> Vec { + // Each face's depth to sort by, and whether it was moved before a stroke face at that depth. + let mut keys: Vec<(f64, bool)> = faces.iter().map(|f| (f.depth, false)).collect(); + if sealed { + // The stroke faces turned toward the viewer of each shape, nearest last. + let mut strokes: Vec> = vec![]; + for f in faces.iter().filter(|f| f.paints == Paints::Stroke && !f.away) { + if strokes.len() <= f.member { + strokes.resize_with(f.member + 1, Vec::new); + } + strokes[f.member].push(f); + } + for list in &mut strokes { + list.sort_by(|a, b| a.depth.total_cmp(&b.depth)); + } + let overlap = |a: &Rect, b: &Rect| a.x0 < b.x1 && b.x0 < a.x1 && a.y0 < b.y1 && b.y0 < a.y1; + let mut checks = 0usize; + for (f, key) in faces.iter().zip(&mut keys) { + if f.paints != Paints::Fill || !f.side || f.away { + continue; + } + let Some(list) = strokes.get(f.member).filter(|l| !l.is_empty()) else { continue }; + for s in list.iter().take_while(|s| s.depth <= f.depth) { + if checks >= MAX_OVERLAP_CHECKS { + break; + } + checks += 1; + if overlap(&s.bounds, &f.bounds) { + *key = (s.depth, true); + break; + } + } + } + } + let mut order: Vec = (0..faces.len()).collect(); + // A stable sort: faces at the same depth keep the order of the appearance, and a fill's side + // face moved to a stroke face's depth goes before it. + order.sort_by(|&a, &b| keys[a].0.total_cmp(&keys[b].0).then(keys[b].1.cmp(&keys[a].1))); + order +} + /// `shapes`, the members of group or layer `n`, reshaped by the visible geometry effects `before` /// its 3D effect, about the bounds of all of them. pub(crate) fn reshape_members(n: &Node, before: &[Effect], shapes: &mut [Profile]) { @@ -556,7 +666,9 @@ pub(crate) fn reshape_members(n: &Node, before: &[Effect], shapes: &mut [Profile /// solid from the path for the top visible fill and one from the outline of each visible stroke, /// each in its own paint. When every one is opaque with normal blending and no colored light makes /// a surface one composite or masks it, their faces are drawn in one back-to-front order (where -/// depths tie, as for the caps turned toward the viewer, in the order of the appearance); +/// depths tie, as for the caps turned toward the viewer, in the order of the appearance, and with +/// caps, a shape's fill walls before the faces of its covering strokes that overlap them: +/// [`draw_order`]); /// otherwise each solid is a surface of its own, in that order. A member's opacity mask and /// blending mode, and those of a group it is in, apply to the surfaces of the members it holds /// ([`masked`]). @@ -588,17 +700,17 @@ fn art_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option>> let (parts, bounds) = solid_parts(shapes, b); let built = if fx.id == INFLATE { // One eye for every puff, as far away as the highest needs. - let shapes: Vec<(&PathData, FillRule)> = parts.iter().map(|(_, _, area, rule, _)| (area, *rule)).collect(); + let shapes: Vec<(&PathData, FillRule)> = parts.iter().map(|(_, _, area, rule, _, _)| (area, *rule)).collect(); vectorcraft_three_d::inflate_together(&shapes, bounds, inflate_options(&p), view_options(&p)) } else { - parts.iter().map(|(_, _, area, rule, _)| facets(&fx.id, area, *rule, bounds, &p)).collect() + parts.iter().map(|(_, _, area, rule, _, _)| facets(&fx.id, area, *rule, bounds, &p)).collect() }; let mut solids = vec![]; - for ((item, opacity, _, _, scopes), f) in parts.iter().zip(built) { + for ((item, opacity, _, _, scopes, member), f) in parts.iter().zip(built) { let item: &AppearanceItem = item; match f { Ok(f) if !crate::revolve::paint_copies_add(&mut paint_bytes, facet_copies(&f, paint_ref(item), colored), paint_ref(item)) => return None, - Ok(f) => solids.push((item, *opacity, *scopes, f)), + Ok(f) => solids.push((item, *opacity, *scopes, f, *member)), // A stroke's outline past the limits is left out; the fill's solid still draws. Err(_) if matches!(item, AppearanceItem::Stroke(_)) => {} // A loaded file or a shape edited past the limits keeps drawing its source path @@ -610,14 +722,16 @@ fn art_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option>> if solids.is_empty() { return None; } - let opaque = solids.iter().all(|(item, member, _, _)| { + let opaque = solids.iter().all(|(item, member, _, _, _)| { let (_, opacity, blend) = paint_of(item, *member); opacity >= 1.0 && blend == BlendMode::Normal }); - let surfaces: Vec<(Vec, Node, &[Scope])> = solids + let surfaces: Vec<(Vec, Node, &[Scope])> = solids .into_iter() - .map(|(item, member, scopes, f)| { - let depths: Vec = f.iter().map(|f| f.depth).collect(); + .map(|(item, member, scopes, f, shape)| { + let closed = shapes.get(shape).is_some_and(|s| s.path.subpaths.iter().all(|p| p.closed)); + let paints = Paints::of(item, closed); + let depths: Vec = f.iter().map(|f| Placed::of(f, shape, paints)).collect(); let (paint, opacity, blend) = paint_of(item, member); let art = if classic { classic::surface(n, f, &paint, opacity, blend, &p) } else { surface(n, f, &paint, opacity, blend, colored) }; (depths, art, scopes) @@ -635,15 +749,20 @@ fn art_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option>> if !opaque || !plain { return Some(surfaces.into_iter().map(|(_, s, _)| Arc::new(s)).collect()); } - let mut faces = vec![]; + let (mut placed, mut faces) = (vec![], vec![]); for (depths, s, _) in surfaces { if let NodeKind::Group { children, .. } = s.kind { - faces.extend(depths.into_iter().zip(children)); + placed.extend(depths); + faces.extend(children.into_iter().map(Some)); } } - // A stable sort: faces at the same depth keep the order of the appearance. - faces.sort_by(|a, b| a.0.total_cmp(&b.0)); - Some(vec![Arc::new(Node::group(n.id, faces.into_iter().map(|(_, face)| face).collect()))]) + let sealed = match fx.id.as_str() { + INFLATE => inflate_options(&p).cap, + CLASSIC_EXTRUDE => classic_extrude_options(&p).cap, + _ => extrude_options(&p).cap, + }; + let order = draw_order(&placed, sealed); + Some(vec![Arc::new(Node::group(n.id, order.into_iter().filter_map(|k| faces.get_mut(k).and_then(Option::take)).collect()))]) } fn geometry(path: &PathData, fx: &[Effect], ctx: &GeomContext) -> PathData { @@ -788,11 +907,11 @@ fn meshes_for(fx: &Effect, shapes: &[Profile], total: &mut usize, limit: usize) // Inflate lays out every puff at once (they share one eye); each Inflate and Extrude & Bevel // solid and its mesh are built as the loop reaches them. let mut inflated = (fx.id == INFLATE).then(|| { - let shapes: Vec<(&PathData, FillRule)> = parts.iter().map(|(_, _, area, rule, _)| (area, *rule)).collect(); + let shapes: Vec<(&PathData, FillRule)> = parts.iter().map(|(_, _, area, rule, _, _)| (area, *rule)).collect(); vectorcraft_three_d::inflate_meshes(&shapes, bounds, inflate_options(&p), view) }); let extrude = extrude_options(&p); - for (item, member, area, rule, _) in &parts { + for (item, member, area, rule, _, _) in &parts { let item: &AppearanceItem = item; let mesh = match inflated.as_mut() { Some(built) => built.next().unwrap_or_else(|| Ok(Mesh::default())), diff --git a/crates/effects/src/solids/tests.rs b/crates/effects/src/solids/tests.rs index 7273a44b2..248473f42 100644 --- a/crates/effects/src/solids/tests.rs +++ b/crates/effects/src/solids/tests.rs @@ -180,6 +180,137 @@ fn the_front_caps_are_drawn_last_in_the_order_of_the_appearance() { } } +/// A C, open to the right: two concave corners where its walls meet the walls of the notch. +fn letter_c() -> BezPath { + let mut b = BezPath::new(); + b.move_to((100.0, 100.0)); + b.line_to((200.0, 100.0)); + b.line_to((200.0, 130.0)); + b.line_to((130.0, 130.0)); + b.line_to((130.0, 170.0)); + b.line_to((200.0, 170.0)); + b.line_to((200.0, 200.0)); + b.line_to((100.0, 200.0)); + b.close_path(); + b +} + +/// The pairs (blue, red) of `walls`, by index, where the blue face is drawn first and the two +/// faces' bounds overlap. +fn red_after_blue(walls: &[&Node]) -> Vec<(usize, usize)> { + let bounds: Vec = walls.iter().map(|f| f.path_data().and_then(PathData::bounds).unwrap()).collect(); + let overlap = |a: &Rect, b: &Rect| a.x0 < b.x1 && b.x0 < a.x1 && a.y0 < b.y1 && b.y0 < a.y1; + let mut out = vec![]; + for (i, a) in walls.iter().enumerate() { + for (j, b) in walls.iter().enumerate().skip(i + 1) { + if blue(a) && !blue(b) && overlap(&bounds[i], &bounds[j]) { + out.push((i, j)); + } + } + } + out +} + +/// An opaque stroke's solid holds the fill's edge, so the fill's walls are drawn before every +/// face of the stroke they overlap, and the stroke's walls cover them at the concave corners of a +/// turned C, in Extrude & Bevel and Extrude & Bevel (Classic). The front caps stay last, the +/// fill's below the stroke's. +#[test] +fn a_fills_walls_are_drawn_before_the_stroke_faces_over_them() { + let views = [json!({}), json!({"rotationX": -20, "rotationY": -40, "rotationZ": 0}), json!({"rotationX": 25, "rotationY": -55, "rotationZ": 10})]; + for id in [EXTRUDE, CLASSIC_EXTRUDE] { + for view in &views { + let mut params = view.clone(); + params["depth"] = json!(60); + let mut n = stroked(&letter_c()); + n.appearance.effects.push(fx(id, params)); + let art = solid_art(&n).unwrap(); + let faces = faces(&art); + let [walls @ .., fill_cap, stroke_cap] = faces.as_slice() else { panic!("{id} {view}: the caps") }; + assert!(!blue(fill_cap) && blue(stroke_cap), "{id} {view}: the caps last"); + assert!(walls.iter().any(|f| !blue(f)) && walls.iter().any(|f| blue(f)), "{id} {view}: walls of both"); + let wrong = red_after_blue(walls); + assert!(wrong.is_empty(), "{id} {view}: fill walls after the stroke faces over them: {wrong:?} of {}", walls.len()); + } + } + // Strokes aligned inside or outside keep the fill's walls, in that order. A dashed stroke + // leaves the fill's walls in the gaps between its dashes, and they keep their depths. + use vectorcraft_doc::StrokeAlign; + for (dash, align) in [(true, StrokeAlign::Center), (false, StrokeAlign::Inside), (false, StrokeAlign::Outside)] { + let mut n = stroked(&letter_c()); + if let Some(AppearanceItem::Stroke(s)) = n.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Stroke(_))) { + s.dash = dash.then(|| vectorcraft_doc::Dash { pattern: vec![12.0, 12.0], offset: 0.0, align_corners: false }); + s.align = align; + } + n.appearance.effects.push(fx(EXTRUDE, views[1].clone())); + let art = solid_art(&n).unwrap(); + let faces = faces(&art); + let [walls @ .., _, _] = faces.as_slice() else { panic!("{align:?}: the caps") }; + assert!(walls.iter().any(|f| !blue(f)), "{align:?}: the fill's walls"); + if !dash { + assert!(red_after_blue(walls).is_empty(), "{align:?}"); + } + } +} + +/// The order keeps depths except where a shape's fill wall turned toward the viewer overlaps a +/// face of the same shape's stroke drawn before it: the wall then goes just before that face. A +/// cap, a face turned away, another shape's stroke, a stroke face the wall does not overlap and an +/// effect without caps keep the order of the depths. +#[test] +fn the_draw_order_moves_a_fill_wall_before_the_stroke_faces_it_overlaps() { + let at = |depth: f64, x: f64, member: usize, paints: Paints, side: bool, away: bool| Placed { + depth, + side, + away, + bounds: Rect::new(x, 0.0, x + 10.0, 10.0), + member, + paints, + }; + // 0 the fill's wall; 1 the stroke's wall over it; 2 another shape's stroke over it; 3 a stroke + // wall elsewhere; 4 a stroke face turned away; 5 a fill cap; 6 the stroke's cap, nearer; 7 a + // fill wall at the stroke wall's depth. + let faces = [ + at(5.0, 0.0, 0, Paints::Fill, true, false), + at(3.0, 5.0, 0, Paints::Stroke, true, false), + at(2.0, 5.0, 1, Paints::Stroke, true, false), + at(1.0, 50.0, 0, Paints::Stroke, true, false), + at(0.0, 0.0, 0, Paints::Stroke, true, true), + at(4.0, 0.0, 0, Paints::Fill, false, false), + at(6.0, 0.0, 0, Paints::Stroke, false, false), + at(3.0, 0.0, 0, Paints::Fill, true, false), + ]; + assert_eq!(draw_order(&faces, true), [4, 3, 2, 0, 7, 1, 5, 6]); + assert_eq!(draw_order(&faces, false), [4, 3, 2, 1, 7, 5, 0, 6]); + // A shape with no stroke, and a stroke without a width, move nothing. + // Only a stroke whose solid hides the fill's whole edge counts: an opaque, plain stroke with a + // width on a closed shape. + let stroke = |f: &dyn Fn(&mut StrokeLayer)| { + let mut s = StrokeLayer::new(Paint::solid(BLUE), 6.0); + f(&mut s); + AppearanceItem::Stroke(s) + }; + assert!(Paints::of(&stroke(&|_| {}), true) == Paints::Stroke); + assert!(Paints::of(&stroke(&|_| {}), false) == Paints::Other, "an open shape"); + assert!(Paints::of(&stroke(&|s| s.width = 0.0), true) == Paints::Other, "no width"); + let dash = vectorcraft_doc::Dash { pattern: vec![4.0, 4.0], offset: 0.0, align_corners: false }; + assert!(Paints::of(&stroke(&|s| s.dash = Some(dash.clone())), true) == Paints::Other, "dashes"); + assert!(Paints::of(&stroke(&|s| s.brush = Some("brush".into())), true) == Paints::Other); + assert!(Paints::of(&stroke(&|s| s.paint = Paint::None), true) == Paints::Other); + assert!(Paints::of(&stroke(&|s| s.paint = Paint::Pattern { pattern: "p".into(), xf: Affine::IDENTITY }), true) == Paints::Other, "a pattern"); + use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop}; + let gradient = |opacity: f32| { + let mut end = GradientStop::new(1.0, RED); + end.opacity = opacity; + Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, BLUE), end])))) + }; + assert!(Paints::of(&stroke(&|s| s.paint = gradient(1.0)), true) == Paints::Stroke, "an opaque gradient"); + assert!(Paints::of(&stroke(&|s| s.paint = gradient(0.5)), true) == Paints::Other, "a gradient with a transparent stop"); + let other = |p: Paints| if p == Paints::Stroke { Paints::Other } else { p }; + let unstroked: Vec = faces.iter().map(|f| at(f.depth, f.bounds.x0, f.member, other(f.paints), f.side, f.away)).collect(); + assert_eq!(draw_order(&unstroked, true), draw_order(&faces, false)); +} + /// A stroked open path builds a ribbon, the stroke's outline, not the area the path would enclose /// if it were closed: for an L of two 100 pt legs, a 6 pt band with butt ends and a mitered corner. #[test] diff --git a/crates/three-d/src/solid.rs b/crates/three-d/src/solid.rs index 45d7d310b..d486f443c 100644 --- a/crates/three-d/src/solid.rs +++ b/crates/three-d/src/solid.rs @@ -257,6 +257,12 @@ pub struct Facet { /// The facet's outline with the profile's curves kept as cubics, for [`Facet::path`]. The /// contours stay the polygon that visibility and depth use. pub outline: Option, + /// The facet is a piece of the solid's sides (Extrude & Bevel's walls and bevels, Inflate's + /// sides) rather than of its ends (the caps, a plane, Inflate's front and back). + pub side: bool, + /// The facet is turned away from the viewer: a closed solid keeps it (seen edge-on, or with + /// Draw Hidden Faces) and draws it behind the facets turned toward the viewer. + pub away: bool, /// The contours in view space, before projection, for tests that measure depths. #[cfg(test)] view: Vec>, diff --git a/crates/three-d/src/solid/build.rs b/crates/three-d/src/solid/build.rs index 66a22f80f..0b107bca5 100644 --- a/crates/three-d/src/solid/build.rs +++ b/crates/three-d/src/solid/build.rs @@ -142,6 +142,8 @@ impl Builder<'_> { normal: [n.0, n.1, n.2], rgb_gradient: None, outline: None, + side: false, + away: behind, #[cfg(test)] view, }); @@ -221,6 +223,11 @@ impl Builder<'_> { } let before = self.facets.len(); self.face(vec![quad], normal, order)?; + if self.facets.len() > before + && let Some(f) = self.facets.last_mut() + { + f.side = true; + } if let Some(w) = curves.as_mut().filter(|_| pieces == 1) && let Some(span) = w.spans.get(i).copied().flatten() { diff --git a/crates/three-d/src/solid/tests.rs b/crates/three-d/src/solid/tests.rs index af9322bc1..be887180b 100644 --- a/crates/three-d/src/solid/tests.rs +++ b/crates/three-d/src/solid/tests.rs @@ -98,6 +98,33 @@ fn hidden_faces_draw_every_face_of_the_solid_behind_the_visible_ones() { } } +/// Each facet says whether it is a piece of the solid's sides and whether it is turned away from +/// the viewer: a box's walls and bevels are sides and its two caps are not; Inflate's sides are +/// sides and its front is not. +#[test] +fn facets_tell_the_sides_from_the_ends_and_the_faces_turned_away() { + let (p, b) = shape(&square(0.0, 0.0, 100.0)); + let v = View { rotation_x: 20.0, rotation_y: 30.0, ..front() }; + let bevel = Bevel { width: 10.0, height: 10.0, ..Bevel::default() }; + for o in [Extrude { hidden: true, ..Extrude::default() }, Extrude { hidden: true, bevel: Some(bevel), ..Extrude::default() }] { + let all = extrude(&p, FillRule::EvenOdd, b, o, v).unwrap(); + let ends: Vec<&Facet> = all.iter().filter(|f| !f.side).collect(); + let [a, c] = ends[..] else { panic!("two caps among {} facets", all.len()) }; + let along = a.normal[0] * c.normal[0] + a.normal[1] * c.normal[1] + a.normal[2] * c.normal[2]; + assert!(close(along, -1.0, 1e-9), "the caps face opposite ways: {along}"); + assert!(all.len() > 6 || o.bevel.is_none(), "the bevel adds sides"); + // The faces turned away are drawn first, the others after them. + let away = all.iter().take_while(|f| f.away).count(); + assert!(away > 0 && all[away..].iter().all(|f| !f.away), "{away} of {}", all.len()); + } + let shown = extrude(&p, FillRule::EvenOdd, b, Extrude::default(), v).unwrap(); + assert!(shown.iter().all(|f| !f.away)); + let puff = inflate(&p, FillRule::EvenOdd, b, Inflate { depth: 20.0, ..Inflate::default() }, v).unwrap(); + assert!(puff.iter().any(|f| f.side) && puff.iter().any(|f| !f.side)); + let flat = inflate(&p, FillRule::EvenOdd, b, Inflate::default(), v).unwrap(); + assert!(!flat.is_empty() && flat.iter().all(|f| !f.side)); +} + #[test] fn taper_shrinks_the_back_and_twist_turns_it() { let (p, b) = shape(&square(0.0, 0.0, 100.0)); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b4c949f74..ad8fa1cd4 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1087,6 +1087,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Divide uses open lines, bent paths and curves as cutting edges (#1307), preserving the frontmost filled face's appearance and holes. Engine tests cover implicit closure of filled open contours and unrelated objects; native automation confirms independent piece movement, one Undo/Redo, canvas/window captures and an exact saved-document read by a separate CLI process. The shared planar map keeps a segment cap (65,536 since #937, was 4,096) and its filled-area fallback; other Pathfinder operations are unchanged. - Type that Transform effects only move and copy (no scale, rotation, reflection or Random) exports as type in each place, and PDF export writes every copy as selectable, searchable text, when its characters are in solid colors without underline or strikethrough, the type object has no fills or strokes of its own, no inline graphics and no perspective, its transform does not scale or shear stroked characters, and the copies do not overlap. When the effect is on the type itself, the type can also have transparency, an opacity mask and raster effects. When the effect is on the group or layer holding the type, the type has no transparency or effects of its own and is not in a clipping path, the group or layer has no fill, stroke or Pathfinder effect of its own, and no group between them has a fill, stroke, Pathfinder or geometry effect. Type still exports as outlines when the effect stops its copies early, or when the copies would number more than 10,000 or take more than 20,000,000 bytes of document data, and so does other type under a geometry effect. - Extrude & Bevel and Inflate treat strokes as outlined: each visible stroke builds a solid from its outline in its own paint beside the top fill's ([three-d.md](three-d.md#fills-and-strokes)). + - With caps and opaque paint, Extrude & Bevel (both kinds) and Inflate draw each side face of the fill's solid before the faces of the same object's strokes that overlap it (strokes on closed paths without dashes, a width profile or a brush, in a solid color or an opaque gradient), so the stroke's walls cover the fill's walls at concave corners instead of showing stripes of the fill. Tests: `solids::tests::a_fills_walls_are_drawn_before_the_stroke_faces_over_them`, `solids::tests::the_draw_order_moves_a_fill_wall_before_the_stroke_faces_it_overlaps`, `solid::tests::facets_tell_the_sides_from_the_ends_and_the_faces_turned_away`. - Extrude & Bevel, Inflate and Rotate apply to type: it makes a solid of its glyph outlines, painted with its own fills and strokes or, when it has none, its characters' colour (the first run's), and stays type, so editing the text reshapes the solid (#4715). - Extrude & Bevel, Inflate and Rotate (Effect › 3D and Materials): live effects in the `three-d` crate with depth, twist, taper, solid or hollow caps, five original bevel shapes (width, height, repeat, spacing, inside, both sides), Inflate's volume, both sides, twist, taper and cap, Rotate's turned fills and strokes, Position presets, the rotation and light gizmos, SVG/PDF vector output and Expand Appearance; one 3D effect per object; covered by geometry, effect, canvas-versus-export, command and dialog tests ([three-d.md](three-d.md)). - Extrude & Bevel and Inflate build detailed shapes: each solid takes up to 65,536 outline points and 163,840 faces (VectorCraft's own limits, was 2,048 points and 32,768 faces), counted before any point is sampled; when the faces do not fit, every curve takes fewer pieces, down to 4. A closed path of 2,000 curves extrudes with a bevel and inflates, and paths of 5,000 and 10,000 curves build at the default settings (`crates/three-d/src/solid/tests_limits.rs`). diff --git a/docs/three-d.md b/docs/three-d.md index 635c1285e..8abb55af4 100644 --- a/docs/three-d.md +++ b/docs/three-d.md @@ -99,7 +99,12 @@ reply on the reference app's bug forum says that strokes "will be treated as out The rest is our reading: - When the fill and every stroke are opaque with Normal blending, the faces of all the solids are drawn in one - back-to-front order, and the caps turned toward the viewer are drawn last, the lower item's first. Otherwise + back-to-front order, and the caps turned toward the viewer are drawn last, the lower item's first. With caps on, + each side face of the fill's solid that is turned toward the viewer is drawn before every face of the same + shape's strokes, turned toward the viewer, whose bounds overlap it, so the stroke's walls cover the fill's walls + at concave corners. This applies to strokes on closed paths without dashes, a width profile or a brush, painted + with a solid color or a gradient without transparent stops; with other strokes, and without caps, the faces keep + their depth order. Otherwise each solid is drawn on its own with its opacity and blending, the lower item's first. With a light that is not white, each solid is also drawn on its own when one of them is a puffed, shaded Inflate surface in a solid color (drawn as one composite) or is painted with a gradient that has transparent stops (drawn with an opacity mask). From c58bd0152580382ac4d04b451382780931eca230 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 12 Nov 2026 10:11:01 +0700 Subject: [PATCH 4/4] 3D: the shadow passes read the six-field solid parts again #6716 added the member index to each solid part after #6754's shadow code was written against the five-field tuple, so main stopped compiling once both landed. The Inflate, Extrude and shadow-drawing loops now destructure all six fields. --- crates/effects/src/solids.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index 19238796a..ff5b1c971 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -740,17 +740,17 @@ fn cast_shadow<'a>(id: &str, p: &Value, shapes: &'a [Profile]) -> Result = parts.iter().map(|(item, member, ..)| ink(item, *member)).collect(); // Inflate's eye is placed for every shape together, as the solids are. let added: Vec, String>> = if id == INFLATE { - let shapes: Vec<(&PathData, FillRule)> = parts.iter().map(|(_, _, area, rule, _)| (area, *rule)).collect(); + let shapes: Vec<(&PathData, FillRule)> = parts.iter().map(|(_, _, area, rule, _, _)| (area, *rule)).collect(); let casts: Vec = inks.iter().map(|i| i.opacity > 0.0).collect(); caster.inflate(&shapes, &casts, bounds, inflate_options(p), view) } else { let mut added = vec![]; - for ((_, _, area, rule, _), ink) in parts.iter().zip(&inks) { + for ((_, _, area, rule, _, _), ink) in parts.iter().zip(&inks) { added.push(if ink.opacity > 0.0 { caster.extrude(area, *rule, bounds, extrude_options(p), view) } else { Ok(0..0) }); } added }; - for (((item, _, _, _, scopes), ink), faces) in parts.iter().zip(inks).zip(added) { + for (((item, _, _, _, scopes, _), ink), faces) in parts.iter().zip(inks).zip(added) { if ink.opacity <= 0.0 { continue; }