From f3cff033ca7ff780bb2063275caea477f41180b8 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 14:15:22 -0800 Subject: [PATCH 1/5] 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/5] 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/5] 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 62b6f53533d02d3606576ef5e66af11590e84e60 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 17:26:16 -0800 Subject: [PATCH 4/5] Color: the panel menu's Show Recent Colors shows or hides the Recent Colors row The Color panel's menu gets a checked Show Recent Colors item under Show Options / Hide Options. Unchecking it hides the panel's Recent Colors row; checking it shows the row again. Applied colors still join the list while the row is hidden, Hide Options hides the row either way, and the Swatches panel keeps its own row. The setting is on by default and is kept with the preferences (ui.color_show_recent_colors); a saved value that is not true or false reads as on. ui.colorPanel takes recentColors: bool and answers it. Docs: docs/mcp.md, docs/control-protocol.md, docs/target-app-parity.md. --- crates/mcp/src/ui_catalog.rs | 2 +- crates/ui-egui/src/i18n/bg.tsv | 1 + crates/ui-egui/src/i18n/bs.tsv | 1 + crates/ui-egui/src/i18n/ca.tsv | 1 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/da.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fi.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hi.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/id.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ka.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/nl.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sv.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + crates/ui-egui/src/menus/ui_commands.rs | 2 +- crates/ui-egui/src/panels/color.rs | 45 ++++++++++--- crates/ui-egui/src/panels/color/tests.rs | 81 +++++++++++++++++++++++- crates/ui-egui/src/state.rs | 6 ++ docs/control-protocol.md | 15 +++-- docs/mcp.md | 4 +- docs/target-app-parity.md | 1 + 35 files changed, 164 insertions(+), 19 deletions(-) diff --git a/crates/mcp/src/ui_catalog.rs b/crates/mcp/src/ui_catalog.rs index e15244580..4bcd8e8b4 100644 --- a/crates/mcp/src/ui_catalog.rs +++ b/crates/mcp/src/ui_catalog.rs @@ -1194,7 +1194,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "ui.colorPanel", "Color Panel Options", "", - "{mode?: grayscale|rgb|hsb|cmyk|websafe, options?: bool} pick the Color panel's mode as its panel menu does (the active color converts to the mode's model as one undo step), and Show Options (true) or Hide Options (false); both are kept with the preferences; RGB, HSB, Web Safe RGB and hex values use the working RGB space (ui.inspect: ui.color_mode, ui.color_hide_options) → {mode (null: none picked yet), options, shown: the mode the panel shows the color in, or tint for a tint of a global or spot swatch}", + "{mode?: grayscale|rgb|hsb|cmyk|websafe, options?: bool, recentColors?: bool} pick the Color panel's mode as its panel menu does (the active color converts to the mode's model as one undo step), Show Options (true) or Hide Options (false), and check (true) or uncheck (false) Show Recent Colors, which shows or hides the panel's Recent Colors row (applied colors still join the list while it is hidden; the Swatches panel keeps its row); all three are kept with the preferences; RGB, HSB, Web Safe RGB and hex values use the working RGB space (ui.inspect: ui.color_mode, ui.color_hide_options, ui.color_show_recent_colors) → {mode (null: none picked yet), options, recentColors, shown: the mode the panel shows the color in, or tint for a tint of a global or spot swatch}", ), ( "ui.savePdfDialog", diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index d83b5ac3c..9fc057bdb 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -3142,6 +3142,7 @@ digits Malayalam Малаялам Show Modified Показване на променените Show Note Показване на бележка Show Options Показване на опциите + Show Recent Colors Показване на скорошните цветове Show Original Path Показване на оригиналния път Open this dialog with these settings next time Отваряне на този прозорец с тези настройки следващия път Auto-Simplify Автоматично опростяване diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 663e95cea..0e4843b25 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -1526,6 +1526,7 @@ digits Malayalam Malajalam Roman Baseline Latinična osnovna linija Select a text object to edit its character attributes. Odaberite tekstualni objekat da uredite njegove atribute znakova. Show Options Prikaži opcije + Show Recent Colors Prikaži nedavne boje Color Panel Options Opcije panela boja Small Caps Mala velika slova Small Caps (on the roadmap) Mala velika slova (u planu) diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 9c60e87e0..7c7b8ec49 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -4056,6 +4056,7 @@ digits Malayalam Malaiàlam Show Modified Mostra els modificats Show Note Mostra la nota Show Options Mostra les opcions + Show Recent Colors Mostra els colors recents Show Original Path Mostra el traçat original Show Package Mostra el paquet Show Panel Mostra el panell diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index e8f9666fa..1469596fd 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -1550,6 +1550,7 @@ digits Malayalam Malajálamské Roman Baseline Latinková účaří Select a text object to edit its character attributes. Vyberte textový objekt, abyste mohli upravit atributy jeho znaků. Show Options Zobrazit možnosti + Show Recent Colors Zobrazit nedávné barvy Color Panel Options Možnosti panelu Barva Small Caps Kapitálky Small Caps (on the roadmap) Kapitálky (v plánu) diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 31915ed6b..f369607d1 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -1462,6 +1462,7 @@ digits Malayalam Malayalam Roman Baseline Romersk grundlinje Select a text object to edit its character attributes. Markér et tekstobjekt for at redigere dets tegnattributter. Show Options Vis indstillinger + Show Recent Colors Vis seneste farver Small Caps Kapitæler Small Caps (on the roadmap) Kapitæler (på vejkortet) Standard Vertical Roman Alignment Standard lodret romersk justering diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 2c5424d81..1d46354e5 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3189,6 +3189,7 @@ digits Malayalam Malayalam Show Modified Geänderte einblenden Show Note Notiz einblenden Show Options Optionen einblenden + Show Recent Colors Zuletzt verwendete Farben einblenden Show Original Path Originalpfad einblenden Open this dialog with these settings next time Dieses Dialogfeld nächstes Mal mit diesen Einstellungen öffnen Auto-Simplify Automatisch vereinfachen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 6071ed5be..83d0a8810 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3680,6 +3680,7 @@ digits Malayalam Μαλαγιάλαμ Show Modified Εμφάνιση τροποποιημένων Show Note Εμφάνιση σημείωσης Show Options Εμφάνιση επιλογών + Show Recent Colors Εμφάνιση πρόσφατων χρωμάτων Show Original Path Εμφάνιση αρχικής διαδρομής Open this dialog with these settings next time Άνοιγμα αυτού του παραθύρου με αυτές τις ρυθμίσεις την επόμενη φορά Auto-Simplify Αυτόματη απλοποίηση diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 4a74e3ae3..6c49d275a 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -3228,6 +3228,7 @@ digits Malayalam Malayálam Show Modified Mostrar modificados Show Note Mostrar nota Show Options Mostrar opciones + Show Recent Colors Mostrar colores recientes Show Original Path Mostrar trazado original Open this dialog with these settings next time Abrir este cuadro de diálogo con estos ajustes la próxima vez Auto-Simplify Simplificar automáticamente diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index f2ec64f60..7b55a3109 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -1524,6 +1524,7 @@ digits Malayalam Malajalam Roman Baseline Latinalainen perusviiva Select a text object to edit its character attributes. Valitse tekstiobjekti, jonka merkkimääritteitä haluat muokata. Show Options Näytä asetukset + Show Recent Colors Näytä viimeisimmät värit Small Caps Kapiteelit Small Caps (on the roadmap) Kapiteelit (suunnitteilla) Standard Vertical Roman Alignment Tavallinen pystysuuntainen latinalainen tasaus diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 807b3363b..164551d44 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3220,6 +3220,7 @@ digits Malayalam Malayalam Show Modified Afficher les éléments modifiés Show Note Afficher la note Show Options Afficher les options + Show Recent Colors Afficher les couleurs récentes Show Original Path Afficher le tracé d’origine Open this dialog with these settings next time Ouvrir cette boîte de dialogue avec ces réglages la prochaine fois Auto-Simplify Simplifier automatiquement diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 572a19779..3504f3ee2 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -4379,6 +4379,7 @@ antiAlias Sharp शार्प Show Modified संशोधित दिखाएँ Show Note नोट दिखाएं Show Options ऑप्शन्स दिखाएँ + Show Recent Colors हाल के रंग दिखाएँ Show Original Path मूल पाथ दिखाएं Show Package पैकेज करें दिखाएँ Show Panel शो पैनल diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 8c2d2339b..4e72a2400 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -3855,6 +3855,7 @@ mark-style Japanese Japán Show Modified Módosultak megjelenítése Show Note Megjegyzés megjelenítése Show Options Beállítások megjelenítése + Show Recent Colors Legutóbbi színek megjelenítése Show Original Path Eredeti útvonal megjelenítése Open this dialog with these settings next time Legközelebb ez a párbeszédablak nyíljon meg ezekkel a beállításokkal Auto-Simplify Automatikus egyszerűsítés diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 606ffae97..d50eb4127 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -1462,6 +1462,7 @@ digits Malayalam Malayalam Roman Baseline Baseline Romawi Select a text object to edit its character attributes. Pilih objek teks untuk mengedit atribut karakternya. Show Options Tampilkan opsi + Show Recent Colors Tampilkan warna terbaru Small Caps Kapital kecil Small Caps (on the roadmap) Kapital kecil (masih dalam roadmap) Standard Vertical Roman Alignment Perataan Romawi vertikal standar diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index bf4c9e7b5..c1f53ffac 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -3265,6 +3265,7 @@ digits Malayalam Malayalam Show Modified Mostra modificati Show Note Mostra nota Show Options Mostra opzioni + Show Recent Colors Mostra colori recenti Show Original Path Mostra tracciato originale Open this dialog with these settings next time Apri questa finestra di dialogo con queste impostazioni la prossima volta Auto-Simplify Semplifica automaticamente diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 7a12afcad..a18a6c0b8 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3083,6 +3083,7 @@ mark-style Japanese 日本式 Show List View リスト表示 Show Note メモを表示 Show Options オプションを表示 + Show Recent Colors 最近使用したカラーを表示 Show Original Path 元のパスを表示 Open this dialog with these settings next time 次回はこの設定でこのダイアログを開く Auto-Simplify 自動単純化 diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index c58480c42..99696a6a4 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -1396,6 +1396,7 @@ digits Malayalam მალაიალამური Roman Baseline ლათინური საბაზისო ხაზი Select a text object to edit its character attributes. სიმბოლოების ატრიბუტების რედაქტირებისთვის მონიშნეთ ტექსტური ობიექტი. Show Options პარამეტრების ჩვენება + Show Recent Colors ბოლოდროინდელი ფერების ჩვენება Small Caps პატარა მთავრული Small Caps (on the roadmap) პატარა მთავრული (სამომავლო გეგმაშია) Standard Vertical Roman Alignment სტანდარტული ვერტიკალური ლათინური სწორება diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 39efdecf2..89da26471 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3072,6 +3072,7 @@ mark-style Japanese 일본식 Show List View 목록 보기 표시 Show Note 메모 표시 Show Options 옵션 표시 + Show Recent Colors 최근 색상 표시 Show Original Path 원래 패스 표시 Open this dialog with these settings next time 다음에 이 설정으로 이 대화상자 열기 Auto-Simplify 자동 단순화 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index dd1d22edc..46636ee74 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -1476,6 +1476,7 @@ digits Malayalam Malayalam Roman Baseline Romeinse basislijn Select a text object to edit its character attributes. Selecteer een tekstobject om de tekenkenmerken ervan te bewerken. Show Options Opties tonen + Show Recent Colors Recente kleuren tonen Small Caps Kleine kapitalen Small Caps (on the roadmap) Kleine kapitalen (op de roadmap) Standard Vertical Roman Alignment Standaard verticale Romeinse uitlijning diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index dd850a3a4..5322f8eb5 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -1462,6 +1462,7 @@ digits Malayalam Malajalam Roman Baseline Linia bazowa łacińska Select a text object to edit its character attributes. Zaznacz obiekt tekstowy, aby edytować atrybuty znaków. Show Options Pokaż opcje + Show Recent Colors Pokaż ostatnie kolory Color Panel Options Opcje panelu Kolor Small Caps Kapitaliki Small Caps (on the roadmap) Kapitaliki (w planach) diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index f67ae6cda..9dff70360 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2705,6 +2705,7 @@ digits Malayalam Malaiala Show List View Mostrar Exibição de Lista Show Note Mostrar Nota Show Options Mostrar Opções + Show Recent Colors Mostrar Cores Recentes Show Original Path Mostrar Caminho Original Open this dialog with these settings next time Abrir este diálogo com estas configurações na próxima vez Auto-Simplify Simplificar Automaticamente diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 8eb542582..263a30854 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3260,6 +3260,7 @@ digits Malayalam Малаялам Show Modified Показать изменённые Show Note Показать примечание Show Options Показать параметры + Show Recent Colors Показать последние цвета Show Original Path Показать исходный контур Open this dialog with these settings next time Открывать это диалоговое окно с этими параметрами в следующий раз Auto-Simplify Автоматическое упрощение diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index fb2c463de..824e6c46f 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -3152,6 +3152,7 @@ digits Malayalam Malayalam Show Modified Visa ändrade Show Note Visa anteckning Show Options Visa alternativ + Show Recent Colors Visa senaste färger Show Original Path Visa ursprunglig bana Open this dialog with these settings next time Öppna den här dialogrutan med de här inställningarna nästa gång Auto-Simplify Förenkla automatiskt diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index d45a171cc..f8390c2e5 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -3056,6 +3056,7 @@ digits Malayalam Malayalam Show Modified Değiştirilenleri Göster Show Note Notu Göster Show Options Seçenekleri Göster + Show Recent Colors Son Renkleri Göster Show Original Path Orijinal Yolu Göster Color Panel Options Renk Paneli Seçenekleri Show Package Paketi Göster diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index acaf20b6b..8bf8e5236 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3257,6 +3257,7 @@ digits Malayalam Малаялам Show Modified Показати змінені Show Note Показати примітку Show Options Показати параметри + Show Recent Colors Показати останні кольори Show Original Path Показати початковий контур Open this dialog with these settings next time Наступного разу відкривати це діалогове вікно з цими параметрами Auto-Simplify Автоматичне спрощення diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 4a2bbd5a6..4ad12f563 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3597,6 +3597,7 @@ mark-style Japanese Kiểu Nhật Show Modified Hiện đã sửa đổi Show Note Hiện ghi chú Show Options Hiện tùy chọn + Show Recent Colors Hiện màu gần đây Show Original Path Hiện đường dẫn gốc Open this dialog with these settings next time Lần sau mở hộp thoại này với các cài đặt này Auto-Simplify Tự động đơn giản hóa diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 0eb7c7ebe..d51bbf066 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -3093,6 +3093,7 @@ digits Malayalam 马拉雅拉姆数字 Show Modified 显示已修改 Show Note 显示注解 Show Options 显示选项 + Show Recent Colors 显示最近使用的颜色 Show Original Path 显示原始路径 Open this dialog with these settings next time 下次以这些设置打开此对话框 Auto-Simplify 自动简化 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 084a7e770..7de5fe722 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -4207,6 +4207,7 @@ digits Malayalam 馬來亞拉姆數字 Show Modified 顯示已修改 Show Note 顯示註解 Show Options 顯示選項 + Show Recent Colors 顯示最近使用的顏色 Show Original Path 顯示原始路徑 Open this dialog with these settings next time 下次以這些設定開啟此對話方塊 Auto-Simplify 自動簡化 diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index a96f13e67..186b21322 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -1195,7 +1195,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "ui.colorPanel", "Color Panel Options", "", - "{mode?: grayscale|rgb|hsb|cmyk|websafe, options?: bool} pick the Color panel's mode as its panel menu does (the active color converts to the mode's model as one undo step), and Show Options (true) or Hide Options (false); both are kept with the preferences; RGB, HSB, Web Safe RGB and hex values use the working RGB space (ui.inspect: ui.color_mode, ui.color_hide_options) → {mode (null: none picked yet), options, shown: the mode the panel shows the color in, or tint for a tint of a global or spot swatch}", + "{mode?: grayscale|rgb|hsb|cmyk|websafe, options?: bool, recentColors?: bool} pick the Color panel's mode as its panel menu does (the active color converts to the mode's model as one undo step), Show Options (true) or Hide Options (false), and check (true) or uncheck (false) Show Recent Colors, which shows or hides the panel's Recent Colors row (applied colors still join the list while it is hidden; the Swatches panel keeps its row); all three are kept with the preferences; RGB, HSB, Web Safe RGB and hex values use the working RGB space (ui.inspect: ui.color_mode, ui.color_hide_options, ui.color_show_recent_colors) → {mode (null: none picked yet), options, recentColors, shown: the mode the panel shows the color in, or tint for a tint of a global or spot swatch}", ), ( "ui.savePdfDialog", diff --git a/crates/ui-egui/src/panels/color.rs b/crates/ui-egui/src/panels/color.rs index e58ca56f6..4e13f629a 100644 --- a/crates/ui-egui/src/panels/color.rs +++ b/crates/ui-egui/src/panels/color.rs @@ -12,7 +12,10 @@ //! the inactive proxy when Alt is also held; //! Shift-dragging a slider moves the others in tandem; RGB and HSB show the out-of-gamut warning; //! Hide Options leaves just the proxy and the spectrum (the app keeps it between sessions too). -//! `ui.colorPanel` picks a mode and shows or hides the options as the panel menu does. +//! Unchecking Show Recent Colors hides the Recent Colors row while applied colors still join the +//! list (the app keeps it between sessions too). +//! `ui.colorPanel` picks a mode, shows or hides the options and checks or unchecks Show Recent +//! Colors as the panel menu does. use egui::{Color32, Rect, Sense, Stroke, StrokeKind, Ui, vec2}; use serde_json::{Value, json}; @@ -287,6 +290,14 @@ pub fn deserialize_hide_options<'de, D: serde::Deserializer<'de>>(d: D) -> Resul Ok(v.as_bool().unwrap_or_default()) } +/// Serde reader for the saved Show Recent Colors ([`crate::UiState::color_show_recent_colors`]): +/// `false` hides the row and any other value shows it, so an unknown value doesn't lose the rest +/// of the preferences. +pub fn deserialize_show_recent_colors<'de, D: serde::Deserializer<'de>>(d: D) -> Result { + let v = ::deserialize(d)?; + Ok(v.as_bool().unwrap_or(true)) +} + /// The mode the panel shows `color` in: with nothing selected (the defaults for new art) the mode /// picked last, which the app keeps between sessions, else [`display_mode`]. fn shown_mode(app: &VectorcraftApp, color: Option<&Color>) -> Mode { @@ -511,16 +522,18 @@ fn recolor(app: &mut VectorcraftApp, ui: &Ui, tgt: &Target, invert: bool, behind } } -/// `ui.colorPanel {mode?, options?}`: pick a mode as the panel menu does ([`set_mode`]), and Show -/// Options (true) or Hide Options (false). → {mode, options, shown}: the mode picked last (null: -/// none yet), whether the options show, and the mode the panel shows the color in (`tint` for a -/// tint of a global or spot swatch). +/// `ui.colorPanel {mode?, options?, recentColors?}`: pick a mode as the panel menu does +/// ([`set_mode`]), Show Options (true) or Hide Options (false), and check (true) or uncheck +/// (false) Show Recent Colors. → {mode, options, recentColors, shown}: the mode picked last (null: +/// none yet), whether the options show, whether Show Recent Colors is checked, and the mode the +/// panel shows the color in (`tint` for a tint of a global or spot swatch). pub(crate) fn command(app: &mut VectorcraftApp, p: &Value) -> Result { let mode = match p.get("mode") { None | Some(Value::Null) => None, Some(v) => Some(menu_mode(v).ok_or("`mode` must be grayscale, rgb, hsb, cmyk or websafe")?), }; let options = crate::menus::opt_bool(p, "options")?; + let recent = crate::menus::opt_bool(p, "recentColors")?; let (fill, stroke) = app.session.proxy_mixed(); let mixed = if app.session.fill_active { fill } else { stroke }; if let Some(m) = mode { @@ -529,12 +542,20 @@ pub(crate) fn command(app: &mut VectorcraftApp, p: &Value) -> Result json!("tint"), None => json!(shown_mode(app, tgt.color().as_ref())), }; - Ok(json!({"mode": app.ui.color_mode, "options": !app.ui.color_hide_options, "shown": shown})) + Ok(json!({ + "mode": app.ui.color_mode, + "options": !app.ui.color_hide_options, + "recentColors": app.ui.color_show_recent_colors, + "shown": shown, + })) } /// Tint mode's rows: the swatch's chip and name, and the T slider and field. Returns a new tint @@ -626,8 +647,12 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { (Some(c), _) => components(mode, &c), (None, _) => vec![0.0; mode.labels().len()], }; - let recent = super::recent_colors_row(app, ui); - widgets::divider(ui); + // Show Recent Colors draws the Recent Colors row; the Swatches panel's row does not read it. + let mut recent = None; + if app.ui.color_show_recent_colors { + recent = super::recent_colors_row(app, ui); + widgets::divider(ui); + } // An edit of the active colour with its displayed components (`new`), or a colour clicked with // Alt held for the inactive proxy (`behind`). let mut new: Option<(Color, Live, Vec)> = None; @@ -770,6 +795,10 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { if menu_item(ui, if hidden { tl!("Show Options") } else { tl!("Hide Options") }, true, false) { command(app, &json!({ "options": hidden })).ok(); } + let recent = app.ui.color_show_recent_colors; + if menu_item(ui, tl!("Show Recent Colors"), true, recent) { + command(app, &json!({ "recentColors": !recent })).ok(); + } ui.separator(); let tgt = target(app, ui); let color = tgt.color(); diff --git a/crates/ui-egui/src/panels/color/tests.rs b/crates/ui-egui/src/panels/color/tests.rs index 9f9b3c1dd..36acc60e4 100644 --- a/crates/ui-egui/src/panels/color/tests.rs +++ b/crates/ui-egui/src/panels/color/tests.rs @@ -599,6 +599,83 @@ fn hide_options_comes_back_after_a_restart() { } } +/// The panel menu's Show Recent Colors hides the Recent Colors row and shows it again. Applied +/// colors still join the list while the row is hidden, Hide Options hides the row either way, and +/// the Swatches panel keeps its own row. +#[test] +fn show_recent_colors_hides_and_shows_the_row() { + let mut app = app(); + app.run("paint.setFill", json!({"color": "#ff0000"})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let none = egui::Modifiers::NONE; + // The first recent chip's rect and the panel's height. The second frame's answer counts, since + // egui also answers for a widget the frame before drew. + let chip = |app: &mut VectorcraftApp| { + frame_with(app, &ctx, vec![], none, ("recent", 0usize)); + frame_with(app, &ctx, vec![], none, ("recent", 0usize)) + }; + let (shown, full) = chip(&mut app); + assert!(shown.is_some(), "the row shows by default"); + // The item is checked while the row shows. + click_menu_item(&mut app, &ctx, "✓ Show Recent Colors"); + assert!(!app.ui.color_show_recent_colors); + let (hidden, short) = chip(&mut app); + assert!(hidden.is_none() && short < full, "the row is hidden: {short} / {full}"); + // A color applied while the row is hidden still joins the list. + app.run("paint.setFill", json!({"color": "#00ff00"})).unwrap(); + assert_eq!(app.session.recent_colors.first().map(|c| c.to_hex()).as_deref(), Some("#00ff00")); + // The Swatches panel keeps its row (drawn in a context of its own, whose chip ids the Color + // panel's would share). + let mut swatch_chip = None; + let screen_rect = Some(Rect::from_min_size(egui::Pos2::ZERO, vec2(280.0, 700.0))); + let swatches = egui::Context::default(); + let mut out = swatches.run_ui(egui::RawInput { screen_rect, ..Default::default() }, |ui| { + crate::panels::swatches::show(&mut app, ui); + swatch_chip = swatches.read_response(ui.scope_id().with(("recent", 0usize))).map(|r| r.rect); + }); + out.textures_delta.clear(); + assert!(swatch_chip.is_some(), "the Swatches panel still draws its recent colors"); + // Show Options leaves the row hidden, and Hide Options hides it with the row checked too. + app.run("ui.colorPanel", json!({"options": false})).unwrap(); + app.run("ui.colorPanel", json!({"options": true})).unwrap(); + assert!(chip(&mut app).0.is_none(), "Show Options keeps Show Recent Colors unchecked"); + click_menu_item(&mut app, &ctx, "Show Recent Colors"); + assert!(chip(&mut app).0.is_some(), "checking the item shows the row again"); + assert_eq!(app.run("ui.colorPanel", json!({"options": false})).unwrap()["recentColors"], true); + assert!(chip(&mut app).0.is_none(), "Hide Options hides the row"); + assert!(app.ui.color_show_recent_colors, "Hide Options leaves Show Recent Colors checked"); + let r = app.run("ui.colorPanel", json!({"options": true, "recentColors": false})).unwrap(); + assert_eq!((&r["options"], &r["recentColors"]), (&json!(true), &json!(false))); + assert!(chip(&mut app).0.is_none()); + app.run("ui.colorPanel", json!({"recentColors": true})).unwrap(); + assert_eq!(chip(&mut app).1, full, "the panel is as tall as before"); +} + +/// Show Recent Colors is kept with the UI state, which the app saves with its preferences. A saved +/// state without it, or with a value that isn't true or false, reads as checked, and the rest of the +/// state still loads. A value that isn't true or false in the command answers an error and changes +/// nothing. +#[test] +fn show_recent_colors_comes_back_after_a_restart() { + let mut app = app(); + app.run("ui.colorPanel", json!({"recentColors": false})).unwrap(); + let saved: crate::UiState = serde_json::from_value(serde_json::to_value(&app.ui).unwrap()).unwrap(); + assert!(!saved.color_show_recent_colors); + assert_eq!(crate::control::inspect(&app, &egui::Context::default())["ui"]["color_show_recent_colors"], false); + let old: crate::UiState = serde_json::from_value(json!({"place_link": false})).unwrap(); + assert!(old.color_show_recent_colors, "a state saved before the setting existed shows the row"); + for v in [json!(7), json!("hide"), json!({}), Value::Null] { + let ui: crate::UiState = serde_json::from_value(json!({"color_show_recent_colors": v, "place_link": false})).unwrap(); + assert_eq!((ui.color_show_recent_colors, ui.place_link), (true, false), "{v}"); + } + for v in [json!("no"), json!(0), json!([])] { + let e = app.run("ui.colorPanel", json!({"mode": "cmyk", "options": false, "recentColors": v})).unwrap_err(); + assert_eq!(e, "`recentColors` must be true or false", "{v}"); + } + assert_eq!((app.ui.color_mode, app.ui.color_hide_options, app.ui.color_show_recent_colors), (None, false, false)); +} + /// `ui.colorPanel {mode}` picks a mode as the panel menu does: HSB shows an RGB fill as H, S and /// B, and CMYK converts it as one undo step. #[test] @@ -607,7 +684,7 @@ fn ui_color_panel_picks_a_mode_as_the_menu_does() { let ctx = egui::Context::default(); app.run("paint.setFill", json!({"color": "#336699"})).unwrap(); let r = app.run("ui.colorPanel", json!({"mode": "hsb"})).unwrap(); - assert_eq!(r, json!({"mode": "hsb", "options": true, "shown": "hsb"})); + assert_eq!(r, json!({"mode": "hsb", "options": true, "recentColors": true, "shown": "hsb"})); assert_eq!(slider_labels(&mut app, &ctx), ["H", "S", "B"]); assert_eq!(crate::control::inspect(&app, &ctx)["ui"]["color_mode"], "hsb"); assert_eq!(paints_hex(&app).0, "#336699", "HSB keeps the RGB color"); @@ -617,7 +694,7 @@ fn ui_color_panel_picks_a_mode_as_the_menu_does() { assert!(matches!(crate::panels::current_paints(&app).0.color(), Some(Color::Rgb { .. })), "undo restores it"); assert_eq!(paints_hex(&app).0, "#336699"); // `{}` reads the settings: CMYK stays picked, and the RGB fill shows in its own model. - assert_eq!(app.run("ui.colorPanel", json!({})).unwrap(), json!({"mode": "cmyk", "options": true, "shown": "rgb"})); + assert_eq!(app.run("ui.colorPanel", json!({})).unwrap(), json!({"mode": "cmyk", "options": true, "recentColors": true, "shown": "rgb"})); // A tint of a global swatch shows Tint mode. app.run("swatch.new", json!({"name": "Ink", "color": {"c": 0, "m": 1, "y": 0, "k": 0}, "spot": true})).unwrap(); app.run("paint.setFill", json!({"swatch": "Ink", "tint": 50})).unwrap(); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 59553120c..7c0cde402 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -652,6 +652,11 @@ pub struct UiState { /// [`crate::panels::color::deserialize_hide_options`]). #[serde(default, deserialize_with = "crate::panels::color::deserialize_hide_options")] pub color_hide_options: bool, + /// Color panel: Show Recent Colors, which draws its Recent Colors row while the options show + /// (`ui.colorPanel`, [`crate::panels::color::deserialize_show_recent_colors`]). The list of + /// recent colors keeps filling while the row is hidden. + #[serde(default = "yes", deserialize_with = "crate::panels::color::deserialize_show_recent_colors")] + pub color_show_recent_colors: bool, /// The SVG Options chosen last (`svg` object of the export/save commands; null: never used). #[serde(default)] pub svg_options: Value, @@ -1094,6 +1099,7 @@ impl Default for UiState { align_spacing: 0.0, color_mode: None, color_hide_options: false, + color_show_recent_colors: true, svg_options: Value::Null, place_link: true, dialog_file: None, diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 04715474d..6382e8227 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -236,12 +236,15 @@ Edit or Apply Colors runs `ui.recolorDialog {colors, library?}` with the harmony recolours as usual; with nothing selected the colours themselves are the rows and `ui.dialog.confirm` saves them as a new colour group (`swatch.newGroup`, named by the `groupName` field). -Color panel options: `ui.colorPanel {mode?, options?}` picks the panel's mode as its panel menu does (`grayscale`, -`rgb`, `hsb`, `cmyk` or `websafe`; the active color converts to the mode's model as one undo step) and shows -(`options: true`) or hides (`false`) the options. It answers `{mode, options, shown}`: `mode` is null until a mode is -picked, and `shown` is the mode the panel shows the color in, or `tint` for a tint of a global or spot swatch. -Another mode, `lab` included, answers an error. The desktop app keeps both settings with its preferences, and -`ui.inspect` reports them as `ui.color_mode` and `ui.color_hide_options`. +Color panel options: `ui.colorPanel {mode?, options?, recentColors?}` picks the panel's mode as its panel menu does +(`grayscale`, `rgb`, `hsb`, `cmyk` or `websafe`; the active color converts to the mode's model as one undo step), +shows (`options: true`) or hides (`false`) the options, and checks (`recentColors: true`) or unchecks (`false`) Show +Recent Colors, which shows or hides the panel's Recent Colors row. Applied colors still join the list while the row +is hidden, and the Swatches panel keeps its own row. It answers `{mode, options, recentColors, shown}`: `mode` is null +until a mode is picked, and `shown` is the mode the panel shows the color in, or `tint` for a tint of a global or spot +swatch. Another mode, `lab` included, answers an error, as does an `options` or `recentColors` value that is not true +or false. The desktop app keeps the three settings with its preferences, and `ui.inspect` reports them as +`ui.color_mode`, `ui.color_hide_options` and `ui.color_show_recent_colors`. Color Themes panel (`window.panel {panel: "colorThemes"}`): its Create tab makes a theme on a harmony wheel and its My Themes tab lists the saved themes; every action runs a command (`colorTheme.save`, `colorTheme.delete`, diff --git a/docs/mcp.md b/docs/mcp.md index 5826c5c8c..85d77f0f1 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -470,7 +470,9 @@ objects' fills or strokes differ (`fillMixed` / `strokeMixed`, drawn as a "?" pr In the app's Color panel and Color Picker, RGB, HSB, hex and Web Safe RGB values describe the active working RGB space. RGB edits preserve the other native channels; HSB uses those same working RGB components. Colors outside display sRGB retain their native numeric values. `ui.colorPanel` selects the panel mode and `ui.colorPicker` -opens the picker through `run_command` with the remote backend. +opens the picker through `run_command` with the remote backend. `ui.colorPanel {"recentColors": false}` unchecks +the panel menu's Show Recent Colors, which hides the Color panel's Recent Colors row, and `true` shows it again; +applied colors still join the list while it is hidden. | Tool | Arguments | Notes | |---|---|---| diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b4c949f74..8e4aa7016 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1268,6 +1268,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Colour:** a Color Picker (colour field, H/S/B/R/G/B channel slider, HSB/RGB/Lab/CMYK fields, hex, web-only snapping, out-of-gamut correction); the Color panel shows a selected colour in its own model and, with nothing selected, the new-art defaults in the mode picked last, which it keeps between sessions; Alt-click paints the other proxy; proxies look through groups and show "?" for mixed paint; Invert, Complement, Apply Last Color (`,`) and Apply Last Gradient (`.`) keep the colour model; every paint command feeds the recent colours. The Color Guide has 21 harmony rules, Steps and Variation options and Limit to Library. Adjust Color Balance (its Global mode shifts tints) and Saturate preview live; Edit Colors and Recolor also reach meshes, images and patterns. Recolor Artwork has colour reduction, five recolour methods, presets, Limit to Library, a colour-wheel Edit tab and Edit Color Group; a local Color Themes panel saves five-colour themes. Fill/Stroke chips open swatch and mixer popovers (the Properties panel has the Control bar's Fill and Stroke chips, Stroke link and weight spinner, with nothing selected too), panels have Illustrator's Window menu keys (Align Shift+F7, Transform Shift+F8, Pathfinder Cmd+Shift+F9, Layers F7, Character Cmd+T…; every panel can be given one in Edit › Keyboard Shortcuts), and Eyedropper Options pick up and apply chosen attributes (Alt and Shift+Alt, image pixels). An ICC CMYK profile converts straight into the RGB working space (not through sRGB, so a wider space such as Wide Gamut RGB keeps colours sRGB can't hold) with the colour settings' black-point compensation, on the canvas and in RGB exports alike (the built-in Generic CMYK compensates both ways, so RGB black separated to a rich black displays as black again). Fills and strokes can overprint (Attributes panel, Overprint Black options), and CMYK documents blend in inks on screen, in PDF and when flattening. Document Color Mode converts the art, symbols, pattern tiles and swatches through the colour settings: Gray stays Gray, RGB grays separate into light grays in cyan, magenta and yellow only, darker four-color grays and a rich black, or go on the black plate only with `grays: "black"` (also for `document.open {colorMode}`). Plain Comma and Period, and also Shift+Comma and Shift+Period, apply the last solid color and the last gradient to the active Fill or Stroke proxy in VectorCraft ([Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html), "Select color fill mode" and "Select gradient fill mode"). Recent Colors keeps the last 30 solid colors applied, newest first and each once, and the list stays the same when another document is opened or made active; a 31st color drops the oldest ([Select colors using the Color panel](https://helpx.adobe.com/illustrator/desktop/manage-colors/select-and-adjust-colors/select-colors-using-the-color-panel.html)). The list lasts for the session. In the Color and Swatches panels the chips wrap onto further rows when the panel is too narrow for one row (`paint.recent`). Tests: `recent_colors_keep_thirty_colors_across_documents` in `crates/engine/src/tests/paintproxy.rs`, `all_thirty_recent_colors_wrap_into_a_narrow_panel` in `crates/ui-egui/src/panels/color/tests.rs`. + Unchecking Show Recent Colors in the Color panel's menu hides its Recent Colors row, and checking it shows the row again ([Select colors using the Color panel](https://helpx.adobe.com/illustrator/desktop/manage-colors/select-and-adjust-colors/select-colors-using-the-color-panel.html)). In VectorCraft the item is checked by default and kept between sessions, applied colors still join the list while the row is hidden, Hide Options hides the row either way, and the Swatches panel keeps its row (`ui.colorPanel {recentColors}`). Tests: `show_recent_colors_hides_and_shows_the_row` in `crates/ui-egui/src/panels/color/tests.rs`. A Cmd/Ctrl-click on the Color panel's spectrum applies Complement and a Cmd/Ctrl+Shift-click applies Invert to the active Fill or Stroke (`paint.complement`, `paint.invert`); with Alt also held, the click recolors the inactive one, as the reference app's [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) list them. View › Proof Setup › Color blindness – Tritanopia-type turns Proof Colors on and proofs the canvas with the tritanopia simulation of Brettel, Viénot and Mollon (1997). The Separations Preview panel's Proof Setup list offers the same target (`view.proofSetup {target: "tritanopia"}`). Grays keep their value. The Separations Preview panel's Gamut Check button reports one color outside the CMYK gamut in the singular and more in the plural, in English and in each language's plural forms (`gamut_check_status_takes_the_plural_form` in `crates/ui-egui/src/i18n/tests.rs`). From c58bd0152580382ac4d04b451382780931eca230 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 12 Nov 2026 10:11:01 +0700 Subject: [PATCH 5/5] 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; }