From 933e2ae08297afacab0c9736520cd37f42c57a53 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Fri, 3 Jul 2026 05:31:36 -0700 Subject: [PATCH 001/165] Add rank polymorphism node audit classifying all 271 nodes --- rank-polymorphism-node-audit.md | 383 ++++++++++++++++++++++++++++++++ 1 file changed, 383 insertions(+) create mode 100644 rank-polymorphism-node-audit.md diff --git a/rank-polymorphism-node-audit.md b/rank-polymorphism-node-audit.md new file mode 100644 index 0000000000..d331a1b1ba --- /dev/null +++ b/rank-polymorphism-node-audit.md @@ -0,0 +1,383 @@ +# Rank Polymorphism Node Audit + +Classification of all 271 live `#[node_macro::node]` definitions (July 2026, master @ 95c1ab81f) for the rank polymorphism refactor. Two additional commented-out definitions (`month`, `day` in animation.rs) are excluded. + +## Rubric summary + +- Wires carry `Item` (rank 0) or `List` (rank 1). Each input connector declares a **cell rank**; the compiler maps kernels over excess rank (the **frame**), zipping framed connectors (longest-list, last-element repeats) and broadcasting unframed ones. Output rank = kernel output rank + frame rank. Rank-2 results force-flatten (concatenate) until tree spines land. +- Classification is by **intended semantics**, not today's signatures (bulk-converted `List` carries no signal). Element-wise is the default; rank 1 only for genuine whole-list needs. +- **Classes:** `element-wise` (all data connectors rank 0 → rank 0, includes generators), `floor` (rank 1 → rank 1), `reducer` (rank 1 → rank 0), `expander` (rank 0 → rank 1), `mixed` (differing cell ranks), `infrastructure` (exempt). +- **Lazy connectors** (`Context -> T`, one evaluation per frame slot): only for (1) frame-synthesizers, (2) demand-context modifiers (Footprint et al.), and (3) evaluation-control (Memoize, Monitor, Switch — nodes whose purpose is deciding whether/when upstream evaluates). +- **Attrs**: node reads/writes ATTR_* columns; keeps its List kernel + `#[length_preserving]` singleton round-trip near-term, still classified by semantic rank. +- No `Option` outputs. `Default::default()` is the fallback only when a node's own semantics define no answer; nodes may define richer valid domains (negative from-end indexing, clamping) as non-failures. +- Notation: `name: Item` = rank-0 connector, `name: List` = rank-1 connector, `name: Context -> Item (lazy)` = lazy connector. `DEVIATION:` marks observable behavior changes vs. today. + +## Summary statistics + +| Class | Count | Share (of 271) | Share (of 244 non-infra) | +|---|---|---|---| +| element-wise | 207 | 76% | 85% | +| expander | 12 | 4% | 5% | +| mixed | 11 | 4% | 5% | +| reducer | 7 | 3% | 3% | +| floor | 7 | 3% | 3% | +| infrastructure | 27 | 10% | — | + +Counts reflect the resolved decisions below (blob unification reclassified Post Request, String to Bytes, and Image to Bytes as element-wise). + +By area: math 57 (100% element-wise), vector 58, graphic 31, text 33, raster 31, transform/repeat/blending 16, generators/bool/brush 15, gcore 24, gstd/render 21, path-bool 1 (counted in generators group). + +## Cross-cutting findings + +1. **The element-wise default holds overwhelmingly.** Every math node, every raster adjustment, every string operation, nearly every vector modifier, and all generators classify as rank 0. The true rank-1 floors number in the single digits (Assign Colors, Pack Strips, Extend, the Flatten family) plus a handful of reducers and expanders. +2. **A third lazy category is needed: evaluation-control.** Memoize's entire purpose is skipping upstream evaluation on cache hit; Monitor is compiler-inserted plumbing; Switch's short-circuiting branches are the user-facing member of this category. None meets the two authored-node lazy criteria, and none should — they warrant an explicit exemption alongside frame-synthesizers and demand-context modifiers. +3. **The demand-context-modifier criterion generalizes beyond Footprint.** Quantize Real Time / Quantize Animation Time rewrite the time in context before upstream evaluates; Area/Centroid reset the Footprint to default for resolution-independence. All are legitimate lazy connectors under "modifies context before upstream evaluates." +4. **A recurring deviation family: hidden whole-list aggregation.** Many measure-style nodes silently reduce across the list today (Count Points, Path Length, Area, Centroid, Point Inside, Dimensions, Sample Gradient, Image Color Palette, plus the progression-family subpath flattening in Cut Path / Position on Path / Tangent on Path / Morph's path). All are reclassified element-wise; recovering the old aggregate behavior requires composing with explicit reducers. +5. **New companion nodes needed** to recover composability lost by removing hidden aggregation: generic numeric reducers (Sum, Average, Minimum, Maximum over a `List`), boolean reducers (Any, All), a Filter/Cull node (`List + List mask`), and a Sort node. Count Elements already exists as the list-length reducer. Also: a Corners node (`List -> Item>` via CSS shorthand expansion) and a Separate Glyphs expander (split out of Text to Vector). +6. **Failure-case pattern:** several generators return an empty `List` on failure today (Hex to Color, QR Code, Image decode, Noise Pattern / Mandelbrot offscreen). Rank-0 output cannot be empty; these become `Default::default()` values. If "absent" must be representable, that's the future exception/invalid-signal channel, not empty lists. +7. **Byte-blob representation split (gstd), RESOLVED:** `Arc<[u8]>` rank-0 blobs vs. `List` per-byte lists were used inconsistently. Unify on a rank-0 blob type, turning String to Bytes, Image to Bytes, and Post Request's body into plain element-wise connectors. +8. **Evidence the refactor is needed:** Black & White's bare `tint: Color` parameter — exactly the rank-0 connector shape this refactor prescribes — currently causes a type error that hides the node (in-code TODO). The three "apply once to the list's parent" TODOs in blending are fixed outright by rank-0 wires. + +## Resolved decisions (Keavon, July 2026) + +1. **Switch keeps short-circuiting.** Its branches stay lazy (`Context -> Item`), classified under the evaluation-control lazy category. Condition is rank-0 eager; when framed by a `List`, each frame slot evaluates only its taken branch (per-element short-circuiting), with memoization collapsing repeated evaluations of context-independent upstreams. +2. **Raster zip-mismatch cases adopt clean rank semantics.** Plural raster data isn't used in the wild; past ad-hoc behaviors are removed: Mix uses longest-zip; Combine Channels uses longest-zip with default-raster slots for mismatched dimensions; Mask passes the image through on degenerate stencils. +3. **Rectangle corner radii become a `Corners` value type** following CSS expansion rules (reusable wherever four-value expansion applies). A new Corners node converts `List -> Item>` via the shorthand rules. +4. **Centroid is element-wise** like the other measure nodes. Group centroid composes as Flatten Path + Centroid (identical recipe to Bounding Box); the flag only applied to aggregating per-shape outputs directly, which needs area weights. +5. **Empty-input passthroughs dropped** (Gradient Map / Fill / Stroke): rank-0 default semantics apply; connectors get sensible `#[default(...)]` values where useful. +6. **Attach Attribute merges into Write Attribute** (both new/undocumented; no real-artwork usage). +7. **Nodes define their own valid domains.** Negative from-end indexing and clamping are legitimate node semantics, not failures; `Default::default()` applies only when the node itself has no defined answer. Index Points keeps its behavior unchanged. +8. **Some / Unwrap Option / Size Of deleted** — vestigial and unused. +9. **Byte-blob unification adopted:** a rank-0 blob type replaces `List`; Post Request's body, String to Bytes, and Image to Bytes become element-wise. +10. **Text to Vector splits in two:** the compound-path-per-string node (element-wise) and a Separate Glyphs expander. + +## Deletions, merges, and splits + +| Node | Action | Reason | +|---|---|---| +| Copy to Points | DELETE | Transform broadcasting + assign/spread family (N×M decomposed) | +| Repeat on Points | DELETE | Transform broadcasting replaces it | +| Map | DELETE/demote to legacy | Compiler framing is exactly this node | +| Map String, Read String | DEPRECATE | Subsumed by framing over rank-0 string kernels | +| Attach Attribute | MERGE into Write Attribute | Identical once Write Attribute's value is an eager zip | +| Extract Element | MERGE into Index Elements | "Bare element" distinction dissolves when Item carries attributes | +| As u32 / As u64 / As f64 | DELETE | Existing TODOs slate them for Passthrough replacement | +| Some / Unwrap Option / Size Of | DELETE | Vestigial and unused (resolution 8) | +| Text to Vector (glyph mode) | SPLIT | `separate_glyphs` becomes a Separate Glyphs expander node (resolution 10) | +| Upload Texture | DELETE | Doc comment already deprecates in favor of Convert | +| To Graphic | Possibly subsume | Auto-conversion between graphical Item types would cover it | +| Legacy Layer Extend | DELETE | Existing TODO; document-upgrade shim | +| Brightness/Contrast Classic | Keep hidden | PSD interop; see raster flags | + +--- + +## Math (57 nodes — all element-wise) + +All in `node-graph/nodes/math/src/lib.rs`. Zero rank-1 connectors in the entire crate; every node zips/broadcasts. Attributes now flow through math nodes (fixing today's attribute-dropping hole). + +| Node | Line | Proposed connectors -> output | Class | Lazy | Attrs | Notes | +|---|---|---|---|---|---|---| +| Math | 39 | `operand_a: Item, expression: Item, operand_b: Item -> Item` | element-wise | no | — | Parse/eval failure -> 0 (already default-safe); per-element expressions want parse caching | +| Add | 84 | `augend: Item, addend: Item -> Item` | element-wise | no | — | | +| Subtract | 98 | `minuend: Item, subtrahend: Item -> Item` | element-wise | no | — | | +| Multiply | 112 | `multiplier: Item, multiplicand: Item -> Item` | element-wise | no | — | Includes DAffine2×DAffine2, DAffine2×DVec2 | +| Divide | 129 | `numerator: Item, denominator: Item -> Item` | element-wise | no | — | Zero denominator -> default (already) | +| Reciprocal | 152 | `value: Item -> Item` | element-wise | no | — | 0 -> 0 (already) | +| Modulo | 165 | `numerator: Item, modulus: Item, always_positive: Item -> Item` | element-wise | no | — | | +| Exponent | 183 | `base: Item, power: Item -> Item` | element-wise | no | — | | +| Root | 200 | `radicand: Item, degree: Item -> Item` | element-wise | no | — | Degree <= 0 -> 0 (already) | +| Logarithm | 225 | `value: Item, base: Item -> Item` | element-wise | no | — | | +| Sine | 248 | `theta: Item, radians: Item -> Item` | element-wise | no | — | | +| Cosine | 261 | `theta: Item, radians: Item -> Item` | element-wise | no | — | | +| Tangent | 274 | `theta: Item, radians: Item -> Item` | element-wise | no | — | | +| Sine Inverse | 287 | `value: Item, radians: Item -> Item` | element-wise | no | — | | +| Cosine Inverse | 301 | `value: Item, radians: Item -> Item` | element-wise | no | — | | +| Tangent Inverse | 319 | `value: Item, radians: Item -> Item` | element-wise | no | — | DVec2 impl is atan2 | +| Remap | 357 | `value: Item, input_min: Item, input_max: Item, output_min: Item, output_max: Item, clamped: Item -> Item` | element-wise | no | — | | +| Random | 405 | `seed: Item, min: Item, max: Item -> Item` | element-wise | no | — | Vary seed per element (e.g. via index) for per-element variation | +| As u32 | 425 | `value: Item -> Item` | element-wise | no | — | DELETE (existing TODO) | +| As u64 | 432 | `value: Item -> Item` | element-wise | no | — | DELETE (existing TODO) | +| As f64 | 439 | `value: Item -> Item` | element-wise | no | — | DELETE (existing TODO) | +| Round | 445 | `value: Item -> Item` | element-wise | no | — | | +| Floor | 456 | `value: Item -> Item` | element-wise | no | — | | +| Ceiling | 467 | `value: Item -> Item` | element-wise | no | — | | +| Absolute Value | 507 | `value: Item -> Item` | element-wise | no | — | | +| Min | 518 | `value: Item, other_value: Item -> Item` | element-wise | no | — | Pairwise; list minimum = future reducer node | +| Max | 532 | `value: Item, other_value: Item -> Item` | element-wise | no | — | Pairwise; list maximum = future reducer node | +| Clamp | 546 | `value: Item, min: Item, max: Item -> Item` | element-wise | no | — | | +| Greatest Common Divisor | 571 | `value: Item, other_value: Item -> Item` | element-wise | no | — | | +| Least Common Multiple | 591 | `value: Item, other_value: Item -> Item` | element-wise | no | — | | +| Less Than | 646 | `value: Item, other_value: Item, or_equal: Item -> Item` | element-wise | no | — | | +| Greater Than | 663 | `value: Item, other_value: Item, or_equal: Item -> Item` | element-wise | no | — | | +| Equals | 679 | `value: Item, other_value: Item -> Item` | element-wise | no | — | | +| Not Equals | 693 | `value: Item, other_value: Item -> Item` | element-wise | no | — | | +| Logical Or | 707 | `value: Item, other_value: Item -> Item` | element-wise | no | — | | +| Logical And | 720 | `value: Item, other_value: Item -> Item` | element-wise | no | — | | +| Logical Not | 733 | `input: Item -> Item` | element-wise | no | — | | +| Switch | 743 | `condition: Item, if_true: Context -> Item (lazy), if_false: Context -> Item (lazy) -> Item` | element-wise | branches (evaluation-control) | — | Short-circuits: only the taken branch evaluates, per frame slot when framed (resolution 1) | +| Bool Value | 790 | `bool: Item -> Item` | element-wise | no | — | Value generator | +| Number Value | 796 | `number: Item -> Item` | element-wise | no | — | Value generator | +| Percentage Value | 802 | `percentage: Item -> Item` | element-wise | no | — | Value generator | +| Vec2 Value | 808 | `x: Item, y: Item -> Item` | element-wise | no | — | Value generator | +| Color Value | 814 | `color: Item -> Item` | element-wise | no | — | Current `List` wrapper is bulk-conversion noise | +| RGBA to Color | 820 | `red: Item, green: Item, blue: Item, alpha: Item -> Item` | element-wise | no | — | Drop `new_from_element` wrapper | +| HSVA to Color | 832 | `hue: Item, saturation: Item, value: Item, alpha: Item -> Item` | element-wise | no | — | Drop wrapper | +| HSLA to Color | 843 | `hue: Item, saturation: Item, lightness: Item, alpha: Item -> Item` | element-wise | no | — | Drop wrapper | +| Hex to Color | 854 | `hex_code: Item -> Item` | element-wise | no | — | DEVIATION: invalid input today yields empty list ("no color"); rank 0 yields `Color::default()` (finding 6) | +| Gradient Value | 863 | `gradient: Item -> Item` | element-wise | no | — | Value generator | +| Gradient Type | 869 | `gradient: Item, gradient_type: Item -> Item` | element-wise | no | attrs | Writes ATTR_GRADIENT_TYPE; whole-list loop becomes the frame; type can now vary per element | +| Spread Method | 878 | `gradient: Item, spread_method: Item -> Item` | element-wise | no | attrs | Writes ATTR_SPREAD_METHOD; same as above | +| Sample Gradient | 887 | `gradient: Item, position: Item -> Item` | element-wise | no | — | DEVIATION: today samples only `element(0)`, silently truncating multi-gradient lists | +| Footprint Value | 897 | `transform: Item, resolution: Item -> Item` | element-wise | no | — | Constructs a Footprint value; not a demand-context modifier | +| Dot Product | 911 | `vector_a: Item, vector_b: Item, normalize: Item -> Item` | element-wise | no | — | | +| Angle Between | 932 | `vector_a: Item, vector_b: Item, radians: Item -> Item` | element-wise | no | — | | +| Angle To | 954 | `observer: Item, target: Item, radians: Item -> Item` | element-wise | no | — | | +| Length | 976 | `vector: Item -> Item` | element-wise | no | — | TODO rename to Magnitude | +| Normalize | 984 | `vector: Item -> Item` | element-wise | no | — | | + +## Vector (43 nodes in vector_nodes.rs + vector_modification_nodes.rs) + +| Node | File:Line | Proposed connectors -> output | Class | Lazy | Attrs | Notes | +|---|---|---|---|---|---|---| +| Assign Colors | vector_nodes.rs:73 | `content: List, fill: Item, stroke: Item, gradient: Item, reverse: Item, randomize: Item, seed: Item, repeat_every: Item -> List` | floor | no | attrs | Genuine floor (spreads by index over list length). Gradient today reads only row 0 -> Item. Assign/spread family direction per assign-transform-nodes branch | +| Fill | vector_nodes.rs:138 | `content: Item, fill: Item -> Item` | element-wise | no | no | DEVIATION: `List` fill today collapses to one fill; zip gives per-element fills (resolution 5: adopted). Backup-color connectors are UI-state stash | +| Stroke | vector_nodes.rs:197 | `content: Item, color: Item, weight: Item, align: Item, cap: Item, join: Item, miter_limit: Item, paint_order: Item, dash_lengths: List, dash_offset: Item -> Item` | mixed | no | attrs | `dash_lengths` is one whole dash pattern (genuine rank 1). DEVIATION: color today uses `element(0)` for all elements | +| Copy to Points | vector_nodes.rs:256 | DELETE | mixed | — | attrs | N×M points×content cross-product, decomposed by Transform broadcast + assign/spread nodes | +| Round Corners | vector_nodes.rs:336 | `source: Item, radius: Item, roundness: Item, edge_length_limit: Item, min_angle_threshold: Item -> Item` | element-wise | no | attrs | World-space via ATTR_TRANSFORM | +| Merge by Distance | vector_nodes.rs:450 | `content: Item, distance: Item, algorithm: Item -> Item` | element-wise | no | attrs | Merging is within one element only | +| Extrude | vector_nodes.rs:675 | `source: Item, direction: Item, joining_algorithm: Item -> Item` | element-wise | no | no | | +| Box Warp | vector_nodes.rs:683 | `content: Item, rectangle: Item -> Item` | element-wise | no | attrs | DEVIATION: today only rectangle element 0 used; zip warps content[i] by rectangle[i] | +| Pack Strips | vector_nodes.rs:768 | `elements: List, separation: Item, strip_max_length: Item, strip_direction: Item -> List` | floor | no | attrs | Genuine whole-list layout (BFDH sort + reorder) | +| Auto-Tangents | vector_nodes.rs:889 | `source: Item, spread: Item, preserve_existing: Item -> Item` | element-wise | no | attrs | | +| Bounding Box | vector_nodes.rs:1043 | `content: Item -> Item` | element-wise | no | no | Already per-element today (no deviation). Union = Combine + Bounding Box | +| Dimensions | vector_nodes.rs:1068 | `content: Item -> Item` | element-wise | no | attrs | DEVIATION: today unions all elements' boxes; per-shape now. Empty -> DVec2::ZERO | +| As Vector | vector_nodes.rs:1078 | passthrough | infrastructure | no | no | Type assertion identity | +| Points to Polyline | vector_nodes.rs:1084 | `points: Item, closed: Item -> Item` | element-wise | no | no | | +| Offset Path | vector_nodes.rs:1112 | `content: Item, distance: Item, join: Item, miter_limit: Item -> Item` | element-wise | no | attrs | | +| Solidify Stroke | vector_nodes.rs:1156 | `content: Item -> List` | expander | no | attrs | 1-2 rows (fill + outlined stroke). DEVIATION: today also flattens `List` input; becomes upstream Flatten. Framed output force-flattens (settled rule) | +| Separate Subpaths | vector_nodes.rs:1267 | `content: Item -> List` | expander | no | no | One row per subpath; framed output force-flattens | +| Path is Closed | vector_nodes.rs:1298 | `content: Item, index: Item -> Item` | element-wise | no | no | DEVIATION: index today counts subpaths across ALL elements; now scoped per element (old behavior = Flatten Path first) | +| Map Points | vector_nodes.rs:1313 | `content: Item, mapped: Context -> DVec2 (lazy) -> Item` | element-wise | yes (frame-synth) | no | Loop over points is invented by the node (index+position in context). Minor DEVIATION: point index restarts per element | +| Flatten Path | vector_nodes.rs:1331 | `content: List -> Item` | reducer | no | attrs | Settled reducer. In-code TODO already plans Flatten + per-element Combine Paths split | +| Sample Polyline | vector_nodes.rs:1373 | `content: Item, spacing: Item, separation: Item, quantity: Item, start_offset: Item, stop_offset: Item, adaptive_spacing: Item -> Item` | element-wise | no | attrs | Settled element-wise; memoized | +| Simplify | vector_nodes.rs:1459 | `content: Item, tolerance: Item -> Item` | element-wise | no | attrs | | +| Decimate | vector_nodes.rs:1503 | `content: Item, tolerance: Item -> Item` | element-wise | no | attrs | | +| Cut Path | vector_nodes.rs:1631 | `content: Item, progression: Item, reverse: Item, parameterized_distance: Item -> Item` | element-wise | no | no | DEVIATION: progression's whole part today indexes subpaths flattened across ALL elements; now per element | +| Cut Segments | vector_nodes.rs:1683 | `content: Item -> Item` | element-wise | no | no | Already clean per-element | +| Position on Path | vector_nodes.rs:1741 | `content: Item, progression: Item, reverse: Item, parameterized_distance: Item -> Item` | element-wise | no | attrs | Same cross-element subpath DEVIATION as Cut Path | +| Tangent on Path | vector_nodes.rs:1779 | `content: Item, progression: Item, reverse: Item, parameterized_distance: Item, radians: Item -> Item` | element-wise | no | attrs | Same DEVIATION | +| Scatter Points | vector_nodes.rs:1827 | `content: Item, separation: Item, seed: Item -> Item` | element-wise | no | no | DEVIATION: RNG today threads one stream across elements; per-element re-seeds (as Jitter Points already does) | +| Spline | vector_nodes.rs:1876 | `content: Item -> Item` | element-wise | no | no | DEVIATION: today filter_maps away pointless elements (hidden filtering); now passes them through unchanged | +| Jitter Points | vector_nodes.rs:1976 | `content: Item, max_distance: Item, seed: Item, along_normals: Item -> Item` | element-wise | no | attrs | RNG already per-element; behavior preserved | +| Offset Points | vector_nodes.rs:2026 | `content: Item, distance: Item -> Item` | element-wise | no | attrs | | +| Morph | vector_nodes.rs:2063 | `content: List, progression: Item, reverse: Item, distribution: Item, path: Item -> Item` | mixed | no | attrs | Content genuinely spread-by-index (rank 1). DEVIATION: control `path` today flattens subpaths across its list; now one element | +| Bevel | vector_nodes.rs:2870 | `source: Item, distance: Item -> Item` | element-wise | no | attrs | | +| Close Path | vector_nodes.rs:2883 | `source: Item -> Item` | element-wise | no | no | | +| Point Inside | vector_nodes.rs:2894 | `source: Item, point: Item -> Item` | element-wise | no | attrs | DEVIATION: today ORs containment across all elements; per-shape now, "any" = future Any reducer | +| Count Elements | vector_nodes.rs:2904 | `content: List -> Item` | reducer | no | no | Genuine list-length reducer (ListDyn, any element type) | +| Count Points | vector_nodes.rs:2909 | `content: Item -> Item` | element-wise | no | no | DEVIATION: today sums across elements; per-shape now, total = Sum reducer | +| Index Points | vector_nodes.rs:2916 | `content: List, index: Item -> Item` | mixed | no | no | Negative from-end indexing and clamping are node-defined valid semantics; behavior unchanged (resolution 7) | +| Path Length | vector_nodes.rs:2950 | `source: Item -> Item` | element-wise | no | attrs | DEVIATION: today sums perimeters across elements | +| Area | vector_nodes.rs:2969 | `content: Context -> Item (lazy) -> Item` | element-wise | yes (context modifier) | attrs | Resets Footprint before upstream eval (justified). DEVIATION: today sums areas across elements | +| Centroid | vector_nodes.rs:2983 | `content: Context -> Item (lazy), centroid_type: Item -> Item` | element-wise | yes (context modifier) | attrs | Same Footprint-reset pattern. DEVIATION: today one weighted centroid across all; group centroid = Flatten Path + Centroid, same recipe as Bounding Box (resolution 4) | +| Path Modify | vector_modification_nodes.rs:9 | (editor-internal) | infrastructure | no | attrs | Hidden node backing Pen/Path tool edits; exempt | +| Apply Transform | vector_modification_nodes.rs:37 | `vector: Item -> Item` | element-wise | no | attrs | Bakes ATTR_TRANSFORM into geometry, resets to identity | + +## Generators, Boolean, Brush (15 nodes) + +| Node | File:Line | Proposed connectors -> output | Class | Lazy | Attrs | Notes | +|---|---|---|---|---|---|---| +| Circle | generator_nodes.rs:59 | `radius: Item -> Item` | element-wise | no | no | Generator emits rank 0; `List` radii frames into `List` | +| Arc | generator_nodes.rs:72 | `radius: Item, start_angle: Item, sweep_angle: Item, arc_type: Item -> Item` | element-wise | no | no | | +| Spiral | generator_nodes.rs:98 | `spiral_type: Item, turns: Item, start_angle: Item, inner_radius: Item, outer_radius: Item, angular_resolution: Item -> Item` | element-wise | no | no | | +| Ellipse | generator_nodes.rs:120 | `radius_x: Item, radius_y: Item -> Item` | element-wise | no | no | | +| Rectangle | generator_nodes.rs:148 | `width: Item, height: Item, corner_radius: Item>, clamped: Item -> Item` | element-wise | no | no | DEVIATION: `List` CSS-shorthand mode replaced by the `Corners` value type + new Corners conversion node (resolution 3) | +| Regular Polygon | generator_nodes.rs:166 | `sides: Item, radius: Item -> Item` | element-wise | no | no | | +| Star | generator_nodes.rs:184 | `sides: Item, radius_1: Item, radius_2: Item -> Item` | element-wise | no | no | | +| QR Code | generator_nodes.rs:223 | `text: Item, has_size: Item, size: Item, error_correction: Item, individual_squares: Item -> Item` | element-wise | no | no | DEVIATION: encode failure -> `Item::default()` instead of empty list | +| Arrow | generator_nodes.rs:278 | `arrow_to: Item, shaft_width: Item, head_width: Item, head_length: Item -> Item` | element-wise | no | no | | +| Line | generator_nodes.rs:290 | `line_to: Item -> Item` | element-wise | no | no | | +| Grid | generator_nodes.rs:310 | `grid_type: Item, spacing: Item, columns: Item, rows: Item, angles: Item -> Item` | element-wise | no | no | Emits one mesh element | +| Boolean Operation | path-bool/lib.rs:26 | `content: List, operation: Item -> Item` | reducer | no | attrs | Genuinely order-dependent whole-list; output drops from 1-item List to Item (existing TODO acknowledges) | +| Brush Stamp Generator | brush/brush.rs:66 | (skip_impl internal) | infrastructure | no | no | Would be element-wise if exposed | +| Blit | brush/brush.rs:86 | (skip_impl internal) | infrastructure | flag | attrs | `positions` is a whole-list fold; `BlendFn` Node connector fails lazy criteria (low stakes, internal) | +| Brush | brush/brush.rs:191 | `background: Item>, trace: List, cache: #[data] -> Item>` | mixed | no | attrs | `trace` genuinely sequential rank 1. DEVIATION: today paints only background element 0 (code TODO resolved by framing) | + +## Graphic / list manipulation (31 nodes) + +This is the list-manipulation core, so rank-1 density is legitimately high here — each rank-1 connector is a genuine whole-list need. + +| Node | File:Line | Proposed connectors -> output | Class | Lazy | Attrs | Notes | +|---|---|---|---|---|---|---| +| Index Elements | graphic.rs:16 | `list: List, index: Item -> Item` | mixed | no | — | Out-of-bounds -> default (already). A List of indices frames into a gather node | +| Omit Element | graphic.rs:47 | `list: List, index: Item -> List` | mixed | no | — | Filtering = rank 1. Out-of-range -> unchanged (keep) | +| Extract Element | graphic.rs:77 | MERGE into Index Elements | mixed | no | — | "Bare element" distinction dissolves with Item | +| Map | graphic.rs:111 | DELETE/demote | infrastructure | — | — | Compiler framing subsumes it | +| Mirror | graphic.rs:146 | `content: List, relative_to_bounds: Item, offset: Item, angle: Item, keep_original: Item -> List` | mixed | no | attrs | Whole-list bounds + duplication (up to 2N out) | +| Path of Subgraph | graphic.rs:218 | (editor plumbing) | infrastructure | no | — | `List` is a single path value | +| Write Attribute | graphic.rs:230 | `content: Item, name: Item, value: Item -> Item` | element-wise | no (was lazy) | attrs | Settled: lazy per-index value becomes eager rank-0 zip | +| Attach Attribute | graphic.rs:267 | MERGE into Write Attribute (confirmed) | element-wise | no | attrs | DEVIATION: wrap-around for short source becomes repeat-last (resolution 6) | +| Read Attribute (×12: Vector, Number, Bool, String, Transform, Color, Blend Mode, Gradient Type, Spread Method, Gradient Stops, Artboard, Raster) | graphic.rs:301-482 | `content: Item, name: Item -> Item` | element-wise | no | attrs | DEVIATION: today skips missing values (shorter output); now emits default per slot, preserving length. Could collapse to one generic node (orthogonal) | +| Extend | graphic.rs:498 | `base: List, new: List -> List` | floor | no | — | Settled: genuine concatenation | +| Legacy Layer Extend | graphic.rs:518 | DELETE | infrastructure | no | attrs | Document-upgrade shim (existing TODO) | +| Wrap Graphic | graphic.rs:545 | `content: List -> Item` | reducer | no | — | Grouping. DAffine2/DVec2 coercion impls worth revisiting | +| To Graphic | graphic.rs:566 | `content: Item -> Item` | element-wise | no | — | DEVIATION: today wraps whole list into ONE Graphic; now per-item conversion. Whole-list grouping = Wrap Graphic. May be subsumed by auto-conversion | +| Flatten Graphic | graphic.rs:584 | `content: List, fully_flatten: Item -> List` | mixed | no | attrs | Explicit nesting reduction; composes parent transforms into children | +| Flatten Vector | graphic.rs:621 | `content: List -> List` | floor | no | attrs | Deep flatten + type filter; ATTR_EDITOR_MERGED_LAYERS hack has removal TODO | +| Flatten Raster | graphic.rs:654 | `content: List -> List>` | floor | no | — | | +| Flatten Color | graphic.rs:660 | `content: List -> List` | floor | no | — | | +| Flatten Gradient | graphic.rs:666 | `content: List -> List` | floor | no | — | | +| Colors to Gradient | graphic.rs:672 | `colors: List -> Item` | reducer | no | — | Spread by index/length. Output drops to Item | +| Create Artboard | artboard.rs:12 | `content: Context -> List (lazy), location: Item, dimensions: Item, background: Item, clip: Item -> Item` | mixed | yes (context modifier) | attrs | Translates Footprint before upstream eval (justified). DEVIATION: background List element-0 + WHITE fallback becomes Item with default | + +## Text (33 nodes) + +| Node | File:Line | Proposed connectors -> output | Class | Lazy | Attrs | Notes | +|---|---|---|---|---|---|---| +| String Value | text/lib.rs:187 | `string: Item -> Item` | element-wise | no | no | Value generator | +| As String | text/lib.rs:193 | `value: Item -> Item` | element-wise | no | no | Debug passthrough | +| String Concatenate | text/lib.rs:199 | `first: Item, second: Item -> Item` | element-wise | no | no | Two lists zip pairwise (not N×M) | +| String Replace | text/lib.rs:205 | `string: Item, from: Item, to: Item -> Item` | element-wise | no | no | | +| String Slice | text/lib.rs:213 | `string: Item, start: Item, end: Item -> Item` | element-wise | no | no | Grapheme-indexed within one string | +| String Truncate | text/lib.rs:236 | `string: Item, length: Item, suffix: Item -> Item` | element-wise | no | no | | +| Format Number | text/lib.rs:264 | `number: Item, decimal_places: Item, decimal_separator: Item, fixed_decimals: Item, use_thousands_separator: Item, thousands_separator: Item, start_at_10000: Item -> Item` | element-wise | no | no | | +| String to Number | text/lib.rs:362 | `string: Item, fallback: Item -> Item` | element-wise | no | no | Explicit fallback connector (better than Default) | +| String Trim | text/lib.rs:374 | `string: Item, start: Item, end: Item -> Item` | element-wise | no | no | | +| String Escape | text/lib.rs:398 | `string: Item, unescape: Item -> Item` | element-wise | no | no | | +| String Reverse | text/lib.rs:411 | `string: Item -> Item` | element-wise | no | no | Graphemes within one string, not list order | +| String Repeat | text/lib.rs:421 | `string: Item, count: Item, separator: Item, separator_escaping: Item -> Item` | element-wise | no | no | One joined string, not a frame | +| String Pad | text/lib.rs:453 | `string: Item, length: Item, padding: Item, up_to: Item, from_end: Item -> Item` | element-wise | no | no | | +| String Contains | text/lib.rs:517 | `string: Item, substring: Item, at_start: Item, at_end: Item -> Item` | element-wise | no | no | | +| String Find Index | text/lib.rs:538 | `string: Item, substring: Item, from_end: Item -> Item` | element-wise | no | no | -1 not-found sentinel (kept) | +| String Occurrences | text/lib.rs:567 | `string: Item, substring: Item, overlapping: Item -> Item` | element-wise | no | no | | +| String Capitalization | text/lib.rs:636 | `string: Item, capitalization: Item, use_joiner: Item, joiner: Item -> Item` | element-wise | no | no | | +| String Length | text/lib.rs:723 | `string: Item -> Item` | element-wise | no | no | Graphemes within one string; list length = Count Elements | +| String Split | text/lib.rs:731 | `string: Item, delimiter: Item, delimiter_escaping: Item -> List` | expander | no | no | | +| String Join | text/lib.rs:752 | `strings: List, separator: Item, separator_escaping: Item -> Item` | reducer | no | no | Genuine whole-list | +| Map String | text/lib.rs:771 | DEPRECATE | infrastructure | — | no | Subsumed by framing | +| Read String | text/lib.rs:794 | DEPRECATE | infrastructure | — | no | Vararg pair of Map String | +| Serialize | text/lib.rs:803 | `value: Item -> Item` | element-wise | no | no | Debug node | +| Regex Contains | regex.rs:7 | `string: Item, pattern: Item, case_insensitive: Item, multiline: Item, at_start: Item, at_end: Item -> Item` | element-wise | no | no | Invalid pattern -> false | +| Regex Replace | regex.rs:45 | `string: Item, pattern: Item, replacement: Item, replace_all: Item, case_insensitive: Item, multiline: Item -> Item` | element-wise | no | no | Invalid pattern -> unchanged | +| Regex Find | regex.rs:87 | `string: Item, pattern: Item, match_index: Item, case_insensitive: Item, multiline: Item -> List` | expander | no | attrs | Writes ATTR_START/END/NAME; empty list on no match | +| Regex Find All | regex.rs:158 | `string: Item, pattern: Item, case_insensitive: Item, multiline: Item -> List` | expander | no | attrs | | +| Regex Split | regex.rs:201 | `string: Item, pattern: Item, case_insensitive: Item, multiline: Item -> List` | expander | no | no | | +| Format JSON | json.rs:13 | `json: Item, compact: Item, multi_line: Item, indent: Item, break_length: Item, break_nested: Item -> Item` | element-wise | no | no | Invalid JSON -> unchanged | +| Query JSON | json.rs:187 | `json: Item, path: Item, unquote_strings: Item -> Item` | element-wise | no | no | Empty string on no match | +| Query JSON All | json.rs:225 | `json: Item, path: Item, unquote_strings: Item -> List` | expander | no | attrs | Writes ATTR_TYPE | +| Text | gstd/text.rs:12 | `text: Item, font: Item, size: Item, line_height: Item, letter_spacing: Item, letter_tilt: Item, has_max_width: Item, max_width: Item, has_max_height: Item, max_height: Item, align: Item -> Item` | element-wise | no | attrs | DEVIATION: single-element List output is bulk-conversion residue; one styled Item with typographic attributes | +| Text to Vector | gstd/text.rs:96 | `strings: Item -> Item` | element-wise | no | attrs | SPLIT: separate_glyphs mode becomes a new Separate Glyphs expander node (resolution 10) | + +## Raster (31 nodes) + +`T` ranges over `Raster | Color | GradientStops`; per-pixel/per-stop looping stays inside the `Adjust`/`Blend` traits, whose impls move from `List` down to bare `X`. GPU (`shader_node`) machinery unchanged; kernel params stay bare for uniform compatibility. + +| Node | File:Line | Proposed connectors -> output | Class | Lazy | Attrs | Notes | +|---|---|---|---|---|---|---| +| Luminance | adjustments.rs:52 | `input: Item, luminance_calc: Item -> Item` | element-wise | no | no | | +| Gamma Correction | adjustments.rs:77 | `input: Item, gamma: Item, inverse: Item -> Item` | element-wise | no | no | | +| Extract Channel | adjustments.rs:98 | `input: Item, channel: Item -> Item` | element-wise | no | no | | +| Make Opaque | adjustments.rs:122 | `input: Item -> Item` | element-wise | no | no | | +| Brightness/Contrast Classic | adjustments.rs:144 | `input: Item, brightness: Item, contrast: Item -> Item` | element-wise | no | no | Hidden; kept for GPU | +| Brightness/Contrast | adjustments.rs:175 | `input: Item, brightness: Item, contrast: Item, use_classic: Item -> Item` | element-wise | no | no | `use_classic` hidden mode kept for PSD interop | +| Levels | adjustments.rs:256 | `image: Item, shadows/midtones/highlights/output min/max: Item -> Item` | element-wise | no | no | | +| Black & White | adjustments.rs:335 | `image: Item, tint: Item, 6× channel weights: Item -> Item` | element-wise | no | no | Bare `tint: Color` currently type-errors (finding 8); refactor unblocks it | +| Hue/Saturation | adjustments.rs:412 | `input: Item, hue_shift: Item, saturation_shift: Item, lightness_shift: Item -> Item` | element-wise | no | no | | +| Invert | adjustments.rs:444 | `input: Item -> Item` | element-wise | no | no | | +| Threshold | adjustments.rs:465 | `image: Item, min_luminance: Item, max_luminance: Item, luminance_calc: Item -> Item` | element-wise | no | no | | +| Vibrance | adjustments.rs:511 | `image: Item, vibrance: Item -> Item` | element-wise | no | no | | +| Channel Mixer | adjustments.rs:713 | `image: Item, monochrome: Item, 16× weights: Item -> Item` | element-wise | no | no | `_output_channel` display-only | +| Selective Color | adjustments.rs:845 | `image: Item, mode: Item, 36× offsets: Item -> Item` | element-wise | no | no | `_colors` display-only | +| Posterize | adjustments.rs:989 | `input: Item, levels: Item -> Item` | element-wise | no | no | | +| Exposure | adjustments.rs:1019 | `input: Item, exposure: Item, offset: Item, gamma_correction: Item -> Item` | element-wise | no | no | | +| Sample Image | std_nodes.rs:33 | `image_frame: Item> -> Item>` | element-wise | no | attrs | DEVIATION: today filter_maps away offscreen elements (hidden filtering); now passes through | +| Combine Channels | std_nodes.rs:97 | `red/green/blue/alpha: Item> ×4 -> Item>` | element-wise | no | attrs | DEVIATION: today pads short lists with zero/one fills and drops mismatched rows; resolved: longest-zip, mismatched dimensions -> default raster slot (resolution 2) | +| Mask | std_nodes.rs:178 | `image: Item>, stencil: Item> -> Item>` | element-wise | no | attrs | Zip answers the code's own multi-stencil TODO. DEVIATION resolved: degenerate stencil passes the image through (resolution 2) | +| Extend Image to Bounds | std_nodes.rs:229 | `image: Item>, bounds: Item -> Item>` | element-wise | no | attrs | | +| Empty Image | std_nodes.rs:277 | `transform: Item, color: Item -> Item>` | element-wise | no | attrs | WHITE fallback becomes `#[default(Color::WHITE)]` | +| Image | std_nodes.rs:292 | `resource: Item -> Item>` | element-wise | no | no | DEVIATION: decode failure -> `Raster::default()` instead of empty list | +| Noise Pattern | std_nodes.rs:316 | `clip: Item, seed: Item, scale: Item, + 12 noise params: Item<...> -> Item>` | element-wise | no | attrs | Reads footprint eagerly (not a modifier). DEVIATION: offscreen -> default raster instead of empty list | +| Mandelbrot | std_nodes.rs:475 | `-> Item>` | element-wise | no | attrs | Same footprint read + empty-list DEVIATION | +| Blur | filter.rs:89 | `image_frame: Item>, radius: Item, box_blur: Item, gamma: Item -> Item>` | element-wise | no | no | Already clean per-element | +| Median Filter | filter.rs:125 | `image_frame: Item>, radius: Item -> Item>` | element-wise | no | no | | +| Mix | blending_nodes.rs:144 | `over: Item, under: Item, blend_mode: Item, opacity: Item -> Item` | element-wise | no | no | DEVIATION resolved: longest-zip replaces today's min-length zip with surplus passthrough/drop (resolution 2) | +| Color Overlay | blending_nodes.rs:168 | `image: Item, color: Item, blend_mode: Item, opacity: Item -> Item` | element-wise | no | no | | +| Image Color Palette | image_color_palette.rs:6 | `image: Item>, count: Item -> List` | expander | no | no | DEVIATION: today pools all elements into one histogram; per-image palettes now, framed output force-flattens | +| Gradient Map | gradient_map.rs:12 | `image: Item, gradient: Item, reverse: Item -> Item` | element-wise | no | no | DEVIATION resolved: default gradient applies; element(0) + empty-passthrough behavior dropped (resolution 5) | +| Dehaze | dehaze.rs:10 | `image_frame: Item>, strength: Item -> Item>` | element-wise | no | no | | + +## Transform, Repeat, Blending (16 nodes) + +| Node | File:Line | Proposed connectors -> output | Class | Lazy | Attrs | Notes | +|---|---|---|---|---|---|---| +| Transform | transform_nodes.rs:14 | `content: Context -> Item (lazy), translation: Item, rotation: Item, scale: Item, skew: Item -> Item` | element-wise | content (footprint modifier) | attrs | THE broadcast node (replaces Repeat on Points). DAffine2/DVec2 value implementations need an eager value-kernel sibling | +| Reset Transform | transform_nodes.rs:57 | `content: Item, reset_translation: Item, reset_rotation: Item, reset_scale: Item -> Item` | element-wise | no | attrs | Round-trip | +| Replace Transform | transform_nodes.rs:95 | `content: Item, transform: Item -> Item` | element-wise | no | attrs | Round-trip | +| Extract Transform | transform_nodes.rs:117 | `content: Item -> Item` | element-wise | no | attrs | DEVIATION: today reads only element 0; framing yields per-element transforms (TODO #2982 anticipated this) | +| Invert Transform | transform_nodes.rs:123 | `transform: Item -> Item` | element-wise | no | no | | +| Decompose Translation | transform_nodes.rs:129 | `transform: Item -> Item` | element-wise | no | no | | +| Decompose Rotation | transform_nodes.rs:135 | `transform: Item -> Item` | element-wise | no | no | | +| Decompose Scale | transform_nodes.rs:143 | `transform: Item, scale_type: Item -> Item` | element-wise | no | no | | +| Decompose Skew | transform_nodes.rs:152 | `transform: Item -> Item` | element-wise | no | no | | +| Repeat | repeat_nodes.rs:12 | `content: Context -> Item (lazy), count: Item, reverse: Item -> List` | expander | content (frame-synth) | no | DEVIATION: multi-element upstream today flattens count×N per iteration; framed version groups per element (same total, different order) | +| Repeat Array | repeat_nodes.rs:48 | `content: Context -> Item (lazy), direction: Item, angle: Item, count: Item -> List` | expander | content (frame-synth) | attrs | Composes per-copy ATTR_TRANSFORM. Same flattening DEVIATION | +| Repeat Radial | repeat_nodes.rs:96 | `content: Context -> Item (lazy), start_angle: Item, radius: Item, count: Item -> List` | expander | content (frame-synth) | attrs | Same family | +| Repeat on Points | repeat_nodes.rs:142 | DELETE | expander | — | attrs | Transform broadcasting replaces it; classified for the record only | +| Blend Mode | blending/lib.rs:185 | `content: Item, blend_mode: Item -> Item` | element-wise | no | attrs | Round-trip. "Apply to the parent" TODO fixed by rank-0 wires | +| Opacity | blending/lib.rs:209 | `content: Item, has_opacity: Item, opacity: Item, has_fill: Item, fill: Item -> Item` | element-wise | no | attrs | Round-trip. Missing attribute = 1.0 implicit default, NOT f64::default() — round-trip must preserve this | +| Clipping Mask | blending/lib.rs:251 | `content: Item, clip: Item -> Item` | element-wise | no | attrs | Round-trip. Same TODO fixed | + +## Gcore (24 nodes) + +| Node | File:Line | Proposed connectors -> output | Class | Lazy | Attrs | Notes | +|---|---|---|---|---|---|---| +| Read Graphic | context.rs:10 | `-> Item` | element-wise | no | no | Vararg generator. DEVIATION: today returns singleton `List`; binding site (`List::new_from_item`) confirms one-element-per-iteration intent | +| Read Vector | context.rs:18 | `-> Item` | element-wise | no | no | Same | +| Read Raster | context.rs:26 | `-> Item>` | element-wise | no | no | Same | +| Read Color | context.rs:34 | `-> Item` | element-wise | no | no | Same | +| Read Gradient | context.rs:42 | `-> Item` | element-wise | no | no | Same | +| Read Position | context.rs:50 | `loop_level: Item -> Item` | element-wise | no | no | Context-driven generator | +| Read Index | context.rs:67 | `loop_level: Item -> Item` | element-wise | no | no | Context-driven generator | +| Real Time | animation.rs:32 | `component: Item -> Item` | element-wise | no | no | Variation from context time | +| Animation Time | animation.rs:53 | `rate: Item -> Item` | element-wise | no | no | | +| Quantize Real Time | animation.rs:64 | `value: Context -> T (lazy), quantum: Item -> T` | infrastructure | yes (context modifier) | no | Rewrites time in context before upstream eval (finding 3) | +| Quantize Animation Time | animation.rs:104 | `value: Context -> T (lazy), quantum: Item -> T` | infrastructure | yes (context modifier) | no | Same | +| Pointer Position | animation.rs:143 | `-> Item` | element-wise | no | no | | +| Log to Console | debug.rs:8 | `value: Item -> Item` | element-wise | no | no | Rank 0 means one log line per element when framed (debatable but consistent) | +| Size Of | debug.rs:16 | DELETE | element-wise | no | no | Vestigial and unused (resolution 8) | +| Some | debug.rs:22 | DELETE | element-wise | no | no | Vestigial; existed to make Option values, which wires ban (resolution 8) | +| Unwrap Option | debug.rs:28 | DELETE | element-wise | no | no | Vestigial; paired with Some (resolution 8) | +| Clone | debug.rs:34 | (by-reference wire test) | infrastructure | no | no | By-reference connectors are a question mark under Item/List wires | +| Passthrough | ops.rs:9 | `content: Item -> Item` | element-wise | no | no | Identity at any rank | +| Into | ops.rs:14 | `value: Item -> Item` | element-wise | no | no | Compiler-inserted conversion | +| Convert | ops.rs:19 | `value: Item -> Item` | element-wise | no | no | Value conversion; eager footprint read | +| Memoize | memo.rs:13 | (evaluation-control) | infrastructure | yes (exempt category) | no | Purpose IS skipping upstream evaluation (finding 2) | +| Monitor | memo.rs:39 | (evaluation-control) | infrastructure | yes (exempt category) | no | Compiler-inserted introspection tap | +| Extract XY | extract_xy.rs:9 | `vector: Item, axis: Item -> Item` | element-wise | no | no | Textbook rank-0 kernel | +| Context Modification | context_modification.rs:15 | (compiler-inserted) | infrastructure | yes (context modifier) | no | Strips context before upstream eval | + +## Gstd / render pipeline (21 nodes) + +| Node | File:Line | Proposed connectors -> output | Class | Lazy | Attrs | Notes | +|---|---|---|---|---|---|---| +| Get Request | platform_application_io.rs:49 | `url: Item, discard_result: Item, headers: Item -> Item` | element-wise | no | no | Framing a URL list issues N requests | +| Post Request | platform_application_io.rs:82 | `url: Item, body: Item, discard_result: Item, headers: Item -> Item` | element-wise | no | no | Blob unification adopted (resolution 9) | +| String to Bytes | platform_application_io.rs:119 | `string: Item -> Item` | element-wise | no | no | Blob unification adopted (resolution 9) | +| Image to Bytes | platform_application_io.rs:125 | `image: Item> -> Item` | element-wise | no | no | Blob unification adopted (resolution 9). DEVIATION: today reads only element(0) | +| Load Resource | platform_application_io.rs:140 | `url: Item -> Item>` | element-wise | no | no | Already rank-0 blob; failure -> empty placeholder (matches Default) | +| Decode Image | platform_application_io.rs:164 | `data: Item> -> Item>` | element-wise | no | no | DEVIATION: decode failure -> `Raster::default()` instead of empty list | +| Create Canvas | platform_application_io.rs:188 | (wasm surface factory) | infrastructure | no | no | | +| Rasterize | platform_application_io.rs:195 | `data: List, footprint: Item, canvas: (infra) -> Item>` | reducer | no | attrs | Whole scene -> one raster. DEVIATION: degenerate footprint -> default raster instead of empty list | +| Editor API | platform_application_io.rs:265 | (scope) | infrastructure | no | no | | +| Resource | platform_application_io.rs:270 | `hash: Item -> Item` | element-wise | no | no | DEVIATION: panics on missing today; becomes `Resource::default()` + logged error (no-panic policy) | +| Wgpu Executor | platform_application_io.rs:278 | (scope) | infrastructure | no | no | | +| Try Wgpu Executor | platform_application_io.rs:288 | (scope) | infrastructure | no | no | Option on infra scope wire: exempt from no-Option rule | +| Render Intermediate | render_node.rs:26 | (render sink) | infrastructure | flag | no | Lazy connector fails both criteria as written but plausibly inherits evaluation-control justification from the sink chain | +| Render | render_node.rs:77 | (render sink) | infrastructure | no | no | | +| Create Context | render_node.rs:150 | (context synthesizer) | infrastructure | yes (context modifier) | no | Synthesizes footprint/time/pointer/varargs before upstream eval | +| Render Pixel Preview | render_pixel_preview.rs:11 | (render sink) | infrastructure | yes (context modifier) | no | Modifies Footprint for logical-resolution upstream | +| Pixel Preview Pipeline | render_pixel_preview.rs:78 | (pipeline warm-up) | infrastructure | no | no | | +| Render Background | render_background.rs:15 | (final composite) | infrastructure | no | no | | +| Composite Background Pipeline | render_background.rs:121 | (pipeline warm-up) | infrastructure | no | no | | +| Render Output Cache | render_cache.rs:327 | (tile cache) | infrastructure | yes (context modifier + frame-synth) | no | Re-evaluates upstream per missing tile with synthesized region Footprints | +| Upload Texture | texture_conversion.rs:252 | DELETE (deprecated for Convert) | element-wise | no | no | Per-element CPU->GPU upload preserving attributes — exactly what framing provides | From a6c202e3b37e14e8c93733fc78c8526c91bc11b6 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Fri, 3 Jul 2026 13:02:12 -0700 Subject: [PATCH 002/165] Implement StaticType for Item --- node-graph/libraries/core-types/src/list.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/node-graph/libraries/core-types/src/list.rs b/node-graph/libraries/core-types/src/list.rs index b0d5888115..eccc49e151 100644 --- a/node-graph/libraries/core-types/src/list.rs +++ b/node-graph/libraries/core-types/src/list.rs @@ -1386,6 +1386,10 @@ impl Item { } } +unsafe impl StaticType for Item { + type Static = Item; +} + // =========== // ItemIter // =========== From 0f7a40c4e6e025dca7002d61bbd11bc4f5ccce7f Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Fri, 3 Jul 2026 13:02:12 -0700 Subject: [PATCH 003/165] Generate Item and mapped List wire variants for nodes declaring an Item primary input --- node-graph/node-macro/src/codegen.rs | 384 ++++++++++++++++++------ node-graph/node-macro/src/validation.rs | 68 +++++ 2 files changed, 356 insertions(+), 96 deletions(-) diff --git a/node-graph/node-macro/src/codegen.rs b/node-graph/node-macro/src/codegen.rs index b65024fe64..37a397dcab 100644 --- a/node-graph/node-macro/src/codegen.rs +++ b/node-graph/node-macro/src/codegen.rs @@ -64,6 +64,10 @@ pub(crate) fn generate_node_code(crate_ident: &CrateIdent, parsed: &ParsedNodeFn // Node generics for regular fields (Node0, Node1, ...) let node_generics: Vec = regular_fields.iter().enumerate().map(|(i, _)| format_ident!("Node{}", i)).collect(); + // An `Item` primary input declares the node as an element-wise rank-0 kernel over element type `T` + let primary_element = primary_item_element(parsed); + let element_wise = primary_element.is_some(); + // Extract just the idents from data_field_generics for struct type parameters let data_field_generic_idents: Vec = data_field_generics .iter() @@ -119,7 +123,7 @@ pub(crate) fn generate_node_code(crate_ident: &CrateIdent, parsed: &ParsedNodeFn quote! { pub(super) #name: #r#gen } }); - let struct_fields = data_field_defs.chain(regular_field_defs); + let struct_fields: Vec<_> = data_field_defs.chain(regular_field_defs).collect(); let mut future_idents = Vec::new(); @@ -187,8 +191,10 @@ pub(crate) fn generate_node_code(crate_ident: &CrateIdent, parsed: &ParsedNodeFn let default_types: Vec<_> = regular_fields .iter() - .map(|field| match &field.ty { + .enumerate() + .map(|(index, field)| match &field.ty { ParsedFieldType::Regular(RegularParsedField { implementations, .. }) => match implementations.first() { + Some(ty) if index == 0 && element_wise => quote!(Some(concrete!(#core_types::list::List<#ty>))), Some(ty) => quote!(Some(concrete!(#ty))), _ => quote!(None), }, @@ -276,64 +282,91 @@ pub(crate) fn generate_node_code(crate_ident: &CrateIdent, parsed: &ParsedNodeFn let all_implementation_types = all_implementation_types.chain(input.implementations.iter().cloned()); let input_type = &parsed.input.ty; - let mut clauses = Vec::new(); let mut clampable_clauses = Vec::new(); - for (field, name) in regular_fields.iter().zip(node_generics.iter()) { - clauses.push(match (&field.ty, *is_async) { - ( - ParsedFieldType::Regular(RegularParsedField { - ty, number_hard_min, number_hard_max, .. - }), - _, - ) => { - let all_lifetime_ty = substitute_lifetimes(ty.clone(), "all"); - let id = future_idents.len(); - let fut_ident = format_ident!("F{}", id); - future_idents.push(fut_ident.clone()); - - // Add Clampable bound if this field uses hard_min or hard_max - if number_hard_min.is_some() || number_hard_max.is_some() { - // The bound applies to the Output type of the future, which is #ty - clampable_clauses.push(quote!(#ty: #core_types::misc::Clampable)); - } - - quote!( - #fut_ident: core::future::Future + #core_types::WasmNotSend + 'n, - for<'all> #all_lifetime_ty: #core_types::WasmNotSend, - #name: #core_types::Node<'n, #input_type, Output = #fut_ident> + #core_types::WasmNotSync - ) - } - (ParsedFieldType::Node(NodeParsedField { input_type, output_type, .. }), true) => { - let id = future_idents.len(); - let fut_ident = format_ident!("F{}", id); - future_idents.push(fut_ident.clone()); - - quote!( - #fut_ident: core::future::Future + #core_types::WasmNotSend + 'n, - #name: #core_types::Node<'n, #input_type, Output = #fut_ident > + #core_types::WasmNotSync - ) - } - (ParsedFieldType::Node { .. }, false) => unreachable!("Found node which takes an impl Node<> input but is not async"), - }); + for field in ®ular_fields { + // Add Clampable bound if this field uses hard_min or hard_max, applying to the Output type of the future, which is #ty + if let ParsedFieldType::Regular(RegularParsedField { + ty, number_hard_min, number_hard_max, .. + }) = &field.ty + && (number_hard_min.is_some() || number_hard_max.is_some()) + { + clampable_clauses.push(quote!(#ty: #core_types::misc::Clampable)); + } } + future_idents.extend((0..regular_fields.len()).map(|id| format_ident!("F{}", id))); + + // Builds every field's where-clause bounds, optionally wrapping the primary field's wire type for the element-wise variants + let build_field_clauses = |primary_wire: Option| -> Vec { + regular_fields + .iter() + .zip(node_generics.iter()) + .zip(future_idents.iter()) + .enumerate() + .map(|(index, ((field, name), fut_ident))| match (&field.ty, *is_async) { + (ParsedFieldType::Regular(RegularParsedField { ty, .. }), _) => { + let ty = match (index, primary_wire) { + // An `Item`-declared primary contributes its element type to the wire wrapping + (0, Some(wrap)) => { + let element_ty = peel_item(ty).unwrap_or_else(|| ty.clone()); + wrap.apply(core_types, &element_ty) + } + _ => ty.clone(), + }; + let all_lifetime_ty = substitute_lifetimes(ty.clone(), "all"); + quote!( + #fut_ident: core::future::Future + #core_types::WasmNotSend + 'n, + for<'all> #all_lifetime_ty: #core_types::WasmNotSend, + #name: #core_types::Node<'n, #input_type, Output = #fut_ident> + #core_types::WasmNotSync + ) + } + (ParsedFieldType::Node(NodeParsedField { input_type, output_type, .. }), true) => { + quote!( + #fut_ident: core::future::Future + #core_types::WasmNotSend + 'n, + #name: #core_types::Node<'n, #input_type, Output = #fut_ident > + #core_types::WasmNotSync + ) + } + (ParsedFieldType::Node { .. }, false) => unreachable!("Found node which takes an impl Node<> input but is not async"), + }) + .collect() + }; let where_clause = where_clause.clone().unwrap_or(WhereClause { where_token: Token![where](output_type.span()), predicates: Default::default(), }); - let mut struct_where_clause = where_clause.clone(); - let extra_where: Punctuated = parse_quote!( - #(#clauses,)* - #(#clampable_clauses,)* - #output_type: 'n, - ); - struct_where_clause.predicates.extend(extra_where); + let make_struct_where_clause = |field_clauses: Vec, extra_clauses: Vec| { + let mut struct_where_clause = where_clause.clone(); + let extra_where: Punctuated = parse_quote!( + #(#field_clauses,)* + #(#clampable_clauses,)* + #(#extra_clauses,)* + #output_type: 'n, + ); + struct_where_clause.predicates.extend(extra_where); + struct_where_clause + }; + let struct_where_clause = make_struct_where_clause(build_field_clauses(element_wise.then_some(WireWrapper::Item)), Vec::new()); + + // The mapped variant clones every non-primary parameter once per row, so those parameter types must be Clone + let param_clone_clauses: Vec = regular_fields + .iter() + .skip(1) + .filter_map(|field| match &field.ty { + ParsedFieldType::Regular(RegularParsedField { ty, .. }) => Some(quote!(#ty: Clone)), + ParsedFieldType::Node(_) => None, + }) + .collect(); + let mapped_struct_where_clause = element_wise.then(|| make_struct_where_clause(build_field_clauses(Some(WireWrapper::List)), param_clone_clauses)); // Only regular fields are parameters to new() - let new_args = node_generics.iter().zip(regular_field_names.iter()).map(|(r#gen, name)| { - quote! { #name: #r#gen } - }); + let new_args: Vec<_> = node_generics + .iter() + .zip(regular_field_names.iter()) + .map(|(r#gen, name)| { + quote! { #name: #r#gen } + }) + .collect(); // Initialize data fields with Default, regular fields with parameters let data_inits = data_field_names.iter().map(|name| { @@ -342,7 +375,7 @@ pub(crate) fn generate_node_code(crate_ident: &CrateIdent, parsed: &ParsedNodeFn let regular_inits = regular_field_names.iter().map(|name| { quote! { #name } }); - let all_field_inits = data_inits.chain(regular_inits); + let all_field_inits: Vec<_> = data_inits.chain(regular_inits).collect(); let async_keyword = is_async.then(|| quote!(async)); let await_keyword = is_async.then(|| quote!(.await)); @@ -366,15 +399,19 @@ pub(crate) fn generate_node_code(crate_ident: &CrateIdent, parsed: &ParsedNodeFn quote!() }; + let eval_prelude = quote! { + use #core_types::misc::Clampable; + + #(#eval_args)* + #(#min_max_args)* + }; + let eval_impl = quote! { type Output = #core_types::registry::DynFuture<'n, #output_type>; #[inline] fn eval(&'n self, __input: #input_type) -> Self::Output { Box::pin(async move { - use #core_types::misc::Clampable; - - #(#eval_args)* - #(#min_max_args)* + #eval_prelude self::#fn_name(__input #(, &self.#data_field_names)* #(, #regular_field_names)*) #await_keyword }) } @@ -382,6 +419,46 @@ pub(crate) fn generate_node_code(crate_ident: &CrateIdent, parsed: &ParsedNodeFn #serialize_impl }; + // The mapped variant calls the kernel once per item of the primary input's list, broadcasting the other parameters by clone + let mapped_eval_impl = element_wise.then(|| { + let primary_name = ®ular_field_names[0]; + let per_row_args: Vec<_> = regular_fields + .iter() + .enumerate() + .map(|(index, field)| { + let name = &field.pat_ident.ident; + match (index, &field.ty) { + (0, _) => quote!(__item), + (_, ParsedFieldType::Regular(_)) => quote!(#name.clone()), + (_, ParsedFieldType::Node(_)) => quote!(#name), + } + }) + .collect(); + + let list_element_ty = peel_item(output_type).unwrap_or_else(|| output_type.clone()); + + quote! { + type Output = #core_types::registry::DynFuture<'n, #core_types::list::List<#list_element_ty>>; + #[inline] + #[allow(clippy::clone_on_copy)] + fn eval(&'n self, __input: #input_type) -> Self::Output { + Box::pin(async move { + #eval_prelude + + let mut __output = #core_types::list::List::with_capacity(#primary_name.len()); + for __item in #primary_name { + let __result = self::#fn_name(__input.clone() #(, &self.#data_field_names)* #(, #per_row_args)*) #await_keyword; + __output.push(__result); + } + + __output + }) + } + + #serialize_impl + } + }); + let identifier = format_ident!("{}_proto_ident", fn_name); let identifier_path = match parsed.attributes.path.as_ref() { Some(path) => { @@ -391,7 +468,8 @@ pub(crate) fn generate_node_code(crate_ident: &CrateIdent, parsed: &ParsedNodeFn None => quote!(std::module_path!()), }; - let register_node_impl = generate_register_node_impl(parsed, &field_names, &struct_name, &identifier)?; + let mapped_struct_name = format_ident!("{}Mapped", struct_name); + let register_node_impl = generate_register_node_impl(parsed, &field_names, &struct_name, &mapped_struct_name, &identifier)?; let import_name = format_ident!("_IMPORT_STUB_{}", mod_name.to_string().to_case(Case::UpperSnake)); let properties = &attributes.properties_string.as_ref().map(|value| quote!(Some(#value))).unwrap_or(quote!(None)); @@ -402,6 +480,47 @@ pub(crate) fn generate_node_code(crate_ident: &CrateIdent, parsed: &ParsedNodeFn let node_input_accessor = generate_node_input_references(parsed, fn_generics, &field_idents, core_types, &identifier, &cfg); let ShaderTokens { shader_entry_point, gpu_node } = attributes.shader_node.as_ref().map(|n| n.codegen(crate_ident, parsed)).unwrap_or(Ok(ShaderTokens::default()))?; + let mapped_node_impl = match (&mapped_struct_where_clause, &mapped_eval_impl) { + (Some(mapped_where_clause), Some(mapped_eval)) => quote! { + #cfg + #[automatically_derived] + impl<'n, #(#fn_generics,)* #(#node_generics,)* #(#future_idents,)*> #core_types::Node<'n, #input_type> for #mod_name::#mapped_struct_name<#(#struct_type_params,)*> + #mapped_where_clause + { + #mapped_eval + } + }, + _ => quote!(), + }; + + let mapped_struct_def = element_wise.then(|| { + quote! { + #struct_derives + pub struct #mapped_struct_name<#(#struct_generic_params,)*> { + #(#struct_fields,)* + } + + #[automatically_derived] + impl<'n, #(#struct_generic_params,)*> #mapped_struct_name<#(#struct_type_params,)*> + { + #[allow(clippy::too_many_arguments)] + pub fn new(#(#new_args,)*) -> Self { + Self { + #(#all_field_inits,)* + } + } + } + } + }); + + let mapped_struct_export = element_wise.then(|| { + quote! { + #cfg + #[doc(hidden)] + pub use #mod_name::#mapped_struct_name; + } + }); + let display_name_header = format!("# {display_name}"); let mut description_doc_attrs = vec![quote!(#[doc = #display_name_header]), quote!(#[doc = ""])]; description_doc_attrs.extend(description.lines().map(|line| quote!(#[doc = #line]))); @@ -440,6 +559,8 @@ pub(crate) fn generate_node_code(crate_ident: &CrateIdent, parsed: &ParsedNodeFn #eval_impl } + #mapped_node_impl + #cfg const fn #identifier() -> #core_types::ProtoNodeIdentifier { #core_types::ProtoNodeIdentifier::new(std::concat!(#identifier_path, "::", std::stringify!(#struct_name))) @@ -449,6 +570,8 @@ pub(crate) fn generate_node_code(crate_ident: &CrateIdent, parsed: &ParsedNodeFn #[doc(inline)] pub use #mod_name::#struct_name; + #mapped_struct_export + #[doc(hidden)] #node_input_accessor @@ -484,6 +607,8 @@ pub(crate) fn generate_node_code(crate_ident: &CrateIdent, parsed: &ParsedNodeFn } } + #mapped_struct_def + #register_node_impl #[cfg_attr(not(target_family = "wasm"), ctor)] @@ -550,6 +675,13 @@ fn generate_node_input_references( } .clone(); + // The element-wise primary input's document wire carries the mapped List form + if input_index == 0 + && let Some(element_ty) = primary_item_element(parsed) + { + ty = parse_quote!(#core_types::list::List<#element_ty>); + } + // We only want the necessary generics. let used = generic_collector.filter_unnecessary_generics(&mut modified, &mut ty); // TODO: figure out a better name that doesn't conflict with so many types @@ -619,7 +751,47 @@ fn generate_phantom_data<'a>(fn_generics: impl Iterator Result { +/// The wire container a generated node variant is registered with, wrapping the kernel's primary input element type. +#[derive(Clone, Copy, PartialEq)] +enum WireWrapper { + Item, + List, +} + +impl WireWrapper { + fn apply(self, core_types: &TokenStream2, ty: &syn::Type) -> syn::Type { + match self { + WireWrapper::Item => parse_quote!(#core_types::list::Item<#ty>), + WireWrapper::List => parse_quote!(#core_types::list::List<#ty>), + } + } +} + +/// Returns the element type of the node's primary input if it is declared `Item`, which marks the node as element-wise. +fn primary_item_element(parsed: &ParsedNodeFn) -> Option { + let field = parsed.fields.first()?; + match &field.ty { + ParsedFieldType::Regular(RegularParsedField { ty, .. }) if !field.is_data_field => peel_item(ty), + _ => None, + } +} + +/// Extracts `T` from an `Item` type, if the type is one. +fn peel_item(ty: &syn::Type) -> Option { + let syn::Type::Path(type_path) = ty else { return None }; + let segment = type_path.path.segments.last()?; + if segment.ident != "Item" { + return None; + } + + let syn::PathArguments::AngleBracketed(arguments) = &segment.arguments else { return None }; + match arguments.args.first()? { + GenericArgument::Type(inner) => Some(inner.clone()), + _ => None, + } +} + +fn generate_register_node_impl(parsed: &ParsedNodeFn, field_names: &[&Ident], struct_name: &Ident, mapped_struct_name: &Ident, identifier: &Ident) -> Result { // On native, `register_node` and `register_metadata` run automatically via `#[ctor]`. // On Wasm, `ctor` isn't available, so this `extern "C"` fn is invoked from JS to register the same way. // `skip_impl` nodes don't generate a `register_node`, so the shim calls only `register_metadata` for them. @@ -675,50 +847,70 @@ fn generate_register_node_impl(parsed: &ParsedNodeFn, field_names: &[&Ident], st let max_implementations = parameter_types.iter().map(|x| x.len()).chain([parsed.input.implementations.len().max(1)]).max(); - for i in 0..max_implementations.unwrap_or(0) { - let mut temp_constructors = Vec::new(); - let mut temp_node_io = Vec::new(); - let mut panic_node_types = Vec::new(); + // Element-wise nodes register two wire variants per implementations row; all other nodes register one + let gcore = quote!(gcore); + let wire_variants: &[Option] = match primary_item_element(parsed).is_some() { + true => &[Some(WireWrapper::Item), Some(WireWrapper::List)], + false => &[None], + }; - for (j, types) in parameter_types.iter().enumerate() { - let field_name = field_names[j]; - let (input_type, output_type) = &types[i.min(types.len() - 1)]; + for i in 0..max_implementations.unwrap_or(0) { + for wire in wire_variants { + let mut temp_constructors = Vec::new(); + let mut temp_node_io = Vec::new(); + let mut panic_node_types = Vec::new(); + + for (j, types) in parameter_types.iter().enumerate() { + let field_name = field_names[j]; + let (input_type, output_type) = &types[i.min(types.len() - 1)]; + let output_type = match (j, wire) { + (0, Some(wrap)) => { + let element_ty = peel_item(output_type).unwrap_or_else(|| output_type.clone()); + wrap.apply(&gcore, &element_ty) + } + _ => output_type.clone(), + }; - let node = matches!(regular_fields[j].ty, ParsedFieldType::Node { .. }); + let node = matches!(regular_fields[j].ty, ParsedFieldType::Node { .. }); - let downcast_node = quote!( - let #field_name: DowncastBothNode<#input_type, #output_type> = DowncastBothNode::new(args[#j].clone()); - ); - if node && !parsed.is_async { - return Err(Error::new_spanned(&parsed.fn_name, "Node needs to be async if you want to use lambda parameters")); + let downcast_node = quote!( + let #field_name: DowncastBothNode<#input_type, #output_type> = DowncastBothNode::new(args[#j].clone()); + ); + if node && !parsed.is_async { + return Err(Error::new_spanned(&parsed.fn_name, "Node needs to be async if you want to use lambda parameters")); + } + temp_constructors.push(downcast_node); + temp_node_io.push(quote!(fn_type_fut!(#input_type, #output_type, alias: #output_type))); + panic_node_types.push(quote!(#input_type, DynFuture<'static, #output_type>)); } - temp_constructors.push(downcast_node); - temp_node_io.push(quote!(fn_type_fut!(#input_type, #output_type, alias: #output_type))); - panic_node_types.push(quote!(#input_type, DynFuture<'static, #output_type>)); - } - let input_type = match parsed.input.implementations.is_empty() { - true => parsed.input.ty.clone(), - false => parsed.input.implementations[i.min(parsed.input.implementations.len() - 1)].clone(), - }; - constructors.push(quote!( - ( - |args| { - Box::pin(async move { - #(#temp_constructors;)* - let node = #struct_name::new(#(#field_names,)*); - // try polling futures - let any: DynAnyNode<#input_type, _, _> = DynAnyNode::new(node); - Box::new(any) as TypeErasedBox<'_> - }) - }, { - let node = #struct_name::new(#(PanicNode::<#panic_node_types>::new(),)*); - let params = vec![#(#temp_node_io,)*]; - let mut node_io = NodeIO::<'_, #input_type>::to_async_node_io(&node, params); - node_io + let input_type = match parsed.input.implementations.is_empty() { + true => parsed.input.ty.clone(), + false => parsed.input.implementations[i.min(parsed.input.implementations.len() - 1)].clone(), + }; + let variant_struct_name = match wire { + Some(WireWrapper::List) => mapped_struct_name, + _ => struct_name, + }; + constructors.push(quote!( + ( + |args| { + Box::pin(async move { + #(#temp_constructors;)* + let node = #variant_struct_name::new(#(#field_names,)*); + // try polling futures + let any: DynAnyNode<#input_type, _, _> = DynAnyNode::new(node); + Box::new(any) as TypeErasedBox<'_> + }) + }, { + let node = #variant_struct_name::new(#(PanicNode::<#panic_node_types>::new(),)*); + let params = vec![#(#temp_node_io,)*]; + let mut node_io = NodeIO::<'_, #input_type>::to_async_node_io(&node, params); + node_io - } - ) - )); + } + ) + )); + } } let native = quote! { #[cfg_attr(not(target_family = "wasm"), ctor)] diff --git a/node-graph/node-macro/src/validation.rs b/node-graph/node-macro/src/validation.rs index 7b51b31efa..2cc98ce819 100644 --- a/node-graph/node-macro/src/validation.rs +++ b/node-graph/node-macro/src/validation.rs @@ -11,6 +11,8 @@ pub fn validate_node_fn(parsed: &ParsedNodeFn) -> syn::Result<()> { validate_primary_input_expose, validate_min_max, validate_range_slider_bounds, + validate_no_item_parameters, + validate_element_wise, ]; for validator in validators { @@ -20,6 +22,72 @@ pub fn validate_node_fn(parsed: &ParsedNodeFn) -> syn::Result<()> { Ok(()) } +fn validate_no_item_parameters(parsed: &ParsedNodeFn) { + // The primary input (index 0) may be Item, which declares the node as an element-wise kernel + for field in parsed.fields.iter().skip(1) { + let ParsedField { + ty: ParsedFieldType::Regular(RegularParsedField { ty, implementations, .. }), + pat_ident, + .. + } = field + else { + continue; + }; + + if outer_wrapper_is(ty, "Item") || implementations.iter().any(|ty| outer_wrapper_is(ty, "Item")) { + emit_error!( + pat_ident.span(), + "Parameter `{}` must not be typed `Item`.", + pat_ident.ident; + help = "Only the primary input may be typed `Item`, which declares the node as an element-wise kernel."; + ); + } + } +} + +fn validate_element_wise(parsed: &ParsedNodeFn) { + let Some(primary) = parsed.fields.first() else { return }; + let ParsedFieldType::Regular(RegularParsedField { ty, implementations, .. }) = &primary.ty else { + return; + }; + + if !outer_wrapper_is(ty, "Item") { + if outer_wrapper_is(&parsed.output_type, "Item") { + emit_error!( + parsed.output_type.span(), + "Returning `Item` requires the primary input to also be typed `Item`, declaring an element-wise node" + ); + } + return; + } + + if primary.is_data_field { + emit_error!(primary.pat_ident.span(), "The `Item` primary input `{}` cannot be a #[data] field", primary.pat_ident.ident); + } + + if parsed.attributes.shader_node.is_some() { + emit_error!(parsed.fn_name.span(), "An element-wise `Item` primary input cannot be combined with `shader_node`"); + } + + if implementations.iter().any(|ty| outer_wrapper_is(ty, "List") || outer_wrapper_is(ty, "Item")) { + emit_error!( + primary.pat_ident.span(), + "The #[implementations(...)] of `{}` must be bare element types; the macro derives the Item and List wire forms", + primary.pat_ident.ident + ); + } + + if !outer_wrapper_is(&parsed.output_type, "Item") { + emit_error!(parsed.output_type.span(), "An element-wise node (declared by its `Item` primary input) must return `Item`"); + } +} + +/// Returns whether the type's outermost path segment is the given wrapper name, like `Item` in `Item`. +fn outer_wrapper_is(ty: &Type, wrapper: &str) -> bool { + let Type::Path(type_path) = ty else { return false }; + type_path.path.segments.last().is_some_and(|segment| segment.ident == wrapper) +} + fn validate_min_max(parsed: &ParsedNodeFn) { for field in &parsed.fields { if let ParsedField { From 4943f98b82b3f57571a9133d9a2b68718d6d2d6e Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Fri, 3 Jul 2026 13:02:12 -0700 Subject: [PATCH 004/165] Migrate nine nodes to Item element-wise kernels, dissolving the blending trait boilerplate --- node-graph/nodes/blending/src/lib.rs | 242 ++---------------- node-graph/nodes/raster/src/filter.rs | 68 ++--- .../nodes/transform/src/transform_nodes.rs | 71 +++-- node-graph/nodes/vector/src/vector_nodes.rs | 49 ++-- 4 files changed, 105 insertions(+), 325 deletions(-) diff --git a/node-graph/nodes/blending/src/lib.rs b/node-graph/nodes/blending/src/lib.rs index fea3aa5ad9..73d63e9421 100644 --- a/node-graph/nodes/blending/src/lib.rs +++ b/node-graph/nodes/blending/src/lib.rs @@ -1,4 +1,4 @@ -use core_types::list::List; +use core_types::list::Item; use core_types::registry::types::Percentage; use core_types::{ATTR_BLEND_MODE, ATTR_CLIPPING_MASK, ATTR_OPACITY, ATTR_OPACITY_FILL, BlendMode, Color, Ctx}; use graphic_types::Graphic; @@ -6,199 +6,17 @@ use graphic_types::Vector; use graphic_types::raster_types::{CPU, Raster}; use vector_types::GradientStops; -pub(crate) trait MultiplyAlpha { - fn multiply_alpha(&mut self, factor: f64); -} - -impl MultiplyAlpha for Color { - fn multiply_alpha(&mut self, factor: f64) { - *self = Color::from_rgbaf32_unchecked(self.r(), self.g(), self.b(), (self.a() * factor as f32).clamp(0., 1.)) - } -} - -fn multiply_list_attribute(list: &mut List, key: &str, factor: f64) { - if let Some(values) = list.iter_attribute_values_mut::(key) { - for v in values { - *v *= factor; - } - } else { - for v in list.iter_attribute_values_mut_or_default::(key) { - *v = factor; - } - } -} - -impl MultiplyAlpha for List { - fn multiply_alpha(&mut self, factor: f64) { - multiply_list_attribute(self, ATTR_OPACITY, factor); - } -} -impl MultiplyAlpha for List { - fn multiply_alpha(&mut self, factor: f64) { - multiply_list_attribute(self, ATTR_OPACITY, factor); - } -} -impl MultiplyAlpha for List> { - fn multiply_alpha(&mut self, factor: f64) { - multiply_list_attribute(self, ATTR_OPACITY, factor); - } -} -impl MultiplyAlpha for List { - fn multiply_alpha(&mut self, factor: f64) { - multiply_list_attribute(self, ATTR_OPACITY, factor); - } -} -impl MultiplyAlpha for List { - fn multiply_alpha(&mut self, factor: f64) { - multiply_list_attribute(self, ATTR_OPACITY, factor); - } -} -impl MultiplyAlpha for List { - fn multiply_alpha(&mut self, factor: f64) { - multiply_list_attribute(self, ATTR_OPACITY, factor); - } -} - -pub(crate) trait MultiplyFill { - fn multiply_fill(&mut self, factor: f64); -} -impl MultiplyFill for Color { - fn multiply_fill(&mut self, factor: f64) { - *self = Color::from_rgbaf32_unchecked(self.r(), self.g(), self.b(), (self.a() * factor as f32).clamp(0., 1.)) - } -} -impl MultiplyFill for List { - fn multiply_fill(&mut self, factor: f64) { - multiply_list_attribute(self, ATTR_OPACITY_FILL, factor); - } -} -impl MultiplyFill for List { - fn multiply_fill(&mut self, factor: f64) { - multiply_list_attribute(self, ATTR_OPACITY_FILL, factor); - } -} -impl MultiplyFill for List> { - fn multiply_fill(&mut self, factor: f64) { - multiply_list_attribute(self, ATTR_OPACITY_FILL, factor); - } -} -impl MultiplyFill for List { - fn multiply_fill(&mut self, factor: f64) { - multiply_list_attribute(self, ATTR_OPACITY_FILL, factor); - } -} -impl MultiplyFill for List { - fn multiply_fill(&mut self, factor: f64) { - multiply_list_attribute(self, ATTR_OPACITY_FILL, factor); - } -} -impl MultiplyFill for List { - fn multiply_fill(&mut self, factor: f64) { - multiply_list_attribute(self, ATTR_OPACITY_FILL, factor); - } -} - -trait SetBlendMode { - fn set_blend_mode(&mut self, blend_mode: BlendMode); -} - -fn set_list_blend_mode(list: &mut List, blend_mode: BlendMode) { - for v in list.iter_attribute_values_mut_or_default::(ATTR_BLEND_MODE) { - *v = blend_mode; - } -} - -impl SetBlendMode for List { - fn set_blend_mode(&mut self, blend_mode: BlendMode) { - set_list_blend_mode(self, blend_mode); - } -} -impl SetBlendMode for List { - fn set_blend_mode(&mut self, blend_mode: BlendMode) { - set_list_blend_mode(self, blend_mode); - } -} -impl SetBlendMode for List> { - fn set_blend_mode(&mut self, blend_mode: BlendMode) { - set_list_blend_mode(self, blend_mode); - } -} -impl SetBlendMode for List { - fn set_blend_mode(&mut self, blend_mode: BlendMode) { - set_list_blend_mode(self, blend_mode); - } -} -impl SetBlendMode for List { - fn set_blend_mode(&mut self, blend_mode: BlendMode) { - set_list_blend_mode(self, blend_mode); - } -} -impl SetBlendMode for List { - fn set_blend_mode(&mut self, blend_mode: BlendMode) { - set_list_blend_mode(self, blend_mode); - } -} - -trait SetClip { - fn set_clip(&mut self, clip: bool); -} - -fn set_list_clip(list: &mut List, clip: bool) { - for v in list.iter_attribute_values_mut_or_default::(ATTR_CLIPPING_MASK) { - *v = clip; - } -} - -impl SetClip for List { - fn set_clip(&mut self, clip: bool) { - set_list_clip(self, clip); - } -} -impl SetClip for List { - fn set_clip(&mut self, clip: bool) { - set_list_clip(self, clip); - } -} -impl SetClip for List> { - fn set_clip(&mut self, clip: bool) { - set_list_clip(self, clip); - } -} -impl SetClip for List { - fn set_clip(&mut self, clip: bool) { - set_list_clip(self, clip); - } -} -impl SetClip for List { - fn set_clip(&mut self, clip: bool) { - set_list_clip(self, clip); - } -} -impl SetClip for List { - fn set_clip(&mut self, clip: bool) { - set_list_clip(self, clip); - } -} - /// Applies the blend mode to the input graphics. Setting this allows for customizing how overlapping content is composited together. #[node_macro::node(category("Blending"))] -fn blend_mode( +fn blend_mode( _: impl Ctx, - /// The layer stack that will be composited when rendering. - #[implementations( - List, - List, - List>, - List, - List, - List, - )] - mut content: T, + /// The content that will be composited when rendering. + #[implementations(Graphic, Vector, Raster, Color, GradientStops, String)] + mut content: Item, /// The choice of equation that controls how brightness and color blends between overlapping pixels. blend_mode: BlendMode, -) -> T { - // TODO: Find a way to make this apply once to the list's parent (i.e. its item in its parent List or Item) rather than applying to each item in its own list, which produces the undesired result - content.set_blend_mode(blend_mode); +) -> Item { + content.set_attribute(ATTR_BLEND_MODE, blend_mode); content } @@ -206,18 +24,11 @@ fn blend_mode( /// Opacity affects the transparency of the content (together with anything above which is clipped to it). /// Fill affects the transparency of the content itself, independent of any content clipped to it. #[node_macro::node(category("Blending"))] -fn opacity( +fn opacity( _: impl Ctx, - /// The layer stack that will be composited when rendering. - #[implementations( - List, - List, - List>, - List, - List, - List, - )] - mut content: T, + /// The content that will be composited when rendering. + #[implementations(Graphic, Vector, Raster, Color, GradientStops, String)] + mut content: Item, /// Whether the *Opacity* property is enabled, multiplying the existing opacity by the chosen percentage. #[widget(ParsedWidgetOverride::Hidden)] #[default(true)] @@ -235,35 +46,30 @@ fn opacity( #[widget(ParsedWidgetOverride::Custom = "optional_percentage")] #[default(100.)] fill: Percentage, -) -> T { - // TODO: Find a way to make this apply once to the list's parent (i.e. its item in its parent List or Item) rather than applying to each item in its own list, which produces the undesired result +) -> Item { if has_opacity { - content.multiply_alpha(opacity / 100.); + let multiplied = content.attribute_cloned_or(ATTR_OPACITY, 1.) * (opacity / 100.); + content.set_attribute(ATTR_OPACITY, multiplied); } + if has_fill { - content.multiply_fill(fill / 100.); + let multiplied = content.attribute_cloned_or(ATTR_OPACITY_FILL, 1.) * (fill / 100.); + content.set_attribute(ATTR_OPACITY_FILL, multiplied); } + content } /// Sets whether the input graphics inherit the alpha of the content beneath them, "clipping" them to that content. #[node_macro::node(category("Blending"))] -fn clipping_mask( +fn clipping_mask( _: impl Ctx, - /// The layer stack that will be composited when rendering. - #[implementations( - List, - List, - List>, - List, - List, - List, - )] - mut content: T, + /// The content that will be composited when rendering. + #[implementations(Graphic, Vector, Raster, Color, GradientStops, String)] + mut content: Item, /// Whether the content inherits the alpha of the content beneath it. clip: bool, -) -> T { - // TODO: Find a way to make this apply once to the list's parent (i.e. its item in its parent List or Item) rather than applying to each item in its own list, which produces the undesired result - content.set_clip(clip); +) -> Item { + content.set_attribute(ATTR_CLIPPING_MASK, clip); content } diff --git a/node-graph/nodes/raster/src/filter.rs b/node-graph/nodes/raster/src/filter.rs index 373298a016..ebd73350f0 100644 --- a/node-graph/nodes/raster/src/filter.rs +++ b/node-graph/nodes/raster/src/filter.rs @@ -1,7 +1,7 @@ use bytemuck::{Pod, Zeroable}; use core_types::color::{Alpha, Color, Pixel, RGB}; use core_types::context::Ctx; -use core_types::list::List; +use core_types::list::Item; use core_types::registry::types::PixelLength; use raster_types::Image; use raster_types::{Bitmap, BitmapMut}; @@ -90,7 +90,7 @@ fn unpremultiply_gamma_to_linear(buffer: Image) -> Imag async fn blur( _: impl Ctx, /// The image to be blurred. - image_frame: List>, + image_frame: Item>, /// The radius of the blur kernel. #[range] #[hard(0..)] @@ -100,26 +100,19 @@ async fn blur( box_blur: bool, /// Opt to incorrectly apply the filter with color calculations in gamma space for compatibility with the results from other software. gamma: bool, -) -> List> { - image_frame - .into_iter() - .map(|mut row| { - let image = row.element().clone(); - - // Run blur algorithm - let blurred_image = if radius < 0.1 { - // Minimum blur radius - image.clone() - } else if box_blur { - Raster::new_cpu(box_blur_algorithm(image.into_data(), radius, gamma)) - } else { - Raster::new_cpu(gaussian_blur_algorithm(image.into_data(), radius, gamma)) - }; - - *row.element_mut() = blurred_image; - row - }) - .collect() +) -> Item> { + let (image, attributes) = image_frame.into_parts(); + + let blurred_image = if radius < 0.1 { + // Minimum blur radius + image + } else if box_blur { + Raster::new_cpu(box_blur_algorithm(image.into_data(), radius, gamma)) + } else { + Raster::new_cpu(gaussian_blur_algorithm(image.into_data(), radius, gamma)) + }; + + Item::from_parts(blurred_image, attributes) } /// Applies a median filter to reduce noise while preserving edges. @@ -127,30 +120,23 @@ async fn blur( async fn median_filter( _: impl Ctx, /// The image to be filtered. - image_frame: List>, + image_frame: Item>, /// The radius of the filter kernel. Larger values remove more noise but may blur fine details. #[range] #[hard(0..)] #[soft(..50)] radius: PixelLength, -) -> List> { - image_frame - .into_iter() - .map(|mut row| { - let image = row.element().clone(); - - // Apply median filter - let filtered_image = if radius < 0.5 { - // Minimum filter radius - image.clone() - } else { - Raster::new_cpu(median_filter_algorithm(image.into_data(), radius as u32)) - }; - - *row.element_mut() = filtered_image; - row - }) - .collect() +) -> Item> { + let (image, attributes) = image_frame.into_parts(); + + let filtered_image = if radius < 0.5 { + // Minimum filter radius + image + } else { + Raster::new_cpu(median_filter_algorithm(image.into_data(), radius as u32)) + }; + + Item::from_parts(filtered_image, attributes) } // 1D gaussian kernel diff --git a/node-graph/nodes/transform/src/transform_nodes.rs b/node-graph/nodes/transform/src/transform_nodes.rs index 8ed04f2cfe..ae831b9a62 100644 --- a/node-graph/nodes/transform/src/transform_nodes.rs +++ b/node-graph/nodes/transform/src/transform_nodes.rs @@ -1,6 +1,6 @@ use core::f64; use core_types::color::Color; -use core_types::list::{List, ListDyn}; +use core_types::list::{Item, List, ListDyn}; use core_types::transform::{ApplyTransform, ScaleType, Transform}; use core_types::{ATTR_TRANSFORM, CloneVarArgs, Context, Ctx, ExtractAll, InjectFootprint, ModifyFootprint, OwnedContextImpl}; use glam::{DAffine2, DMat2, DVec2}; @@ -57,57 +57,56 @@ async fn transform( fn reset_transform( _: impl Ctx, #[implementations( - List, - List, - List>, - List>, - List, - List, + Graphic, + Vector, + Raster, + Raster, + Color, + GradientStops, )] - mut content: List, + mut content: Item, #[default(true)] reset_translation: bool, reset_rotation: bool, reset_scale: bool, -) -> List { - for row_transform in content.iter_attribute_values_mut_or_default::(ATTR_TRANSFORM) { - if reset_translation { - row_transform.translation = DVec2::ZERO; - } +) -> Item { + let item_transform = content.attribute_mut_or_insert_default::(ATTR_TRANSFORM); + + if reset_translation { + item_transform.translation = DVec2::ZERO; + } - match (reset_rotation, reset_scale) { - (true, true) => row_transform.matrix2 = DMat2::IDENTITY, - (true, false) => { - let scale = row_transform.scale_magnitudes(); - row_transform.matrix2 = DMat2::from_diagonal(scale); - } - (false, true) => { - let rotation = row_transform.decompose_rotation(); - row_transform.matrix2 = DMat2::from_angle(rotation); - } - (false, false) => {} + match (reset_rotation, reset_scale) { + (true, true) => item_transform.matrix2 = DMat2::IDENTITY, + (true, false) => { + let scale = item_transform.scale_magnitudes(); + item_transform.matrix2 = DMat2::from_diagonal(scale); } + (false, true) => { + let rotation = item_transform.decompose_rotation(); + item_transform.matrix2 = DMat2::from_angle(rotation); + } + (false, false) => {} } + content } -/// Overwrites the transform of each item in the input `List` with the specified transform. +/// Overwrites the transform of the input content with the specified transform. #[node_macro::node(category("Math: Transform"))] fn replace_transform( _: impl Ctx + InjectFootprint, #[implementations( - List, - List, - List>, - List>, - List, - List, + Graphic, + Vector, + Raster, + Raster, + Color, + GradientStops, )] - mut content: List, + mut content: Item, transform: DAffine2, -) -> List { - for row_transform in content.iter_attribute_values_mut_or_default::(ATTR_TRANSFORM) { - *row_transform = transform.transform(); - } +) -> Item { + content.set_attribute(ATTR_TRANSFORM, transform.transform()); content } diff --git a/node-graph/nodes/vector/src/vector_nodes.rs b/node-graph/nodes/vector/src/vector_nodes.rs index 3b08ac33d1..ebe1d0480b 100644 --- a/node-graph/nodes/vector/src/vector_nodes.rs +++ b/node-graph/nodes/vector/src/vector_nodes.rs @@ -1145,28 +1145,22 @@ async fn auto_tangents( } #[node_macro::node(category("Vector: Modifier"), path(core_types::vector))] -async fn bounding_box(_: impl Ctx, content: List) -> List { - content - .into_iter() - .map(|mut row| { - let vector = std::mem::take(row.element_mut()); - - let mut result = vector - .bounding_box_rect() - .map(|bbox| { - let mut vector = Vector::default(); - vector.append_bezpath(bbox.to_path(DEFAULT_ACCURACY)); - vector - }) - .unwrap_or_default(); +async fn bounding_box(_: impl Ctx, mut content: Item) -> Item { + let mut result = content + .element() + .bounding_box_rect() + .map(|bbox| { + let mut vector = Vector::default(); + vector.append_bezpath(bbox.to_path(DEFAULT_ACCURACY)); + vector + }) + .unwrap_or_default(); - result.stroke = vector.stroke.clone(); - result.set_stroke_transform(DAffine2::IDENTITY); + result.stroke = std::mem::take(&mut content.element_mut().stroke); + result.set_stroke_transform(DAffine2::IDENTITY); - *row.element_mut() = result; - row - }) - .collect() + *content.element_mut() = result; + content } #[node_macro::node(category("Vector: Measure"), path(core_types::vector))] @@ -3103,14 +3097,9 @@ fn bevel(_: impl Ctx, source: List, #[default(10.)] distance: Length) -> } #[node_macro::node(category("Vector: Modifier"), path(core_types::vector))] -fn close_path(_: impl Ctx, source: List) -> List { +fn close_path(_: impl Ctx, mut source: Item) -> Item { + source.element_mut().close_subpaths(); source - .into_iter() - .map(|mut row| { - row.element_mut().close_subpaths(); - row - }) - .collect() } #[node_macro::node(category("Vector: Measure"), path(core_types::vector))] @@ -3292,8 +3281,8 @@ mod test { #[tokio::test] async fn bounding_box() { - let bounding_box = super::bounding_box((), vector_node_from_bezpath(Rect::new(-1., -1., 1., 1.).to_path(DEFAULT_ACCURACY))).await; - let bounding_box = bounding_box.element(0).unwrap(); + let bounding_box = super::bounding_box((), Item::new_from_element(Vector::from_bezpath(Rect::new(-1., -1., 1., 1.).to_path(DEFAULT_ACCURACY)))).await; + let bounding_box = bounding_box.element(); assert_eq!(bounding_box.region_manipulator_groups().count(), 1); let manipulator_groups_anchors = bounding_box .region_manipulator_groups() @@ -3310,7 +3299,7 @@ mod test { let square = Vector::from_bezpath(Rect::new(-1., -1., 1., 1.).to_path(DEFAULT_ACCURACY)); let mut square = List::new_from_element(square); square.with_attribute_mut_or_default(ATTR_TRANSFORM, 0, |t: &mut DAffine2| *t *= DAffine2::from_angle(std::f64::consts::FRAC_PI_4)); - let bounding_box = BoundingBoxNode { content: FutureWrapperNode(square) }.eval(Footprint::default()).await; + let bounding_box = BoundingBoxNodeMapped { content: FutureWrapperNode(square) }.eval(Footprint::default()).await; let bounding_box = bounding_box.element(0).unwrap(); assert_eq!(bounding_box.region_manipulator_groups().count(), 1); let manipulator_groups_anchors = bounding_box From b23817c060720901c3f6e4b1cff415b93510bd2e Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Fri, 3 Jul 2026 13:02:55 -0700 Subject: [PATCH 005/165] Document the Item kernel implementation and staging plan --- rank-polymorphism-implementation.md | 49 +++++++++++++++++++++++++++++ rank-polymorphism-node-audit.md | 12 +++---- 2 files changed, 55 insertions(+), 6 deletions(-) create mode 100644 rank-polymorphism-implementation.md diff --git a/rank-polymorphism-implementation.md b/rank-polymorphism-implementation.md new file mode 100644 index 0000000000..e3d7f3d0ba --- /dev/null +++ b/rank-polymorphism-implementation.md @@ -0,0 +1,49 @@ +# Rank Polymorphism Implementation + +Status and staging plan for the rank polymorphism refactor. The classification that drives this work is in `rank-polymorphism-node-audit.md`. + +## Implemented (this branch) + +### Element-wise kernels, declared structurally by `Item` + +A node whose primary input is typed `Item` is an element-wise rank-0 kernel: it consumes one item per call, with full access to the item's element and attributes. No macro attribute is needed; the signature is the declaration. The macro generates two registry variants per implementations row under the same node identifier: + +- **`Item` variant** (struct `XNode`): evaluates the primary input as one `Item` and calls the kernel once. Output wire: `Item`. Dormant until rank-0 wires flow (generator flip stage), but registered and type-checked now. +- **`List` variant** (struct `XNodeMapped`): evaluates the primary input as a `List` and calls the kernel once per item, broadcasting all other parameters by clone. Output wire: `List`. This exactly replaces today's hand-written per-row loops, so existing documents resolve to it with identical behavior. + +The kernel owns the item wrapping on both sides: it receives `Item`, may read or write attributes, and returns `Item`. Generic element-wise nodes list bare element types in `#[implementations(...)]` (e.g. `Graphic, Vector, Raster`); the macro derives both wire forms. Field metadata, `NodeInputDecleration` accessors, and default types keep the `List` wire form for document compatibility. + +Rules enforced by validation: only the primary input may be `Item`-typed; an `Item` primary requires an `Item` return and bare-element implementations; an `Item` return requires an `Item` primary; incompatible with `shader_node` (GPU kernels need bare `repr(C)` values). Bare-typed primaries continue to mean today's plain wires, so unmigrated nodes are untouched. + +Migrated so far: Bounding Box, Close Path, Blur, Median Filter, Blend Mode, Opacity, Clipping Mask, Reset Transform, Replace Transform. The attribute-manipulating nodes became one-line kernels (`content.set_attribute(ATTR_BLEND_MODE, blend_mode)`), which deleted the ~180 lines of `SetBlendMode`/`MultiplyAlpha`/`MultiplyFill`/`SetClip` trait impls in the blending crate and resolved its three "apply once to the list's parent" TODOs — the `Item` variant is precisely that path. + +### Supporting changes + +`Item` implements `StaticType`, making it a legal type-erased wire type. + +## Authoring guide + +```rust +#[node_macro::node(category("Blending"))] +fn blend_mode( + _: impl Ctx, + #[implementations(Graphic, Vector, Raster, Color, GradientStops, String)] + mut content: Item, + blend_mode: BlendMode, +) -> Item { + content.set_attribute(ATTR_BLEND_MODE, blend_mode); + content +} +``` + +Kernels that ignore attributes simply pass them through by mutating the element in place (`content.element_mut()`) or via `into_parts`/`from_parts` when the element type changes. Bare-`T` kernel sugar (macro-owned attribute plumbing) is deliberately deferred until the system is proven; `Item` everywhere is the one authoring style. + +## Staged roadmap + +1. **Batch migration (mechanical, no new machinery).** Convert the remaining audit-classified element-wise nodes to `Item` kernels. Each conversion is wire-compatible; behavior deviations are only those already approved in the audit. +2. **Rank-aware resolution and promote adapters (compiler).** Teach the preprocessor/typing context to insert promotion adapters (bare value -> `Item`; `Item` -> broadcast `List`), following the existing `IntoNode` insertion precedent. This unlocks mixed-rank wiring and is the prerequisite for zip. +3. **Multi-connector zip.** Extend the mapped variant to zip all ranked connectors sharing the frame (longest-list, last-element repeats; attribute merge primary-first), replacing the single-mapped-input limitation. This delivers `Item` content x `List` translations -> `List` through Transform. +4. **Generator flip + document migration.** Generators emit `Item`; document upgrade inserts promotions where old documents expect lists; frontend displays `Item` as `T`. Rank-0 wires begin flowing through real documents. +5. **Parameter ranking.** Scalar connectors move to `Item`-style wires with TaggedValue promotion in the preprocessor, making every data connector frameable. +6. **Node family completion.** Delete Copy to Points, Repeat on Points, Map, Map String, Attach Attribute, Extract Element, the As-type trio, and the Option debug trio; land the assign/spread family; add the companion nodes (Sum, Average, Minimum, Maximum, Any, All, Filter, Sort, Corners, Separate Glyphs). +7. **Later horizons.** Data-tree spines (rank >= 2 as data), demand-driven broadcast-as-re-evaluation in the adapters, GPU/`shader_node` integration with element-typed implementations, and possibly bare-`T` kernel sugar once the semantics are settled. diff --git a/rank-polymorphism-node-audit.md b/rank-polymorphism-node-audit.md index d331a1b1ba..700b91d3e3 100644 --- a/rank-polymorphism-node-audit.md +++ b/rank-polymorphism-node-audit.md @@ -8,7 +8,7 @@ Classification of all 271 live `#[node_macro::node]` definitions (July 2026, mas - Classification is by **intended semantics**, not today's signatures (bulk-converted `List` carries no signal). Element-wise is the default; rank 1 only for genuine whole-list needs. - **Classes:** `element-wise` (all data connectors rank 0 → rank 0, includes generators), `floor` (rank 1 → rank 1), `reducer` (rank 1 → rank 0), `expander` (rank 0 → rank 1), `mixed` (differing cell ranks), `infrastructure` (exempt). - **Lazy connectors** (`Context -> T`, one evaluation per frame slot): only for (1) frame-synthesizers, (2) demand-context modifiers (Footprint et al.), and (3) evaluation-control (Memoize, Monitor, Switch — nodes whose purpose is deciding whether/when upstream evaluates). -- **Attrs**: node reads/writes ATTR_* columns; keeps its List kernel + `#[length_preserving]` singleton round-trip near-term, still classified by semantic rank. +- **Attrs**: node reads/writes ATTR_* values; authored as an element-wise `Item` kernel with direct attribute access, classified by semantic rank. - No `Option` outputs. `Default::default()` is the fallback only when a node's own semantics define no answer; nodes may define richer valid domains (negative from-end indexing, clamping) as non-failures. - Notation: `name: Item` = rank-0 connector, `name: List` = rank-1 connector, `name: Context -> Item (lazy)` = lazy connector. `DEVIATION:` marks observable behavior changes vs. today. @@ -311,8 +311,8 @@ This is the list-manipulation core, so rank-1 density is legitimately high here | Node | File:Line | Proposed connectors -> output | Class | Lazy | Attrs | Notes | |---|---|---|---|---|---|---| | Transform | transform_nodes.rs:14 | `content: Context -> Item (lazy), translation: Item, rotation: Item, scale: Item, skew: Item -> Item` | element-wise | content (footprint modifier) | attrs | THE broadcast node (replaces Repeat on Points). DAffine2/DVec2 value implementations need an eager value-kernel sibling | -| Reset Transform | transform_nodes.rs:57 | `content: Item, reset_translation: Item, reset_rotation: Item, reset_scale: Item -> Item` | element-wise | no | attrs | Round-trip | -| Replace Transform | transform_nodes.rs:95 | `content: Item, transform: Item -> Item` | element-wise | no | attrs | Round-trip | +| Reset Transform | transform_nodes.rs:57 | `content: Item, reset_translation: Item, reset_rotation: Item, reset_scale: Item -> Item` | element-wise | no | attrs | Implemented as an Item kernel | +| Replace Transform | transform_nodes.rs:95 | `content: Item, transform: Item -> Item` | element-wise | no | attrs | Implemented as an Item kernel | | Extract Transform | transform_nodes.rs:117 | `content: Item -> Item` | element-wise | no | attrs | DEVIATION: today reads only element 0; framing yields per-element transforms (TODO #2982 anticipated this) | | Invert Transform | transform_nodes.rs:123 | `transform: Item -> Item` | element-wise | no | no | | | Decompose Translation | transform_nodes.rs:129 | `transform: Item -> Item` | element-wise | no | no | | @@ -323,9 +323,9 @@ This is the list-manipulation core, so rank-1 density is legitimately high here | Repeat Array | repeat_nodes.rs:48 | `content: Context -> Item (lazy), direction: Item, angle: Item, count: Item -> List` | expander | content (frame-synth) | attrs | Composes per-copy ATTR_TRANSFORM. Same flattening DEVIATION | | Repeat Radial | repeat_nodes.rs:96 | `content: Context -> Item (lazy), start_angle: Item, radius: Item, count: Item -> List` | expander | content (frame-synth) | attrs | Same family | | Repeat on Points | repeat_nodes.rs:142 | DELETE | expander | — | attrs | Transform broadcasting replaces it; classified for the record only | -| Blend Mode | blending/lib.rs:185 | `content: Item, blend_mode: Item -> Item` | element-wise | no | attrs | Round-trip. "Apply to the parent" TODO fixed by rank-0 wires | -| Opacity | blending/lib.rs:209 | `content: Item, has_opacity: Item, opacity: Item, has_fill: Item, fill: Item -> Item` | element-wise | no | attrs | Round-trip. Missing attribute = 1.0 implicit default, NOT f64::default() — round-trip must preserve this | -| Clipping Mask | blending/lib.rs:251 | `content: Item, clip: Item -> Item` | element-wise | no | attrs | Round-trip. Same TODO fixed | +| Blend Mode | blending/lib.rs:185 | `content: Item, blend_mode: Item -> Item` | element-wise | no | attrs | Implemented as an Item kernel; "apply to the parent" TODO fixed | +| Opacity | blending/lib.rs:209 | `content: Item, has_opacity: Item, opacity: Item, has_fill: Item, fill: Item -> Item` | element-wise | no | attrs | Implemented as an Item kernel; missing attribute treated as the 1.0 implicit default, NOT f64::default() | +| Clipping Mask | blending/lib.rs:251 | `content: Item, clip: Item -> Item` | element-wise | no | attrs | Implemented as an Item kernel; same TODO fixed | ## Gcore (24 nodes) From 8b77ed62c870bd1cdbce11f24884a8e2c4e5c8e9 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Fri, 3 Jul 2026 13:38:23 -0700 Subject: [PATCH 006/165] Route Item through TaggedValue::TypeDefault --- node-graph/graph-craft/src/document/value.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/node-graph/graph-craft/src/document/value.rs b/node-graph/graph-craft/src/document/value.rs index c3af90cf23..7348eee7ac 100644 --- a/node-graph/graph-craft/src/document/value.rs +++ b/node-graph/graph-craft/src/document/value.rs @@ -4,7 +4,7 @@ use crate::application_io::resource::Resource; use crate::proto::{Any as DAny, FutureAny}; use brush_nodes::brush_stroke::BrushStroke; use core_types::color::SRGBA8; -use core_types::list::List; +use core_types::list::{Item, List}; use core_types::transform::Footprint; use core_types::uuid::NodeId; use core_types::{CacheHash, Color, ContextFeatures, MemoHash, Node, Type, TypeDescriptor}; @@ -37,6 +37,7 @@ macro_rules! for_each_type_default { $action!(List>); $action!(List); $action!(List); + $action!(Item); $action!(DocumentNode); $action!(Resource); }; From 7658c1462817afca555a8f97c23528310e4ed7ed Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Fri, 3 Jul 2026 13:38:23 -0700 Subject: [PATCH 007/165] Add executor integration tests covering the Item and List wire variants --- .../src/dynamic_executor.rs | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/node-graph/interpreted-executor/src/dynamic_executor.rs b/node-graph/interpreted-executor/src/dynamic_executor.rs index 904c83d6f7..6dfe57e176 100644 --- a/node-graph/interpreted-executor/src/dynamic_executor.rs +++ b/node-graph/interpreted-executor/src/dynamic_executor.rs @@ -432,7 +432,12 @@ impl BorrowTree { #[cfg(test)] mod test { use super::*; + use core_types::Context; + use core_types::descriptor; + use core_types::list::{Item, List}; + use graph_craft::ProtoNodeIdentifier; use graph_craft::document::value::TaggedValue; + use graphene_std::vector::Vector; #[test] fn push_node_sync() { @@ -445,4 +450,49 @@ mod test { let result = futures::executor::block_on(tree.eval(NodeId(0), ())); assert_eq!(result, Some(2u32)); } + + /// Builds a two-node network feeding the given value into Bounding Box, whose primary input registers both `Item` and `List` wire variants. + fn bounding_box_network(content: TaggedValue) -> ProtoNetwork { + let value_node = ProtoNode::value(ConstructionArgs::Value(content.into()), vec![NodeId(0)]); + + let mut bounding_box_node = ProtoNode::value(ConstructionArgs::Nodes(vec![NodeId(0)]), vec![NodeId(1)]); + bounding_box_node.identifier = ProtoNodeIdentifier::new("core_types::vector::BoundingBoxNode"); + + ProtoNetwork { + inputs: vec![], + output: NodeId(1), + nodes: vec![(NodeId(0), value_node), (NodeId(1), bounding_box_node)], + } + } + + fn compile_bounding_box_network(content: TaggedValue) -> BorrowTree { + let network = bounding_box_network(content); + let mut typing_context = TypingContext::new(&crate::node_registry::NODE_REGISTRY); + typing_context.update(&network).expect("The network should resolve against exactly one registered wire variant"); + futures::executor::block_on(BorrowTree::new(network, &typing_context)).expect("The resolved variant's constructor should instantiate") + } + + #[test] + fn item_wire_variant_resolves_and_executes() { + let tree = compile_bounding_box_network(TaggedValue::TypeDefault(descriptor!(Item))); + + let context: Context = None; + let result: Option> = futures::executor::block_on(tree.eval(NodeId(1), context.clone())); + assert!(result.is_some(), "The Item wire variant should downcast and execute end-to-end"); + + let wrong_type: Option> = futures::executor::block_on(tree.eval(NodeId(1), context)); + assert!(wrong_type.is_none(), "An Item wire should not downcast as a List"); + } + + #[test] + fn list_wire_variant_resolves_and_executes() { + let tree = compile_bounding_box_network(TaggedValue::TypeDefault(descriptor!(List))); + + let context: Context = None; + let result: Option> = futures::executor::block_on(tree.eval(NodeId(1), context.clone())); + assert!(result.is_some(), "The mapped List wire variant should downcast and execute end-to-end"); + + let wrong_type: Option> = futures::executor::block_on(tree.eval(NodeId(1), context)); + assert!(wrong_type.is_none(), "A List wire should not downcast as an Item"); + } } From 76b06643012ab98da528c0bfd7c14656c0b33c25 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Fri, 3 Jul 2026 13:38:23 -0700 Subject: [PATCH 008/165] Collapse element-wise Item/List wire pairs to the List form for conversion insertion --- node-graph/preprocessor/src/lib.rs | 56 +++++++++++++++++++++++++++--- 1 file changed, 52 insertions(+), 4 deletions(-) diff --git a/node-graph/preprocessor/src/lib.rs b/node-graph/preprocessor/src/lib.rs index 4e543a7b7a..71910312da 100644 --- a/node-graph/preprocessor/src/lib.rs +++ b/node-graph/preprocessor/src/lib.rs @@ -143,11 +143,14 @@ impl Preprocessor { .take(input_count) .enumerate() .map(|(i, inputs)| { + let single_wire_type = match inputs.len() { + 1 => inputs.iter().next(), + _ => collapse_item_list_pair(inputs), + }; ( NodeId(i as u64), - match inputs.len() { - 1 => { - let input = inputs.iter().next().unwrap(); + match single_wire_type { + Some(input) => { let input_ty = input.nested_type(); let mut inputs = vec![NodeInput::import(input.clone(), i)]; @@ -174,7 +177,7 @@ impl Preprocessor { ..Default::default() } } - _ => DocumentNode { + None => DocumentNode { inputs: vec![NodeInput::import(generic!(X), i)], implementation: DocumentNodeImplementation::ProtoNode(passthrough_node.clone()), visible: false, @@ -301,3 +304,48 @@ impl std::fmt::Display for PreprocessorError { } } } + +/// Collapses an element-wise node's dual wire registration for one field, `{Item, List}`, to its `List` document wire form. +fn collapse_item_list_pair(types: &HashSet) -> Option<&Type> { + fn inner_name<'a>(ty: &'a Type, wrapper: &str) -> Option<&'a str> { + let Type::Concrete(descriptor) = ty.nested_type() else { return None }; + descriptor.name.strip_prefix("core_types::list::")?.strip_prefix(wrapper)?.strip_prefix('<')?.strip_suffix('>') + } + + let mut types_iterator = types.iter(); + let (first, second) = (types_iterator.next()?, types_iterator.next()?); + if types_iterator.next().is_some() { + return None; + } + + for (item, list) in [(first, second), (second, first)] { + if let (Some(item_inner), Some(list_inner)) = (inner_name(item, "Item"), inner_name(list, "List")) + && item_inner == list_inner + { + return Some(list); + } + } + + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn item_list_wire_pair_collapses_to_list() { + let registry = core_types::registry::NODE_REGISTRY.lock().unwrap(); + let identifier = ProtoNodeIdentifier::new("core_types::vector::BoundingBoxNode"); + let implementations = registry.get(&identifier).expect("Bounding Box should be registered"); + + let primary_types: HashSet<_> = implementations.iter().map(|(_, node_io)| node_io.inputs[0].clone()).collect(); + assert_eq!(primary_types.len(), 2, "An element-wise node should register Item and List wire variants for its primary input"); + + let collapsed = collapse_item_list_pair(&primary_types).expect("The Item/List wire pair should collapse"); + let Type::Concrete(descriptor) = collapsed.nested_type() else { + panic!("The collapsed wire type should be concrete") + }; + assert!(descriptor.name.contains("List<"), "The collapse should pick the List form, but got {}", descriptor.name); + } +} From 8575ef92aef20ae9daf78f3326f3a2e02277ef5a Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Fri, 3 Jul 2026 14:31:41 -0700 Subject: [PATCH 009/165] Migrate sixteen vector modifier nodes to Item element-wise kernels --- node-graph/nodes/vector/src/vector_nodes.rs | 1120 +++++++++---------- 1 file changed, 532 insertions(+), 588 deletions(-) diff --git a/node-graph/nodes/vector/src/vector_nodes.rs b/node-graph/nodes/vector/src/vector_nodes.rs index ebe1d0480b..08d37dc3ee 100644 --- a/node-graph/nodes/vector/src/vector_nodes.rs +++ b/node-graph/nodes/vector/src/vector_nodes.rs @@ -442,7 +442,7 @@ async fn copy_to_points( #[node_macro::node(category("Vector: Modifier"), path(core_types::vector))] async fn round_corners( _: impl Ctx, - source: List, + source: Item, #[hard(0..)] #[default(10.)] radius: PixelLength, @@ -455,128 +455,115 @@ async fn round_corners( #[hard(0..180)] #[default(5.)] min_angle_threshold: Angle, -) -> List { - (0..source.len()) - .map(|index| { - let source_transform: DAffine2 = source.attribute_cloned_or_default(ATTR_TRANSFORM, index); - let source_transform_inverse = source_transform.inverse(); - let attributes = source.clone_item_attributes(index); - let source = source.element(index).unwrap(); - - // Flip the roundness to help with user intuition - let roundness = 1. - roundness; - // Convert 0-100 to 0-0.5 - let edge_length_limit = edge_length_limit * 0.005; - - let mut result = Vector { - stroke: source.stroke.clone(), - ..Default::default() - }; +) -> Item { + let source_transform: DAffine2 = source.attribute_cloned_or_default(ATTR_TRANSFORM); + let source_transform_inverse = source_transform.inverse(); + let (source, attributes) = source.into_parts(); + + // Flip the roundness to help with user intuition + let roundness = 1. - roundness; + // Convert 0-100 to 0-0.5 + let edge_length_limit = edge_length_limit * 0.005; + + let mut result = Vector { + stroke: source.stroke.clone(), + ..Default::default() + }; - // Grab the initial point ID as a stable starting point - let mut initial_point_id = source.point_domain.ids().first().copied().unwrap_or(PointId::generate()); + // Grab the initial point ID as a stable starting point + let mut initial_point_id = source.point_domain.ids().first().copied().unwrap_or(PointId::generate()); - for mut bezpath in source.stroke_bezpath_iter() { - bezpath.apply_affine(Affine::new(source_transform.to_cols_array())); - let (manipulator_groups, is_closed) = bezpath_to_manipulator_groups(&bezpath); + for mut bezpath in source.stroke_bezpath_iter() { + bezpath.apply_affine(Affine::new(source_transform.to_cols_array())); + let (manipulator_groups, is_closed) = bezpath_to_manipulator_groups(&bezpath); - // End if not enough points for corner rounding - if manipulator_groups.len() < 3 { - result.append_bezpath(bezpath); - continue; - } + // End if not enough points for corner rounding + if manipulator_groups.len() < 3 { + result.append_bezpath(bezpath); + continue; + } - let mut new_manipulator_groups = Vec::new(); + let mut new_manipulator_groups = Vec::new(); - for i in 0..manipulator_groups.len() { - // Skip first and last points for open paths - if !is_closed && (i == 0 || i == manipulator_groups.len() - 1) { - new_manipulator_groups.push(manipulator_groups[i]); - continue; - } + for i in 0..manipulator_groups.len() { + // Skip first and last points for open paths + if !is_closed && (i == 0 || i == manipulator_groups.len() - 1) { + new_manipulator_groups.push(manipulator_groups[i]); + continue; + } - // Not the prettiest, but it makes the rest of the logic more readable - let prev_index = if i == 0 { if is_closed { manipulator_groups.len() - 1 } else { 0 } } else { i - 1 }; - let curr_index = i; - let next_index = if i == manipulator_groups.len() - 1 { if is_closed { 0 } else { i } } else { i + 1 }; + // Not the prettiest, but it makes the rest of the logic more readable + let prev_index = if i == 0 { if is_closed { manipulator_groups.len() - 1 } else { 0 } } else { i - 1 }; + let curr_index = i; + let next_index = if i == manipulator_groups.len() - 1 { if is_closed { 0 } else { i } } else { i + 1 }; - let prev = manipulator_groups[prev_index].anchor; - let curr = manipulator_groups[curr_index].anchor; - let next = manipulator_groups[next_index].anchor; + let prev = manipulator_groups[prev_index].anchor; + let curr = manipulator_groups[curr_index].anchor; + let next = manipulator_groups[next_index].anchor; - let dir1 = (curr - prev).normalize_or(DVec2::X); - let dir2 = (next - curr).normalize_or(DVec2::X); + let dir1 = (curr - prev).normalize_or(DVec2::X); + let dir2 = (next - curr).normalize_or(DVec2::X); - let theta = PI - dir1.angle_to(dir2).abs(); + let theta = PI - dir1.angle_to(dir2).abs(); - // Skip near-straight corners - if theta > PI - min_angle_threshold.to_radians() { - new_manipulator_groups.push(manipulator_groups[curr_index]); - continue; - } + // Skip near-straight corners + if theta > PI - min_angle_threshold.to_radians() { + new_manipulator_groups.push(manipulator_groups[curr_index]); + continue; + } - // Calculate L, with limits to avoid extreme values - let distance_along_edge = radius / (theta / 2.).sin(); - let distance_along_edge = distance_along_edge.min(edge_length_limit * (curr - prev).length().min((next - curr).length())).max(0.01); - - // Find points on each edge at distance L from corner - let p1 = curr - dir1 * distance_along_edge; - let p2 = curr + dir2 * distance_along_edge; - - // Add first point (coming into the rounded corner) - new_manipulator_groups.push(ManipulatorGroup { - anchor: p1, - in_handle: None, - out_handle: Some(curr - dir1 * distance_along_edge * roundness), - id: initial_point_id.next_id(), - }); - - // Add second point (coming out of the rounded corner) - new_manipulator_groups.push(ManipulatorGroup { - anchor: p2, - in_handle: Some(curr + dir2 * distance_along_edge * roundness), - out_handle: None, - id: initial_point_id.next_id(), - }); - } + // Calculate L, with limits to avoid extreme values + let distance_along_edge = radius / (theta / 2.).sin(); + let distance_along_edge = distance_along_edge.min(edge_length_limit * (curr - prev).length().min((next - curr).length())).max(0.01); - // One subpath for each shape - let mut rounded_subpath = bezpath_from_manipulator_groups(&new_manipulator_groups, is_closed); - rounded_subpath.apply_affine(Affine::new(source_transform_inverse.to_cols_array())); - result.append_bezpath(rounded_subpath); - } + // Find points on each edge at distance L from corner + let p1 = curr - dir1 * distance_along_edge; + let p2 = curr + dir2 * distance_along_edge; - Item::from_parts(result, attributes) - }) - .collect() + // Add first point (coming into the rounded corner) + new_manipulator_groups.push(ManipulatorGroup { + anchor: p1, + in_handle: None, + out_handle: Some(curr - dir1 * distance_along_edge * roundness), + id: initial_point_id.next_id(), + }); + + // Add second point (coming out of the rounded corner) + new_manipulator_groups.push(ManipulatorGroup { + anchor: p2, + in_handle: Some(curr + dir2 * distance_along_edge * roundness), + out_handle: None, + id: initial_point_id.next_id(), + }); + } + + // One subpath for each shape + let mut rounded_subpath = bezpath_from_manipulator_groups(&new_manipulator_groups, is_closed); + rounded_subpath.apply_affine(Affine::new(source_transform_inverse.to_cols_array())); + result.append_bezpath(rounded_subpath); + } + + Item::from_parts(result, attributes) } #[node_macro::node(name("Merge by Distance"), category("Vector: Modifier"), path(core_types::vector))] pub fn merge_by_distance( _: impl Ctx, - content: List, + mut content: Item, #[default(0.1)] #[hard(0.0001..)] distance: PixelLength, algorithm: MergeByDistanceAlgorithm, -) -> List { +) -> Item { match algorithm { - MergeByDistanceAlgorithm::Spatial => content - .into_iter() - .map(|mut row| { - let transform: DAffine2 = row.attribute_cloned_or_default(ATTR_TRANSFORM); - row.element_mut().merge_by_distance_spatial(transform, distance); - row - }) - .collect(), - MergeByDistanceAlgorithm::Topological => content - .into_iter() - .map(|mut row| { - row.element_mut().merge_by_distance_topological(distance); - row - }) - .collect(), + MergeByDistanceAlgorithm::Spatial => { + let transform: DAffine2 = content.attribute_cloned_or_default(ATTR_TRANSFORM); + content.element_mut().merge_by_distance_spatial(transform, distance); + } + MergeByDistanceAlgorithm::Topological => content.element_mut().merge_by_distance_topological(distance), } + + content } pub mod extrude_algorithms { @@ -777,10 +764,8 @@ pub mod extrude_algorithms { } #[node_macro::node(category("Vector: Modifier"), path(core_types::vector))] -async fn extrude(_: impl Ctx, mut source: List, direction: DVec2, joining_algorithm: ExtrudeJoiningAlgorithm) -> List { - for vector in source.iter_element_values_mut() { - extrude_algorithms::extrude(vector, direction, joining_algorithm); - } +async fn extrude(_: impl Ctx, mut source: Item, direction: DVec2, joining_algorithm: ExtrudeJoiningAlgorithm) -> Item { + extrude_algorithms::extrude(source.element_mut(), direction, joining_algorithm); source } @@ -993,7 +978,7 @@ where #[node_macro::node(category("Vector: Modifier"), name("Auto-Tangents"), path(core_types::vector))] async fn auto_tangents( _: impl Ctx, - source: List, + source: Item, /// The amount of spread for the auto-tangents, from 0 (sharp corner) to 1 (full spread). #[default(0.5)] #[range] @@ -1002,146 +987,141 @@ async fn auto_tangents( /// If active, existing non-zero handles won't be affected. #[default(true)] preserve_existing: bool, -) -> List { - (0..source.len()) - .map(|index| { - let transform: DAffine2 = source.attribute_cloned_or_default(ATTR_TRANSFORM, index); - let attributes = source.clone_item_attributes(index); - let source = source.element(index).unwrap(); +) -> Item { + let transform: DAffine2 = source.attribute_cloned_or_default(ATTR_TRANSFORM); + let (source, attributes) = source.into_parts(); - let mut result = Vector { - stroke: source.stroke.clone(), - ..Default::default() - }; + let mut result = Vector { + stroke: source.stroke.clone(), + ..Default::default() + }; - for mut subpath in source.stroke_bezier_paths() { - subpath.apply_transform(transform); + for mut subpath in source.stroke_bezier_paths() { + subpath.apply_transform(transform); - let manipulators_list = subpath.manipulator_groups(); - if manipulators_list.len() < 2 { - // Not enough points for softening or handle removal - result.append_subpath(subpath, true); - continue; - } + let manipulators_list = subpath.manipulator_groups(); + if manipulators_list.len() < 2 { + // Not enough points for softening or handle removal + result.append_subpath(subpath, true); + continue; + } - let mut new_manipulators_list = Vec::with_capacity(manipulators_list.len()); - // Track which manipulator indices were given auto-tangent (colinear) handles - let mut auto_tangented = vec![false; manipulators_list.len()]; - let is_closed = subpath.closed(); + let mut new_manipulators_list = Vec::with_capacity(manipulators_list.len()); + // Track which manipulator indices were given auto-tangent (colinear) handles + let mut auto_tangented = vec![false; manipulators_list.len()]; + let is_closed = subpath.closed(); - for i in 0..manipulators_list.len() { - let current = &manipulators_list[i]; - let is_endpoint = !is_closed && (i == 0 || i == manipulators_list.len() - 1); + for i in 0..manipulators_list.len() { + let current = &manipulators_list[i]; + let is_endpoint = !is_closed && (i == 0 || i == manipulators_list.len() - 1); - if preserve_existing { - // Check if this point has handles that are meaningfully different from the anchor - let has_handles = (current.in_handle.is_some() && !current.in_handle.unwrap().abs_diff_eq(current.anchor, 1e-5)) - || (current.out_handle.is_some() && !current.out_handle.unwrap().abs_diff_eq(current.anchor, 1e-5)); + if preserve_existing { + // Check if this point has handles that are meaningfully different from the anchor + let has_handles = (current.in_handle.is_some() && !current.in_handle.unwrap().abs_diff_eq(current.anchor, 1e-5)) + || (current.out_handle.is_some() && !current.out_handle.unwrap().abs_diff_eq(current.anchor, 1e-5)); - // If the point already has handles, keep it as is - if has_handles { - new_manipulators_list.push(*current); - continue; - } - } + // If the point already has handles, keep it as is + if has_handles { + new_manipulators_list.push(*current); + continue; + } + } - // If spread is 0, remove handles for this point, making it a sharp corner - if spread == 0. { - new_manipulators_list.push(ManipulatorGroup { - anchor: current.anchor, - in_handle: None, - out_handle: None, - id: current.id, - }); - continue; - } + // If spread is 0, remove handles for this point, making it a sharp corner + if spread == 0. { + new_manipulators_list.push(ManipulatorGroup { + anchor: current.anchor, + in_handle: None, + out_handle: None, + id: current.id, + }); + continue; + } - // Endpoints of open paths get zero-length cubic handles so adjacent segments remain cubic (not quadratic) - if is_endpoint { - new_manipulators_list.push(ManipulatorGroup { - anchor: current.anchor, - in_handle: Some(current.anchor), - out_handle: Some(current.anchor), - id: current.id, - }); - continue; - } + // Endpoints of open paths get zero-length cubic handles so adjacent segments remain cubic (not quadratic) + if is_endpoint { + new_manipulators_list.push(ManipulatorGroup { + anchor: current.anchor, + in_handle: Some(current.anchor), + out_handle: Some(current.anchor), + id: current.id, + }); + continue; + } - // Get previous and next points for auto-tangent calculation - let prev_index = if i == 0 { manipulators_list.len() - 1 } else { i - 1 }; - let next_index = if i == manipulators_list.len() - 1 { 0 } else { i + 1 }; + // Get previous and next points for auto-tangent calculation + let prev_index = if i == 0 { manipulators_list.len() - 1 } else { i - 1 }; + let next_index = if i == manipulators_list.len() - 1 { 0 } else { i + 1 }; - let current_position = current.anchor; - let delta_prev = manipulators_list[prev_index].anchor - current_position; - let delta_next = manipulators_list[next_index].anchor - current_position; + let current_position = current.anchor; + let delta_prev = manipulators_list[prev_index].anchor - current_position; + let delta_next = manipulators_list[next_index].anchor - current_position; - // Calculate normalized directions and distances to adjacent points - let distance_prev = delta_prev.length(); - let distance_next = delta_next.length(); + // Calculate normalized directions and distances to adjacent points + let distance_prev = delta_prev.length(); + let distance_next = delta_next.length(); - // Check if we have valid directions (e.g., points are not coincident) - if distance_prev < 1e-5 || distance_next < 1e-5 { - // Fallback: keep the original manipulator group (which has no active handles here) - new_manipulators_list.push(*current); - continue; - } + // Check if we have valid directions (e.g., points are not coincident) + if distance_prev < 1e-5 || distance_next < 1e-5 { + // Fallback: keep the original manipulator group (which has no active handles here) + new_manipulators_list.push(*current); + continue; + } - let direction_prev = delta_prev / distance_prev; - let direction_next = delta_next / distance_next; + let direction_prev = delta_prev / distance_prev; + let direction_next = delta_next / distance_next; - // Calculate handle direction as the bisector of the two normalized directions. - // This ensures the in and out handles are colinear (180° apart) through the anchor. - let mut handle_direction = (direction_prev - direction_next).try_normalize().unwrap_or_else(|| direction_prev.perp()); + // Calculate handle direction as the bisector of the two normalized directions. + // This ensures the in and out handles are colinear (180° apart) through the anchor. + let mut handle_direction = (direction_prev - direction_next).try_normalize().unwrap_or_else(|| direction_prev.perp()); - // Ensure consistent orientation of the handle direction. - // This makes the `+ handle_direction` for in_handle and `- handle_direction` for out_handle consistent. - if direction_prev.dot(handle_direction) < 0. { - handle_direction = -handle_direction; - } + // Ensure consistent orientation of the handle direction. + // This makes the `+ handle_direction` for in_handle and `- handle_direction` for out_handle consistent. + if direction_prev.dot(handle_direction) < 0. { + handle_direction = -handle_direction; + } - // Calculate handle lengths: 1/3 of distance to adjacent points, scaled by spread - let in_length = distance_prev / 3. * spread; - let out_length = distance_next / 3. * spread; - - // Create new manipulator group with calculated auto-tangents - new_manipulators_list.push(ManipulatorGroup { - anchor: current_position, - in_handle: Some(current_position + handle_direction * in_length), - out_handle: Some(current_position - handle_direction * out_length), - id: current.id, - }); - auto_tangented[i] = true; - } + // Calculate handle lengths: 1/3 of distance to adjacent points, scaled by spread + let in_length = distance_prev / 3. * spread; + let out_length = distance_next / 3. * spread; - // Record segment count before appending so we can find the new segment IDs - let segment_offset = result.segment_domain.ids().len(); - - let mut softened_bezpath = bezpath_from_manipulator_groups(&new_manipulators_list, is_closed); - softened_bezpath.apply_affine(Affine::new(transform.inverse().to_cols_array())); - result.append_bezpath(softened_bezpath); - - // Mark auto-tangented points as having colinear handles - let segment_ids = result.segment_domain.ids(); - let num_manipulators = new_manipulators_list.len(); - for (i, _) in auto_tangented.iter().enumerate().filter(|&(_, &tangented)| tangented) { - // For interior point i, the incoming segment is segment_offset + (i - 1) and outgoing is segment_offset + i. - // For closed paths, point 0's incoming segment is the last one (segment_offset + num_manipulators - 1). - // For open paths, endpoints are never auto-tangented (the `is_endpoint` check above ensures that), - // so `i == 0` and `i == num_manipulators - 1` only occur here when the path is closed - let in_segment_index = if i == 0 { segment_offset + num_manipulators - 1 } else { segment_offset + i - 1 }; - let out_segment_index = if i == num_manipulators - 1 { segment_offset } else { segment_offset + i }; - - if in_segment_index < segment_ids.len() && out_segment_index < segment_ids.len() { - result - .colinear_manipulators - .push([HandleId::end(segment_ids[in_segment_index]), HandleId::primary(segment_ids[out_segment_index])]); - } - } + // Create new manipulator group with calculated auto-tangents + new_manipulators_list.push(ManipulatorGroup { + anchor: current_position, + in_handle: Some(current_position + handle_direction * in_length), + out_handle: Some(current_position - handle_direction * out_length), + id: current.id, + }); + auto_tangented[i] = true; + } + + // Record segment count before appending so we can find the new segment IDs + let segment_offset = result.segment_domain.ids().len(); + + let mut softened_bezpath = bezpath_from_manipulator_groups(&new_manipulators_list, is_closed); + softened_bezpath.apply_affine(Affine::new(transform.inverse().to_cols_array())); + result.append_bezpath(softened_bezpath); + + // Mark auto-tangented points as having colinear handles + let segment_ids = result.segment_domain.ids(); + let num_manipulators = new_manipulators_list.len(); + for (i, _) in auto_tangented.iter().enumerate().filter(|&(_, &tangented)| tangented) { + // For interior point i, the incoming segment is segment_offset + (i - 1) and outgoing is segment_offset + i. + // For closed paths, point 0's incoming segment is the last one (segment_offset + num_manipulators - 1). + // For open paths, endpoints are never auto-tangented (the `is_endpoint` check above ensures that), + // so `i == 0` and `i == num_manipulators - 1` only occur here when the path is closed + let in_segment_index = if i == 0 { segment_offset + num_manipulators - 1 } else { segment_offset + i - 1 }; + let out_segment_index = if i == num_manipulators - 1 { segment_offset } else { segment_offset + i }; + + if in_segment_index < segment_ids.len() && out_segment_index < segment_ids.len() { + result + .colinear_manipulators + .push([HandleId::end(segment_ids[in_segment_index]), HandleId::primary(segment_ids[out_segment_index])]); } + } + } - Item::from_parts(result, attributes) - }) - .collect() + Item::from_parts(result, attributes) } #[node_macro::node(category("Vector: Modifier"), path(core_types::vector))] @@ -1180,75 +1160,70 @@ fn as_vector(_: impl Ctx, value: List) -> List { /// Creates a polyline from a series of vector points, replacing any existing segments and regions that may already exist. #[node_macro::node(category("Vector"), name("Points to Polyline"), path(core_types::vector))] -async fn points_to_polyline(_: impl Ctx, mut points: List, #[default(true)] closed: bool) -> List { - for vector in points.iter_element_values_mut() { - let mut segment_domain = SegmentDomain::new(); - let mut next_id = SegmentId::ZERO; +async fn points_to_polyline(_: impl Ctx, mut points: Item, #[default(true)] closed: bool) -> Item { + let vector = points.element_mut(); - let points_count = vector.point_domain.ids().len(); + let mut segment_domain = SegmentDomain::new(); + let mut next_id = SegmentId::ZERO; - if points_count >= 2 { - (0..points_count - 1).for_each(|i| { - segment_domain.push(next_id.next_id(), i, i + 1, BezierHandles::Linear, StrokeId::generate()); - }); + let points_count = vector.point_domain.ids().len(); - if closed && points_count != 2 { - segment_domain.push(next_id.next_id(), points_count - 1, 0, BezierHandles::Linear, StrokeId::generate()); + if points_count >= 2 { + (0..points_count - 1).for_each(|i| { + segment_domain.push(next_id.next_id(), i, i + 1, BezierHandles::Linear, StrokeId::generate()); + }); - vector - .region_domain - .push(RegionId::generate(), segment_domain.ids()[0]..=*segment_domain.ids().last().unwrap(), FillId::generate()); - } - } + if closed && points_count != 2 { + segment_domain.push(next_id.next_id(), points_count - 1, 0, BezierHandles::Linear, StrokeId::generate()); - vector.segment_domain = segment_domain; + vector + .region_domain + .push(RegionId::generate(), segment_domain.ids()[0]..=*segment_domain.ids().last().unwrap(), FillId::generate()); + } } + vector.segment_domain = segment_domain; + points } #[node_macro::node(category("Vector: Modifier"), path(core_types::vector), properties("offset_path_properties"))] -async fn offset_path(_: impl Ctx, content: List, distance: f64, join: StrokeJoin, #[default(4.)] miter_limit: f64) -> List { - content - .into_iter() - .map(|mut row| { - let transform_attribute: DAffine2 = row.attribute_cloned_or_default(ATTR_TRANSFORM); - let transform = Affine::new(transform_attribute.to_cols_array()); - let vector = std::mem::take(row.element_mut()); +async fn offset_path(_: impl Ctx, mut content: Item, distance: f64, join: StrokeJoin, #[default(4.)] miter_limit: f64) -> Item { + let transform_attribute: DAffine2 = content.attribute_cloned_or_default(ATTR_TRANSFORM); + let transform = Affine::new(transform_attribute.to_cols_array()); + let vector = std::mem::take(content.element_mut()); + + let bezpaths = vector.stroke_bezpath_iter(); + let mut result = Vector { + stroke: vector.stroke.clone(), + ..Default::default() + }; + result.set_stroke_transform(DAffine2::IDENTITY); - let bezpaths = vector.stroke_bezpath_iter(); - let mut result = Vector { - stroke: vector.stroke.clone(), - ..Default::default() - }; - result.set_stroke_transform(DAffine2::IDENTITY); + // Perform operation on all subpaths in this shape. + for mut bezpath in bezpaths { + bezpath.apply_affine(transform); - // Perform operation on all subpaths in this shape. - for mut bezpath in bezpaths { - bezpath.apply_affine(transform); - - // Taking the existing stroke data and passing it to Kurbo to generate new paths. - let mut bezpath_out = offset_bezpath( - &bezpath, - -distance, - match join { - StrokeJoin::Miter => kurbo::Join::Miter, - StrokeJoin::Bevel => kurbo::Join::Bevel, - StrokeJoin::Round => kurbo::Join::Round, - }, - Some(miter_limit), - ); - - bezpath_out.apply_affine(transform.inverse()); - - // One closed subpath, open path. - result.append_bezpath(bezpath_out); - } + // Taking the existing stroke data and passing it to Kurbo to generate new paths. + let mut bezpath_out = offset_bezpath( + &bezpath, + -distance, + match join { + StrokeJoin::Miter => kurbo::Join::Miter, + StrokeJoin::Bevel => kurbo::Join::Bevel, + StrokeJoin::Round => kurbo::Join::Round, + }, + Some(miter_limit), + ); - *row.element_mut() = result; - row - }) - .collect() + bezpath_out.apply_affine(transform.inverse()); + + // One closed subpath, open path. + result.append_bezpath(bezpath_out); + } + + *content.element_mut() = result; + content } #[node_macro::node(category("Vector: Modifier"), path(core_types::vector))] @@ -1482,7 +1457,7 @@ pub async fn flatten_path(_: impl Ctx, #[implementations(Lis #[node_macro::node(category("Vector: Modifier"), path(core_types::vector), properties("sample_polyline_properties"), memoize)] async fn sample_polyline( _: impl Ctx, - content: List, + mut content: Item, spacing: PointSpacingType, #[default(100.)] #[hard(0..)] @@ -1498,7 +1473,7 @@ async fn sample_polyline( #[unit(" px")] stop_offset: f64, adaptive_spacing: bool, -) -> List { +) -> Item { let pathseg_perimeter = |segment: PathSeg| { if is_linear(segment) { Line::new(segment.start(), segment.end()).perimeter(DEFAULT_ACCURACY) @@ -1507,61 +1482,56 @@ async fn sample_polyline( } }; - content - .into_iter() - .map(|mut row| { - let mut result = Vector { - point_domain: Default::default(), - segment_domain: Default::default(), - region_domain: Default::default(), - colinear_manipulators: Default::default(), - stroke: std::mem::take(&mut row.element_mut().stroke), - }; - // Transfer the stroke transform from the input vector content to the result. - result.set_stroke_transform(row.attribute_cloned_or_default(ATTR_TRANSFORM)); + let mut result = Vector { + point_domain: Default::default(), + segment_domain: Default::default(), + region_domain: Default::default(), + colinear_manipulators: Default::default(), + stroke: std::mem::take(&mut content.element_mut().stroke), + }; + // Transfer the stroke transform from the input vector content to the result. + result.set_stroke_transform(content.attribute_cloned_or_default(ATTR_TRANSFORM)); - for local_bezpath in row.element().stroke_bezpath_iter() { - // Apply the transform to compute sample locations in world space (for correct distance-based spacing) - let mut world_bezpath = local_bezpath.clone(); - let transform_attribute: DAffine2 = row.attribute_cloned_or_default(ATTR_TRANSFORM); - world_bezpath.apply_affine(Affine::new(transform_attribute.to_cols_array())); + for local_bezpath in content.element().stroke_bezpath_iter() { + // Apply the transform to compute sample locations in world space (for correct distance-based spacing) + let mut world_bezpath = local_bezpath.clone(); + let transform_attribute: DAffine2 = content.attribute_cloned_or_default(ATTR_TRANSFORM); + world_bezpath.apply_affine(Affine::new(transform_attribute.to_cols_array())); - // Per-segment perimeter lengths (transform-baked) for distance-based spacing - let segment_lengths: Vec = world_bezpath.segments().map(pathseg_perimeter).collect(); + // Per-segment perimeter lengths (transform-baked) for distance-based spacing + let segment_lengths: Vec = world_bezpath.segments().map(pathseg_perimeter).collect(); - let amount = match spacing { - PointSpacingType::Separation => separation, - PointSpacingType::Quantity => quantity as f64, - }; + let amount = match spacing { + PointSpacingType::Separation => separation, + PointSpacingType::Quantity => quantity as f64, + }; - // Compute sample locations using world-space distances, then evaluate positions on the untransformed bezpath. - // This avoids needing to invert the transform (which fails when the transform is singular, e.g. zero scale). - let Some((locations, was_closed)) = bezpath_algorithms::compute_sample_locations(&world_bezpath, spacing, amount, start_offset, stop_offset, adaptive_spacing, &segment_lengths) else { - continue; - }; + // Compute sample locations using world-space distances, then evaluate positions on the untransformed bezpath. + // This avoids needing to invert the transform (which fails when the transform is singular, e.g. zero scale). + let Some((locations, was_closed)) = bezpath_algorithms::compute_sample_locations(&world_bezpath, spacing, amount, start_offset, stop_offset, adaptive_spacing, &segment_lengths) else { + continue; + }; - // Evaluate the sample locations on the untransformed bezpath and append the result - let mut sample_bezpath = BezPath::new(); - for &(segment_index, t) in &locations { - let segment = local_bezpath.get_seg(segment_index + 1).unwrap(); - let point = segment.eval(t); + // Evaluate the sample locations on the untransformed bezpath and append the result + let mut sample_bezpath = BezPath::new(); + for &(segment_index, t) in &locations { + let segment = local_bezpath.get_seg(segment_index + 1).unwrap(); + let point = segment.eval(t); - if sample_bezpath.elements().is_empty() { - sample_bezpath.move_to(point); - } else { - sample_bezpath.line_to(point); - } - } - if was_closed { - sample_bezpath.close_path(); - } - result.append_bezpath(sample_bezpath); + if sample_bezpath.elements().is_empty() { + sample_bezpath.move_to(point); + } else { + sample_bezpath.line_to(point); } + } + if was_closed { + sample_bezpath.close_path(); + } + result.append_bezpath(sample_bezpath); + } - *row.element_mut() = result; - row - }) - .collect() + *content.element_mut() = result; + content } /// Simplifies vector paths by reducing the number of curve segments while preserving the overall shape within the given tolerance. @@ -1569,43 +1539,38 @@ async fn sample_polyline( async fn simplify( _: impl Ctx, /// The vector paths to simplify. - content: List, + mut content: Item, /// The maximum distance the simplified path may deviate from the original. #[default(5.)] #[unit(" px")] tolerance: Length, -) -> List { +) -> Item { if tolerance <= 0. { return content; } let options = SimplifyOptions::default(); - content - .into_iter() - .map(|mut row| { - let transform_attribute: DAffine2 = row.attribute_cloned_or_default(ATTR_TRANSFORM); - let transform = Affine::new(transform_attribute.to_cols_array()); - let inverse_transform = transform.inverse(); + let transform_attribute: DAffine2 = content.attribute_cloned_or_default(ATTR_TRANSFORM); + let transform = Affine::new(transform_attribute.to_cols_array()); + let inverse_transform = transform.inverse(); - let mut result = Vector { - stroke: std::mem::take(&mut row.element_mut().stroke), - ..Default::default() - }; + let mut result = Vector { + stroke: std::mem::take(&mut content.element_mut().stroke), + ..Default::default() + }; - for mut bezpath in row.element().stroke_bezpath_iter() { - bezpath.apply_affine(transform); + for mut bezpath in content.element().stroke_bezpath_iter() { + bezpath.apply_affine(transform); - let mut simplified = simplify_bezpath(bezpath, tolerance, &options); + let mut simplified = simplify_bezpath(bezpath, tolerance, &options); - simplified.apply_affine(inverse_transform); - result.append_bezpath(simplified); - } + simplified.apply_affine(inverse_transform); + result.append_bezpath(simplified); + } - *row.element_mut() = result; - row - }) - .collect() + *content.element_mut() = result; + content } /// Decimates vector paths into polylines by sampling any curves into line segments, then removing points that don't significantly contribute to the shape using the Ramer-Douglas-Peucker algorithm. @@ -1613,12 +1578,12 @@ async fn simplify( async fn decimate( _: impl Ctx, /// The vector paths to decimate. - content: List, + mut content: Item, /// The maximum distance a point can deviate from the simplified path before it is kept. #[default(5.)] #[unit(" px")] tolerance: Length, -) -> List { +) -> Item { // Tolerance of 0 means no simplification is possible, so return immediately if tolerance <= 0. { return content; @@ -1677,61 +1642,56 @@ async fn decimate( points.iter().enumerate().filter(|(i, _)| keep[*i]).map(|(_, p)| *p).collect() } - content - .into_iter() - .map(|mut row| { - let transform_attribute: DAffine2 = row.attribute_cloned_or_default(ATTR_TRANSFORM); - let transform = Affine::new(transform_attribute.to_cols_array()); - let inverse_transform = transform.inverse(); + let transform_attribute: DAffine2 = content.attribute_cloned_or_default(ATTR_TRANSFORM); + let transform = Affine::new(transform_attribute.to_cols_array()); + let inverse_transform = transform.inverse(); - let mut result = Vector { - stroke: std::mem::take(&mut row.element_mut().stroke), - ..Default::default() - }; + let mut result = Vector { + stroke: std::mem::take(&mut content.element_mut().stroke), + ..Default::default() + }; - for mut bezpath in row.element().stroke_bezpath_iter() { - bezpath.apply_affine(transform); + for mut bezpath in content.element().stroke_bezpath_iter() { + bezpath.apply_affine(transform); - let is_closed = matches!(bezpath.elements().last(), Some(PathEl::ClosePath)); + let is_closed = matches!(bezpath.elements().last(), Some(PathEl::ClosePath)); - // Flatten the bezpath into line segments, then collect the points - let mut points = Vec::new(); - kurbo::flatten(bezpath, tolerance * 0.5, |el| match el { - PathEl::MoveTo(p) | PathEl::LineTo(p) => { - points.push(DVec2::new(p.x, p.y)); - } - _ => {} - }); + // Flatten the bezpath into line segments, then collect the points + let mut points = Vec::new(); + kurbo::flatten(bezpath, tolerance * 0.5, |el| match el { + PathEl::MoveTo(p) | PathEl::LineTo(p) => { + points.push(DVec2::new(p.x, p.y)); + } + _ => {} + }); - // For closed paths, the last point duplicates the first, so remove it - if is_closed && points.len() > 1 && points.last() == points.first() { - points.pop(); - } + // For closed paths, the last point duplicates the first, so remove it + if is_closed && points.len() > 1 && points.last() == points.first() { + points.pop(); + } - // Apply RDP simplification - let simplified = rdp_simplify(&points, tolerance); - if simplified.is_empty() { - continue; - } + // Apply RDP simplification + let simplified = rdp_simplify(&points, tolerance); + if simplified.is_empty() { + continue; + } - // Reconstruct as a polyline - let mut new_bezpath = BezPath::new(); - new_bezpath.move_to((simplified[0].x, simplified[0].y)); - for &point in &simplified[1..] { - new_bezpath.line_to((point.x, point.y)); - } - if is_closed { - new_bezpath.close_path(); - } + // Reconstruct as a polyline + let mut new_bezpath = BezPath::new(); + new_bezpath.move_to((simplified[0].x, simplified[0].y)); + for &point in &simplified[1..] { + new_bezpath.line_to((point.x, point.y)); + } + if is_closed { + new_bezpath.close_path(); + } - new_bezpath.apply_affine(inverse_transform); - result.append_bezpath(new_bezpath); - } + new_bezpath.apply_affine(inverse_transform); + result.append_bezpath(new_bezpath); + } - *row.element_mut() = result; - row - }) - .collect() + *content.element_mut() = result; + content } /// Cuts a path at a given progression from 0 to 1 along the path, creating two new subpaths from the original one (if the path is initially open) or one open subpath (if the path is initially closed). @@ -1741,34 +1701,30 @@ async fn decimate( async fn cut_path( _: impl Ctx, /// The path to insert a cut into. - mut content: List, + mut content: Item, /// The factor from the start to the end of the path, 0–1 for one subpath, 1–2 for a second subpath, and so on. progression: Progression, /// Swap the direction of the path. reverse: bool, /// Traverse the path using each segment's Bézier curve parameterization instead of the Euclidean distance. Faster to compute but doesn't respect actual distances. parameterized_distance: bool, -) -> List { +) -> Item { let euclidian = !parameterized_distance; - let bezpaths = content - .iter_element_values() - .enumerate() - .flat_map(|(row_index, vector)| vector.stroke_bezpath_iter().map(|bezpath| (row_index, bezpath)).collect::>()) - .collect::>(); + let bezpaths = content.element().stroke_bezpath_iter().collect::>(); let bezpath_count = bezpaths.len() as f64; let t_value = progression.clamp(0., bezpath_count); let t_value = if reverse { bezpath_count - t_value } else { t_value }; let index = if t_value >= bezpath_count { (bezpath_count - 1.) as usize } else { t_value as usize }; - if let Some((row_index, bezpath)) = bezpaths.get(index).cloned() { + if let Some(bezpath) = bezpaths.get(index).cloned() { let mut result_vector = Vector { - stroke: content.element(row_index).unwrap().stroke.clone(), + stroke: content.element().stroke.clone(), ..Default::default() }; - for (_, (_, bezpath)) in bezpaths.iter().enumerate().filter(|(i, (ri, _))| *i != index && *ri == row_index) { + for (_, bezpath) in bezpaths.iter().enumerate().filter(|&(i, _)| i != index) { result_vector.append_bezpath(bezpath.clone()); } let t = if t_value == bezpath_count { 1. } else { t_value.fract() }; @@ -1781,7 +1737,7 @@ async fn cut_path( result_vector.append_bezpath(bezpath); } - *content.element_mut(row_index).unwrap() = result_vector; + *content.element_mut() = result_vector; } content @@ -1789,56 +1745,56 @@ async fn cut_path( /// Cuts path segments into separate disconnected pieces where each is a distinct subpath. #[node_macro::node(category("Vector: Modifier"), path(core_types::vector))] -async fn cut_segments(_: impl Ctx, mut content: List) -> List { +async fn cut_segments(_: impl Ctx, mut content: Item) -> Item { // Iterate through every segment and make a copy of each of its endpoints, then reassign each segment's endpoints to its own unique point copy - for vector in content.iter_element_values_mut() { - let points_count = vector.point_domain.ids().len(); - let segments_count = vector.segment_domain.ids().len(); + let vector = content.element_mut(); - let mut point_usages = vec![0_usize; points_count]; + let points_count = vector.point_domain.ids().len(); + let segments_count = vector.segment_domain.ids().len(); - // Count how many times each point is used as an endpoint of the segments - let start_points = vector.segment_domain.start_point().to_vec(); - let end_points = vector.segment_domain.end_point().to_vec(); - for (&start, &end) in start_points.iter().zip(end_points.iter()) { - point_usages[start] += 1; - point_usages[end] += 1; - } + let mut point_usages = vec![0_usize; points_count]; - let mut new_points = PointDomain::new(); - let mut offset_sum: usize = 0; - let mut points_with_new_offsets = Vec::with_capacity(points_count); + // Count how many times each point is used as an endpoint of the segments + let start_points = vector.segment_domain.start_point().to_vec(); + let end_points = vector.segment_domain.end_point().to_vec(); + for (&start, &end) in start_points.iter().zip(end_points.iter()) { + point_usages[start] += 1; + point_usages[end] += 1; + } - // Build a new point domain with the original points, but with duplications based on their extra usages by the segments - for (index, (point_id, point)) in vector.point_domain.iter().enumerate() { - // Ensure at least one usage to preserve free-floating points not connected to any segments - let usage_count = point_usages[index].max(1); + let mut new_points = PointDomain::new(); + let mut offset_sum: usize = 0; + let mut points_with_new_offsets = Vec::with_capacity(points_count); - new_points.push_unchecked(point_id, point); + // Build a new point domain with the original points, but with duplications based on their extra usages by the segments + for (index, (point_id, point)) in vector.point_domain.iter().enumerate() { + // Ensure at least one usage to preserve free-floating points not connected to any segments + let usage_count = point_usages[index].max(1); - for i in 1..usage_count { - new_points.push_unchecked(point_id.generate_from_hash(i as u64), point); - } + new_points.push_unchecked(point_id, point); - points_with_new_offsets.push(offset_sum); - offset_sum += usage_count; + for i in 1..usage_count { + new_points.push_unchecked(point_id.generate_from_hash(i as u64), point); } - // Reconcile the segment domain with the new points - vector.point_domain = new_points; - for original_segment_index in 0..segments_count { - let original_point_start_index = start_points[original_segment_index]; - let original_point_end_index = end_points[original_segment_index]; + points_with_new_offsets.push(offset_sum); + offset_sum += usage_count; + } - point_usages[original_point_start_index] -= 1; - point_usages[original_point_end_index] -= 1; + // Reconcile the segment domain with the new points + vector.point_domain = new_points; + for original_segment_index in 0..segments_count { + let original_point_start_index = start_points[original_segment_index]; + let original_point_end_index = end_points[original_segment_index]; - let start_usage = points_with_new_offsets[original_point_start_index] + point_usages[original_point_start_index]; - let end_usage = points_with_new_offsets[original_point_end_index] + point_usages[original_point_end_index]; + point_usages[original_point_start_index] -= 1; + point_usages[original_point_end_index] -= 1; - vector.segment_domain.set_start_point(original_segment_index, start_usage); - vector.segment_domain.set_end_point(original_segment_index, end_usage); - } + let start_usage = points_with_new_offsets[original_point_start_index] + point_usages[original_point_start_index]; + let end_usage = points_with_new_offsets[original_point_end_index] + point_usages[original_point_end_index]; + + vector.segment_domain.set_start_point(original_segment_index, start_usage); + vector.segment_domain.set_end_point(original_segment_index, end_usage); } content @@ -1936,7 +1892,7 @@ async fn tangent_on_path( #[node_macro::node(category("Vector: Modifier"), path(core_types::vector), memoize)] async fn scatter_points( _: impl Ctx, - content: List, + mut content: Item, #[unit(" px")] #[default(10.)] #[range] @@ -1944,89 +1900,79 @@ async fn scatter_points( #[soft(1..100)] separation: f64, seed: SeedValue, -) -> List { +) -> Item { let mut rng = rand::rngs::StdRng::seed_from_u64(seed.into()); - content - .into_iter() - .map(|mut row| { - let mut result = Vector::default(); + let mut result = Vector::default(); - let path_with_bounding_boxes: Vec<_> = row - .element() - .stroke_bezpath_iter() - .map(|mut bezpath| { - // TODO: apply transform to points instead of modifying the paths - bezpath.close_path(); - let bbox = bezpath.bounding_box(); - (bezpath, bbox) - }) - .collect(); + let path_with_bounding_boxes: Vec<_> = content + .element() + .stroke_bezpath_iter() + .map(|mut bezpath| { + // TODO: apply transform to points instead of modifying the paths + bezpath.close_path(); + let bbox = bezpath.bounding_box(); + (bezpath, bbox) + }) + .collect(); - for (i, (subpath, _)) in path_with_bounding_boxes.iter().enumerate() { - if subpath.segments().count() < 2 { - continue; - } + for (i, (subpath, _)) in path_with_bounding_boxes.iter().enumerate() { + if subpath.segments().count() < 2 { + continue; + } - for point in bezpath_algorithms::poisson_disk_points(i, &path_with_bounding_boxes, separation, || rng.random::()) { - result.point_domain.push(PointId::generate(), point); - } - } + for point in bezpath_algorithms::poisson_disk_points(i, &path_with_bounding_boxes, separation, || rng.random::()) { + result.point_domain.push(PointId::generate(), point); + } + } - // Transfer the style from the input vector content to the result. - result.stroke = row.element().stroke.clone(); - result.set_stroke_transform(DAffine2::IDENTITY); + // Transfer the style from the input vector content to the result. + result.stroke = content.element().stroke.clone(); + result.set_stroke_transform(DAffine2::IDENTITY); - *row.element_mut() = result; - row - }) - .collect() + *content.element_mut() = result; + content } #[node_macro::node(name("Spline"), category("Vector: Modifier"), path(core_types::vector))] -async fn spline(_: impl Ctx, content: List) -> List { - content - .into_iter() - .filter_map(|mut row| { - // Exit early if there are no points to generate splines from. - if row.element().point_domain.positions().is_empty() { - return None; - } +async fn spline(_: impl Ctx, mut content: Item) -> Item { + // Exit early if there are no points to generate splines from. + if content.element().point_domain.positions().is_empty() { + return content; + } - let mut segment_domain = SegmentDomain::default(); - let mut next_id = SegmentId::ZERO; - for (manipulator_groups, closed) in row.element().stroke_manipulator_groups() { - let positions = manipulator_groups.iter().map(|manipulators| manipulators.anchor).collect::>(); - let closed = closed && positions.len() > 2; + let mut segment_domain = SegmentDomain::default(); + let mut next_id = SegmentId::ZERO; + for (manipulator_groups, closed) in content.element().stroke_manipulator_groups() { + let positions = manipulator_groups.iter().map(|manipulators| manipulators.anchor).collect::>(); + let closed = closed && positions.len() > 2; - // Compute control point handles for Bezier spline. - let first_handles = if closed { - solve_spline_first_handle_closed(&positions) - } else { - solve_spline_first_handle_open(&positions) - }; + // Compute control point handles for Bezier spline. + let first_handles = if closed { + solve_spline_first_handle_closed(&positions) + } else { + solve_spline_first_handle_open(&positions) + }; - let stroke_id = StrokeId::ZERO; + let stroke_id = StrokeId::ZERO; - // Create segments with computed Bezier handles and add them to the output vector element's segment domain. - for i in 0..(positions.len() - if closed { 0 } else { 1 }) { - let next_index = (i + 1) % positions.len(); + // Create segments with computed Bezier handles and add them to the output vector element's segment domain. + for i in 0..(positions.len() - if closed { 0 } else { 1 }) { + let next_index = (i + 1) % positions.len(); - let start_index = row.element().point_domain.resolve_id(manipulator_groups[i].id).unwrap(); - let end_index = row.element().point_domain.resolve_id(manipulator_groups[next_index].id).unwrap(); + let start_index = content.element().point_domain.resolve_id(manipulator_groups[i].id).unwrap(); + let end_index = content.element().point_domain.resolve_id(manipulator_groups[next_index].id).unwrap(); - let handle_start = first_handles[i]; - let handle_end = positions[next_index] * 2. - first_handles[next_index]; - let handles = BezierHandles::Cubic { handle_start, handle_end }; + let handle_start = first_handles[i]; + let handle_end = positions[next_index] * 2. - first_handles[next_index]; + let handles = BezierHandles::Cubic { handle_start, handle_end }; - segment_domain.push(next_id.next_id(), start_index, end_index, handles, stroke_id); - } - } + segment_domain.push(next_id.next_id(), start_index, end_index, handles, stroke_id); + } + } - row.element_mut().segment_domain = segment_domain; - Some(row) - }) - .collect() + content.element_mut().segment_domain = segment_domain; + content } /// Computes the inverse of a transform's linear (matrix2) part, handling singular transforms @@ -2087,7 +2033,7 @@ fn apply_point_deltas(element: &mut Vector, deltas: &[DVec2], transform: DAffine async fn jitter_points( _: impl Ctx, /// The vector geometry with points to be jittered. - content: List, + mut content: Item, /// The maximum extent of the random distance each point can be offset. #[default(5.)] #[unit(" px")] @@ -2097,38 +2043,37 @@ async fn jitter_points( /// Whether to offset anchor points along their normal direction (perpendicular to the path) or in a random direction. Free-floating and branching points have no normal direction, so they receive a random-angled offset regardless of this setting. #[default(true)] along_normals: bool, -) -> List { - content - .into_iter() - .map(|mut row| { - let mut rng = rand::rngs::StdRng::seed_from_u64(seed.into()); - let transform_attribute: DAffine2 = row.attribute_cloned_or_default(ATTR_TRANSFORM); - let inverse_linear = inverse_linear_or_repair(transform_attribute.matrix2); - - let deltas: Vec<_> = (0..row.element().point_domain.positions().len()) - .map(|point_index| { - let normal = if along_normals { - row.element().segment_domain.point_tangent(point_index, row.element().point_domain.positions()).map(|t| -t.perp()) - } else { - None - }; +) -> Item { + let mut rng = rand::rngs::StdRng::seed_from_u64(seed.into()); + let transform_attribute: DAffine2 = content.attribute_cloned_or_default(ATTR_TRANSFORM); + let inverse_linear = inverse_linear_or_repair(transform_attribute.matrix2); + + let deltas: Vec<_> = (0..content.element().point_domain.positions().len()) + .map(|point_index| { + let normal = if along_normals { + content + .element() + .segment_domain + .point_tangent(point_index, content.element().point_domain.positions()) + .map(|t| -t.perp()) + } else { + None + }; - let offset = if let Some(normal) = normal { - normal * (rng.random::() * 2. - 1.) - } else { - DVec2::from_angle(rng.random::() * TAU) * rng.random::() - }; + let offset = if let Some(normal) = normal { + normal * (rng.random::() * 2. - 1.) + } else { + DVec2::from_angle(rng.random::() * TAU) * rng.random::() + }; - inverse_linear * offset * max_distance - }) - .collect(); + inverse_linear * offset * max_distance + }) + .collect(); - let transform: DAffine2 = row.attribute_cloned_or_default(ATTR_TRANSFORM); - apply_point_deltas(row.element_mut(), &deltas, transform); + let transform: DAffine2 = content.attribute_cloned_or_default(ATTR_TRANSFORM); + apply_point_deltas(content.element_mut(), &deltas, transform); - row - }) - .collect() + content } /// Displaces anchor points along their normal direction (perpendicular to the path) by a set distance. @@ -2137,34 +2082,34 @@ async fn jitter_points( async fn offset_points( _: impl Ctx, /// The vector geometry with points to be offset. - content: List, + mut content: Item, /// The distance to offset each anchor point along its normal. Positive values move outward, negative values move inward. #[default(10.)] #[unit(" px")] distance: f64, -) -> List { - content - .into_iter() - .map(|mut row| { - let transform_attribute: DAffine2 = row.attribute_cloned_or_default(ATTR_TRANSFORM); - let inverse_linear = inverse_linear_or_repair(transform_attribute.matrix2); +) -> Item { + let transform_attribute: DAffine2 = content.attribute_cloned_or_default(ATTR_TRANSFORM); + let inverse_linear = inverse_linear_or_repair(transform_attribute.matrix2); - let deltas: Vec<_> = (0..row.element().point_domain.positions().len()) - .map(|point_index| { - let Some(normal) = row.element().segment_domain.point_tangent(point_index, row.element().point_domain.positions()).map(|t| -t.perp()) else { - return DVec2::ZERO; - }; + let deltas: Vec<_> = (0..content.element().point_domain.positions().len()) + .map(|point_index| { + let Some(normal) = content + .element() + .segment_domain + .point_tangent(point_index, content.element().point_domain.positions()) + .map(|t| -t.perp()) + else { + return DVec2::ZERO; + }; - inverse_linear * normal * distance - }) - .collect(); + inverse_linear * normal * distance + }) + .collect(); - let transform: DAffine2 = row.attribute_cloned_or_default(ATTR_TRANSFORM); - apply_point_deltas(row.element_mut(), &deltas, transform); + let transform: DAffine2 = content.attribute_cloned_or_default(ATTR_TRANSFORM); + apply_point_deltas(content.element_mut(), &deltas, transform); - row - }) - .collect() + content } /// Interpolates the geometry, appearance, and transform between multiple vector layers, producing a single morphed vector shape. @@ -3084,16 +3029,11 @@ fn bevel_algorithm(mut vector: Vector, transform: DAffine2, distance: f64) -> Ve } #[node_macro::node(category("Vector: Modifier"), path(core_types::vector))] -fn bevel(_: impl Ctx, source: List, #[default(10.)] distance: Length) -> List { - source - .into_iter() - .map(|row| { - let transform: DAffine2 = row.attribute_cloned_or_default(ATTR_TRANSFORM); - let (element, attributes) = row.into_parts(); +fn bevel(_: impl Ctx, source: Item, #[default(10.)] distance: Length) -> Item { + let transform: DAffine2 = source.attribute_cloned_or_default(ATTR_TRANSFORM); + let (element, attributes) = source.into_parts(); - Item::from_parts(bevel_algorithm(element, transform, distance), attributes) - }) - .collect() + Item::from_parts(bevel_algorithm(element, transform, distance), attributes) } #[node_macro::node(category("Vector: Modifier"), path(core_types::vector))] @@ -3273,6 +3213,10 @@ mod test { List::new_from_element(Vector::from_bezpath(bezpath)) } + fn vector_item_from_bezpath(bezpath: BezPath) -> Item { + Item::new_from_element(Vector::from_bezpath(bezpath)) + } + fn create_vector_item(bezpath: BezPath, transform: DAffine2) -> Item { let mut row = Vector::default(); row.append_bezpath(bezpath); @@ -3342,8 +3286,8 @@ mod test { #[tokio::test] async fn sample_polyline() { let path = BezPath::from_vec(vec![PathEl::MoveTo(Point::ZERO), PathEl::CurveTo(Point::ZERO, Point::new(100., 0.), Point::new(100., 0.))]); - let sample_polyline = super::sample_polyline(Footprint::default(), vector_node_from_bezpath(path), PointSpacingType::Separation, 30., 0, 0., 0., false).await; - let sample_polyline = sample_polyline.element(0).unwrap(); + let sample_polyline = super::sample_polyline(Footprint::default(), vector_item_from_bezpath(path), PointSpacingType::Separation, 30., 0, 0., 0., false).await; + let sample_polyline = sample_polyline.element(); assert_eq!(sample_polyline.point_domain.positions().len(), 4); for (pos, expected) in sample_polyline.point_domain.positions().iter().zip([DVec2::X * 0., DVec2::X * 30., DVec2::X * 60., DVec2::X * 90.]) { assert!(pos.distance(expected) < 1e-3, "Expected {expected} found {pos}"); @@ -3352,8 +3296,8 @@ mod test { #[tokio::test] async fn sample_polyline_adaptive_spacing() { let path = BezPath::from_vec(vec![PathEl::MoveTo(Point::ZERO), PathEl::CurveTo(Point::ZERO, Point::new(100., 0.), Point::new(100., 0.))]); - let sample_polyline = super::sample_polyline(Footprint::default(), vector_node_from_bezpath(path), PointSpacingType::Separation, 18., 0, 45., 10., true).await; - let sample_polyline = sample_polyline.element(0).unwrap(); + let sample_polyline = super::sample_polyline(Footprint::default(), vector_item_from_bezpath(path), PointSpacingType::Separation, 18., 0, 45., 10., true).await; + let sample_polyline = sample_polyline.element(); assert_eq!(sample_polyline.point_domain.positions().len(), 4); for (pos, expected) in sample_polyline.point_domain.positions().iter().zip([DVec2::X * 45., DVec2::X * 60., DVec2::X * 75., DVec2::X * 90.]) { assert!(pos.distance(expected) < 1e-3, "Expected {expected} found {pos}"); @@ -3363,12 +3307,12 @@ mod test { async fn poisson() { let poisson_points = super::scatter_points( Footprint::default(), - vector_node_from_bezpath(Ellipse::from_rect(Rect::new(-50., -50., 50., 50.)).to_path(DEFAULT_ACCURACY)), + vector_item_from_bezpath(Ellipse::from_rect(Rect::new(-50., -50., 50., 50.)).to_path(DEFAULT_ACCURACY)), 10. * std::f64::consts::SQRT_2, 0, ) .await; - let poisson_points = poisson_points.element(0).unwrap(); + let poisson_points = poisson_points.element(); assert!( (20..=40).contains(&poisson_points.point_domain.positions().len()), "actual len {}", @@ -3392,8 +3336,8 @@ mod test { } #[tokio::test] async fn spline() { - let spline = super::spline(Footprint::default(), vector_node_from_bezpath(Rect::new(0., 0., 100., 100.).to_path(DEFAULT_ACCURACY))).await; - let spline = spline.element(0).unwrap(); + let spline = super::spline(Footprint::default(), vector_item_from_bezpath(Rect::new(0., 0., 100., 100.).to_path(DEFAULT_ACCURACY))).await; + let spline = spline.element(); assert_eq!(spline.stroke_bezpath_iter().count(), 1); assert_eq!(spline.point_domain.positions(), &[DVec2::ZERO, DVec2::new(100., 0.), DVec2::new(100., 100.), DVec2::new(0., 100.)]); } @@ -3463,8 +3407,8 @@ mod test { #[tokio::test] async fn bevel_rect() { let source = Rect::new(0., 0., 100., 100.).to_path(DEFAULT_ACCURACY); - let beveled = super::bevel(Footprint::default(), vector_node_from_bezpath(source), 2_f64.sqrt() * 10.); - let beveled = beveled.element(0).unwrap(); + let beveled = super::bevel(Footprint::default(), vector_item_from_bezpath(source), 2_f64.sqrt() * 10.); + let beveled = beveled.element(); assert_eq!(beveled.point_domain.positions().len(), 8); assert_eq!(beveled.segment_domain.ids().len(), 8); @@ -3491,8 +3435,8 @@ mod test { source.line_to(Point::ZERO); source.push(curve.as_path_el()); - let beveled = super::bevel((), vector_node_from_bezpath(source), 2_f64.sqrt() * 10.); - let beveled = beveled.element(0).unwrap(); + let beveled = super::bevel((), vector_item_from_bezpath(source), 2_f64.sqrt() * 10.); + let beveled = beveled.element(); assert_eq!(beveled.point_domain.positions().len(), 4); assert_eq!(beveled.segment_domain.ids().len(), 3); @@ -3520,8 +3464,8 @@ mod test { vector_list.set_attribute(ATTR_TRANSFORM, 0, DAffine2::from_scale_angle_translation(DVec2::splat(10.), 1., DVec2::new(99., 77.))); - let beveled = super::bevel((), List::new_from_element(vector), 2_f64.sqrt() * 10.); - let beveled = beveled.element(0).unwrap(); + let beveled = super::bevel((), Item::new_from_element(vector), 2_f64.sqrt() * 10.); + let beveled = beveled.element(); assert_eq!(beveled.point_domain.positions().len(), 4); assert_eq!(beveled.segment_domain.ids().len(), 3); @@ -3543,8 +3487,8 @@ mod test { source.line_to(Point::new(100., 100.)); source.line_to(Point::new(0., 100.)); - let beveled = super::bevel(Footprint::default(), vector_node_from_bezpath(source), 999.); - let beveled = beveled.element(0).unwrap(); + let beveled = super::bevel(Footprint::default(), vector_item_from_bezpath(source), 999.); + let beveled = beveled.element(); assert_eq!(beveled.point_domain.positions().len(), 6); assert_eq!(beveled.segment_domain.ids().len(), 5); @@ -3567,8 +3511,8 @@ mod test { let subpath = BezPath::from_path_segments([line, point, curve].into_iter()); - let beveled_list = super::bevel(Footprint::default(), vector_node_from_bezpath(subpath), 5.); - let beveled = beveled_list.element(0).unwrap(); + let beveled_item = super::bevel(Footprint::default(), vector_item_from_bezpath(subpath), 5.); + let beveled = beveled_item.element(); assert_eq!(beveled.point_domain.positions().len(), 6); assert_eq!(beveled.segment_domain.ids().len(), 5); From 0d781fe9e1e8277b2c3546f2efd3c1885dfd9d5a Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Fri, 3 Jul 2026 14:31:41 -0700 Subject: [PATCH 010/165] Migrate Sample Image, Extend Image to Bounds, and Dehaze to Item element-wise kernels --- node-graph/nodes/brush/src/brush.rs | 7 +- node-graph/nodes/raster/src/dehaze.rs | 51 +++--- node-graph/nodes/raster/src/std_nodes.rs | 190 +++++++++++------------ 3 files changed, 115 insertions(+), 133 deletions(-) diff --git a/node-graph/nodes/brush/src/brush.rs b/node-graph/nodes/brush/src/brush.rs index 53b0ea0052..2d0349be8b 100644 --- a/node-graph/nodes/brush/src/brush.rs +++ b/node-graph/nodes/brush/src/brush.rs @@ -223,10 +223,7 @@ async fn brush( let mut brush_plan = cache.compute_brush_plan(list_item, &draw_strokes); - // TODO: Find a way to handle more than one item - let Some(mut actual_image) = extend_image_to_bounds((), List::new_from_item(brush_plan.background), background_bounds).into_iter().next() else { - return List::new(); - }; + let mut actual_image = extend_image_to_bounds((), brush_plan.background, background_bounds); let final_stroke_idx = brush_plan.strokes.len().saturating_sub(1); for (idx, stroke) in brush_plan.strokes.into_iter().enumerate() { @@ -263,7 +260,7 @@ async fn brush( ); let blit_target = if idx == 0 { let target = core::mem::take(&mut brush_plan.first_stroke_texture); - extend_image_to_bounds((), List::new_from_item(target), stroke_to_layer) + List::new_from_item(extend_image_to_bounds((), target, stroke_to_layer)) } else { empty_image((), stroke_to_layer, List::new_from_element(Color::TRANSPARENT)) // EmptyImageNode::new(CopiedNode::new(stroke_to_layer), CopiedNode::new(Color::TRANSPARENT)).eval(()) diff --git a/node-graph/nodes/raster/src/dehaze.rs b/node-graph/nodes/raster/src/dehaze.rs index b236dafa52..9abdd31f70 100644 --- a/node-graph/nodes/raster/src/dehaze.rs +++ b/node-graph/nodes/raster/src/dehaze.rs @@ -1,5 +1,5 @@ use core_types::context::Ctx; -use core_types::list::List; +use core_types::list::Item; use core_types::registry::types::Percentage; use image::{DynamicImage, GenericImage, GenericImageView, GrayImage, ImageBuffer, Luma, Rgba, RgbaImage}; use ndarray::{Array2, ArrayBase, Dim, OwnedRepr}; @@ -8,33 +8,28 @@ use raster_types::{CPU, Raster}; use std::cmp::{max, min}; #[node_macro::node(category("Raster: Filter"))] -async fn dehaze(_: impl Ctx, image_frame: List>, strength: Percentage) -> List> { - image_frame - .into_iter() - .map(|mut row| { - let image = std::mem::replace(row.element_mut(), Raster::new_cpu(Image::default())); - // Prepare the image data for processing - let image_data = bytemuck::cast_vec(image.data.clone()); - let image_buffer = image::Rgba32FImage::from_raw(image.width, image.height, image_data).expect("Failed to convert internal image format into image-rs data type."); - let dynamic_image: DynamicImage = image_buffer.into(); - - // Run the dehaze algorithm - let dehazed_dynamic_image = dehaze_image(dynamic_image, strength / 100.); - - // Prepare the image data for returning - let buffer = dehazed_dynamic_image.to_rgba32f().into_raw(); - let color_vec = bytemuck::cast_vec(buffer); - let dehazed_image = Image { - width: image.width, - height: image.height, - data: color_vec, - base64_string: None, - }; - - *row.element_mut() = Raster::new_cpu(dehazed_image); - row - }) - .collect() +async fn dehaze(_: impl Ctx, image_frame: Item>, strength: Percentage) -> Item> { + let (image, attributes) = image_frame.into_parts(); + + // Prepare the image data for processing + let image_data = bytemuck::cast_vec(image.data.clone()); + let image_buffer = image::Rgba32FImage::from_raw(image.width, image.height, image_data).expect("Failed to convert internal image format into image-rs data type."); + let dynamic_image: DynamicImage = image_buffer.into(); + + // Run the dehaze algorithm + let dehazed_dynamic_image = dehaze_image(dynamic_image, strength / 100.); + + // Prepare the image data for returning + let buffer = dehazed_dynamic_image.to_rgba32f().into_raw(); + let color_vec = bytemuck::cast_vec(buffer); + let dehazed_image = Image { + width: image.width, + height: image.height, + data: color_vec, + base64_string: None, + }; + + Item::from_parts(Raster::new_cpu(dehazed_image), attributes) } // There is no real point in modifying these values because they do not change the final result all that much. diff --git a/node-graph/nodes/raster/src/std_nodes.rs b/node-graph/nodes/raster/src/std_nodes.rs index 6ff551843d..7ec898263d 100644 --- a/node-graph/nodes/raster/src/std_nodes.rs +++ b/node-graph/nodes/raster/src/std_nodes.rs @@ -31,67 +31,63 @@ impl From for Error { } #[node_macro::node(category("Debug"))] -pub fn sample_image(ctx: impl ExtractFootprint + Clone + Send, image_frame: List>) -> List> { - image_frame - .into_iter() - .filter_map(|row| { - let image_frame_transform: DAffine2 = row.attribute_cloned_or_default(ATTR_TRANSFORM); - let (image, mut attributes) = row.into_parts(); - - // Resize the image using the image crate - let data = bytemuck::cast_vec(image.data.clone()); - - let footprint = ctx.footprint(); - let viewport_bounds = footprint.viewport_bounds_in_local_space(); - let image_bounds = Bbox::from_transform(image_frame_transform).to_axis_aligned_bbox(); - let intersection = viewport_bounds.intersect(&image_bounds); - let image_size = DAffine2::from_scale(DVec2::new(image.width as f64, image.height as f64)); - let size = intersection.size(); - let size_px = image_size.transform_vector2(size).as_uvec2(); - - // If the image would not be visible, add nothing. - if size.x <= 0. || size.y <= 0. { - return None; - } +pub fn sample_image(ctx: impl ExtractFootprint + Clone + Send, image_frame: Item>) -> Item> { + let image_frame_transform: DAffine2 = image_frame.attribute_cloned_or_default(ATTR_TRANSFORM); - let image_buffer = ::image::Rgba32FImage::from_raw(image.width, image.height, data).expect("Failed to convert internal image format into image-rs data type."); - - let dynamic_image: ::image::DynamicImage = image_buffer.into(); - let offset = (intersection.start - image_bounds.start).max(DVec2::ZERO); - let offset_px = image_size.transform_vector2(offset).as_uvec2(); - let cropped = dynamic_image.crop_imm(offset_px.x, offset_px.y, size_px.x, size_px.y); - - let viewport_resolution_x = footprint.transform.transform_vector2(DVec2::X * size.x).length(); - let viewport_resolution_y = footprint.transform.transform_vector2(DVec2::Y * size.y).length(); - let mut new_width = size_px.x; - let mut new_height = size_px.y; - - // Only downscale the image for now - let resized = if new_width < image.width || new_height < image.height { - new_width = viewport_resolution_x as u32; - new_height = viewport_resolution_y as u32; - // TODO: choose filter based on quality requirements - cropped.resize_exact(new_width, new_height, ::image::imageops::Triangle) - } else { - cropped - }; - let buffer = resized.to_rgba32f(); - let buffer = buffer.into_raw(); - let vec = bytemuck::cast_vec(buffer); - let image = Image { - width: new_width, - height: new_height, - data: vec, - base64_string: None, - }; - // we need to adjust the offset if we truncate the offset calculation - - let new_transform = image_frame_transform * DAffine2::from_translation(offset) * DAffine2::from_scale(size); - attributes.insert(ATTR_TRANSFORM, new_transform); + let footprint = ctx.footprint(); + let viewport_bounds = footprint.viewport_bounds_in_local_space(); + let image_bounds = Bbox::from_transform(image_frame_transform).to_axis_aligned_bbox(); + let intersection = viewport_bounds.intersect(&image_bounds); + let size = intersection.size(); - Some(Item::from_parts(Raster::new_cpu(image), attributes)) - }) - .collect() + // If the image would not be visible, return it unchanged + if size.x <= 0. || size.y <= 0. { + return image_frame; + } + + let (image, mut attributes) = image_frame.into_parts(); + + // Resize the image using the image crate + let data = bytemuck::cast_vec(image.data.clone()); + let image_size = DAffine2::from_scale(DVec2::new(image.width as f64, image.height as f64)); + let size_px = image_size.transform_vector2(size).as_uvec2(); + + let image_buffer = ::image::Rgba32FImage::from_raw(image.width, image.height, data).expect("Failed to convert internal image format into image-rs data type."); + + let dynamic_image: ::image::DynamicImage = image_buffer.into(); + let offset = (intersection.start - image_bounds.start).max(DVec2::ZERO); + let offset_px = image_size.transform_vector2(offset).as_uvec2(); + let cropped = dynamic_image.crop_imm(offset_px.x, offset_px.y, size_px.x, size_px.y); + + let viewport_resolution_x = footprint.transform.transform_vector2(DVec2::X * size.x).length(); + let viewport_resolution_y = footprint.transform.transform_vector2(DVec2::Y * size.y).length(); + let mut new_width = size_px.x; + let mut new_height = size_px.y; + + // Only downscale the image for now + let resized = if new_width < image.width || new_height < image.height { + new_width = viewport_resolution_x as u32; + new_height = viewport_resolution_y as u32; + // TODO: choose filter based on quality requirements + cropped.resize_exact(new_width, new_height, ::image::imageops::Triangle) + } else { + cropped + }; + let buffer = resized.to_rgba32f(); + let buffer = buffer.into_raw(); + let vec = bytemuck::cast_vec(buffer); + let image = Image { + width: new_width, + height: new_height, + data: vec, + base64_string: None, + }; + // we need to adjust the offset if we truncate the offset calculation + + let new_transform = image_frame_transform * DAffine2::from_translation(offset) * DAffine2::from_scale(size); + attributes.insert(ATTR_TRANSFORM, new_transform); + + Item::from_parts(Raster::new_cpu(image), attributes) } #[node_macro::node(category("Raster: Channels"))] @@ -227,51 +223,45 @@ pub fn mask( } #[node_macro::node(category(""))] -pub fn extend_image_to_bounds(_: impl Ctx, image: List>, bounds: DAffine2) -> List> { - image - .into_iter() - .map(|mut row| { - let row_transform: DAffine2 = row.attribute_cloned_or_default(ATTR_TRANSFORM); - let image_aabb = Bbox::unit().affine_transform(row_transform).to_axis_aligned_bbox(); - let bounds_aabb = Bbox::unit().affine_transform(bounds.transform()).to_axis_aligned_bbox(); - if image_aabb.contains(bounds_aabb.start) && image_aabb.contains(bounds_aabb.end) { - return row; - } +pub fn extend_image_to_bounds(_: impl Ctx, image: Item>, bounds: DAffine2) -> Item> { + let image_transform: DAffine2 = image.attribute_cloned_or_default(ATTR_TRANSFORM); + let image_aabb = Bbox::unit().affine_transform(image_transform).to_axis_aligned_bbox(); + let bounds_aabb = Bbox::unit().affine_transform(bounds.transform()).to_axis_aligned_bbox(); + if image_aabb.contains(bounds_aabb.start) && image_aabb.contains(bounds_aabb.end) { + return image; + } - let image_data = &row.element().data; - let (image_width, image_height) = (row.element().width, row.element().height); - if image_width == 0 || image_height == 0 { - return empty_image((), bounds, List::new_from_element(Color::TRANSPARENT)).into_iter().next().unwrap(); - } + let (image, mut attributes) = image.into_parts(); + let (image_width, image_height) = (image.width, image.height); + if image_width == 0 || image_height == 0 { + return empty_image((), bounds, List::new_from_element(Color::TRANSPARENT)).into_iter().next().unwrap(); + } - let orig_image_scale = DVec2::new(image_width as f64, image_height as f64); - let layer_to_image_space = DAffine2::from_scale(orig_image_scale) * row_transform.inverse(); - let bounds_in_image_space = Bbox::unit().affine_transform(layer_to_image_space * bounds).to_axis_aligned_bbox(); - - let new_start = bounds_in_image_space.start.floor().min(DVec2::ZERO); - let new_end = bounds_in_image_space.end.ceil().max(orig_image_scale); - let new_scale = new_end - new_start; - - // Copy over original image into enlarged image. - let mut new_image = Image::new(new_scale.x as u32, new_scale.y as u32, Color::TRANSPARENT); - let offset_in_new_image = (-new_start).as_uvec2(); - for y in 0..image_height { - let old_start = y * image_width; - let new_start = (y + offset_in_new_image.y) * new_image.width + offset_in_new_image.x; - let old_row = &image_data[old_start as usize..(old_start + image_width) as usize]; - let new_row = &mut new_image.data[new_start as usize..(new_start + image_width) as usize]; - new_row.copy_from_slice(old_row); - } + let orig_image_scale = DVec2::new(image_width as f64, image_height as f64); + let layer_to_image_space = DAffine2::from_scale(orig_image_scale) * image_transform.inverse(); + let bounds_in_image_space = Bbox::unit().affine_transform(layer_to_image_space * bounds).to_axis_aligned_bbox(); + + let new_start = bounds_in_image_space.start.floor().min(DVec2::ZERO); + let new_end = bounds_in_image_space.end.ceil().max(orig_image_scale); + let new_scale = new_end - new_start; + + // Copy over original image into enlarged image. + let mut new_image = Image::new(new_scale.x as u32, new_scale.y as u32, Color::TRANSPARENT); + let offset_in_new_image = (-new_start).as_uvec2(); + for y in 0..image_height { + let old_start = y * image_width; + let new_start = (y + offset_in_new_image.y) * new_image.width + offset_in_new_image.x; + let old_row = &image.data[old_start as usize..(old_start + image_width) as usize]; + let new_row = &mut new_image.data[new_start as usize..(new_start + image_width) as usize]; + new_row.copy_from_slice(old_row); + } - // Compute new transform. - // let layer_to_new_texture_space = (DAffine2::from_scale(1. / new_scale) * DAffine2::from_translation(new_start) * layer_to_image_space).inverse(); - let new_texture_to_layer_space = row_transform * DAffine2::from_scale(1. / orig_image_scale) * DAffine2::from_translation(new_start) * DAffine2::from_scale(new_scale); + // Compute new transform. + // let layer_to_new_texture_space = (DAffine2::from_scale(1. / new_scale) * DAffine2::from_translation(new_start) * layer_to_image_space).inverse(); + let new_texture_to_layer_space = image_transform * DAffine2::from_scale(1. / orig_image_scale) * DAffine2::from_translation(new_start) * DAffine2::from_scale(new_scale); - *row.element_mut() = Raster::new_cpu(new_image); - row.set_attribute(ATTR_TRANSFORM, new_texture_to_layer_space); - row - }) - .collect() + attributes.insert(ATTR_TRANSFORM, new_texture_to_layer_space); + Item::from_parts(Raster::new_cpu(new_image), attributes) } #[node_macro::node(category("Debug"))] From 62c21607f4a734cc71543cf4fb5ac90ddfc48232 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Fri, 3 Jul 2026 14:40:12 -0700 Subject: [PATCH 011/165] Fix bevel_with_transform test to actually exercise the transform attribute --- node-graph/nodes/vector/src/vector_nodes.rs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/node-graph/nodes/vector/src/vector_nodes.rs b/node-graph/nodes/vector/src/vector_nodes.rs index 08d37dc3ee..7bfba16586 100644 --- a/node-graph/nodes/vector/src/vector_nodes.rs +++ b/node-graph/nodes/vector/src/vector_nodes.rs @@ -3459,12 +3459,10 @@ mod test { source.line_to(Point::ZERO); source.push(curve.as_path_el()); - let vector = Vector::from_bezpath(source); - let mut vector_list = List::new_from_element(vector.clone()); + let transform = DAffine2::from_scale_angle_translation(DVec2::splat(10.), 1., DVec2::new(99., 77.)); + let vector_item = Item::new_from_element(Vector::from_bezpath(source)).with_attribute(ATTR_TRANSFORM, transform); - vector_list.set_attribute(ATTR_TRANSFORM, 0, DAffine2::from_scale_angle_translation(DVec2::splat(10.), 1., DVec2::new(99., 77.))); - - let beveled = super::bevel((), Item::new_from_element(vector), 2_f64.sqrt() * 10.); + let beveled = super::bevel((), vector_item, 2_f64.sqrt() * 100.); let beveled = beveled.element(); assert_eq!(beveled.point_domain.positions().len(), 4); From 2c35ac28df11c534813aca796e002c0fb5d1ad23 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Fri, 3 Jul 2026 14:54:39 -0700 Subject: [PATCH 012/165] Implement From for Item --- node-graph/libraries/core-types/src/list.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/node-graph/libraries/core-types/src/list.rs b/node-graph/libraries/core-types/src/list.rs index eccc49e151..a1dd08e6de 100644 --- a/node-graph/libraries/core-types/src/list.rs +++ b/node-graph/libraries/core-types/src/list.rs @@ -1386,6 +1386,12 @@ impl Item { } } +impl From for Item { + fn from(element: T) -> Self { + Self::new_from_element(element) + } +} + unsafe impl StaticType for Item { type Static = Item; } From 4a924666d616192750c7730e3802f522f6f237d6 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Fri, 3 Jul 2026 14:54:39 -0700 Subject: [PATCH 013/165] Register PromoteNode rank adapters wrapping bare values into Item wires --- .../interpreted-executor/src/node_registry.rs | 57 ++++++++++++++++++- 1 file changed, 55 insertions(+), 2 deletions(-) diff --git a/node-graph/interpreted-executor/src/node_registry.rs b/node-graph/interpreted-executor/src/node_registry.rs index d9ee2e6ad2..9eebd0f973 100644 --- a/node-graph/interpreted-executor/src/node_registry.rs +++ b/node-graph/interpreted-executor/src/node_registry.rs @@ -8,7 +8,7 @@ use graphene_std::any::DynAnyNode; use graphene_std::application_io::Texture; use graphene_std::brush::brush_stroke::BrushStroke; use graphene_std::gradient::GradientStops; -use graphene_std::list::{AttributeDyn, AttributeValueDyn, List, ListDyn}; +use graphene_std::list::{AttributeDyn, AttributeValueDyn, Item, List, ListDyn}; #[cfg(target_family = "wasm")] use graphene_std::platform_application_io::canvas_utils::CanvasHandle; #[cfg(feature = "gpu")] @@ -291,6 +291,59 @@ fn node_registry() -> HashMap, input: Context, fn_params: [Context => Option<&wgpu_executor::WgpuExecutor>]), async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => wgpu_executor::WgpuPipelineCache]), ]; + // A rank promotion adapter registered per element type: a bare value wraps into an `Item`, while `Item` and `List` wires pass through unchanged + macro_rules! promote_node { + (element: $element:ty) => {{ + let entries: Vec<(ProtoNodeIdentifier, NodeConstructor, NodeIOTypes)> = vec![ + promote_node!(from: $element, to: Item<$element>, element: $element), + promote_node!(from: Item<$element>, to: Item<$element>, element: $element), + promote_node!(from: List<$element>, to: List<$element>, element: $element), + ]; + entries + }}; + (from: $from:ty, to: $to:ty, element: $element:ty) => { + ( + ProtoNodeIdentifier::new(concat!["graphene_core::ops::PromoteNode<", stringify!($element), ">"]), + |mut args| { + Box::pin(async move { + let node = graphene_std::ops::IntoNode::new( + graphene_std::any::downcast_node::(args.pop().unwrap()), + graphene_std::any::FutureWrapperNode::new(graphene_std::value::ClonedNode::new(std::marker::PhantomData::<$to>)), + ); + let any: DynAnyNode = graphene_std::any::DynAnyNode::new(node); + Box::new(any) as TypeErasedBox + }) + }, + { + let node = graphene_std::ops::IntoNode::new( + graphene_std::any::PanicNode:: + Send>>>::new(), + graphene_std::any::FutureWrapperNode::new(graphene_std::value::ClonedNode::new(std::marker::PhantomData::<$to>)), + ); + let params = vec![fn_type_fut!(Context, $from)]; + let node_io = NodeIO::<'_, Context>::to_async_node_io(&node, params); + node_io + }, + ) + }; + } + // ============= + // PROMOTE NODES + // ============= + node_types.extend( + [ + promote_node!(element: Vector), + promote_node!(element: Raster), + promote_node!(element: Graphic), + promote_node!(element: Color), + promote_node!(element: GradientStops), + promote_node!(element: String), + promote_node!(element: f64), + promote_node!(element: DVec2), + promote_node!(element: bool), + ] + .into_iter() + .flatten(), + ); // ============= // CONVERT NODES // ============= @@ -332,7 +385,7 @@ fn node_registry() -> HashMap Date: Fri, 3 Jul 2026 14:54:39 -0700 Subject: [PATCH 014/165] Insert PromoteNode adapters for Item/List wire pair fields in the preprocessor --- .../src/dynamic_executor.rs | 21 ++++++++++++ node-graph/preprocessor/src/lib.rs | 34 ++++++++++++++++++- 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/node-graph/interpreted-executor/src/dynamic_executor.rs b/node-graph/interpreted-executor/src/dynamic_executor.rs index 6dfe57e176..9a56b2e6ae 100644 --- a/node-graph/interpreted-executor/src/dynamic_executor.rs +++ b/node-graph/interpreted-executor/src/dynamic_executor.rs @@ -484,6 +484,27 @@ mod test { assert!(wrong_type.is_none(), "An Item wire should not downcast as a List"); } + #[test] + fn bare_value_promotes_to_item_wire() { + let value_node = ProtoNode::value(ConstructionArgs::Value(TaggedValue::F64(3.).into()), vec![NodeId(0)]); + + let mut promote_node = ProtoNode::value(ConstructionArgs::Nodes(vec![NodeId(0)]), vec![NodeId(1)]); + promote_node.identifier = ProtoNodeIdentifier::new("graphene_core::ops::PromoteNode"); + + let network = ProtoNetwork { + inputs: vec![], + output: NodeId(1), + nodes: vec![(NodeId(0), value_node), (NodeId(1), promote_node)], + }; + let mut typing_context = TypingContext::new(&crate::node_registry::NODE_REGISTRY); + typing_context.update(&network).expect("A bare f64 should resolve the promotion variant"); + let tree = futures::executor::block_on(BorrowTree::new(network, &typing_context)).expect("The promotion constructor should instantiate"); + + let context: Context = None; + let result: Option> = futures::executor::block_on(tree.eval(NodeId(1), context)); + assert_eq!(result.map(|item| *item.element()), Some(3.), "The bare value should arrive wrapped as an Item"); + } + #[test] fn list_wire_variant_resolves_and_executes() { let tree = compile_bounding_box_network(TaggedValue::TypeDefault(descriptor!(List))); diff --git a/node-graph/preprocessor/src/lib.rs b/node-graph/preprocessor/src/lib.rs index 71910312da..9c17e34574 100644 --- a/node-graph/preprocessor/src/lib.rs +++ b/node-graph/preprocessor/src/lib.rs @@ -143,9 +143,41 @@ impl Preprocessor { .take(input_count) .enumerate() .map(|(i, inputs)| { + // A field registering the Item/List wire pair gets a rank promotion adapter instead of a typed conversion + if inputs.len() != 1 + && let Some(list_input) = collapse_item_list_pair(inputs) + { + let element_name = { + let name = list_input.nested_type().identifier_name(); + name.strip_prefix("List<").and_then(|rest| rest.strip_suffix('>')).unwrap_or(&name).to_string() + }; + let promote_node_identifier = ProtoNodeIdentifier::with_owned_string(format!("graphene_core::ops::PromoteNode<{element_name}>")); + + let document_node = if into_node_registry.keys().any(|ident| ident.as_str() == promote_node_identifier.as_str()) { + generated_nodes += 1; + let mut original_location = OriginalLocation::default(); + original_location.auto_convert_index = Some(i); + DocumentNode { + inputs: vec![NodeInput::import(generic!(X), i)], + implementation: DocumentNodeImplementation::ProtoNode(promote_node_identifier), + visible: true, + original_location, + ..Default::default() + } + } else { + DocumentNode { + inputs: vec![NodeInput::import(generic!(X), i)], + implementation: DocumentNodeImplementation::ProtoNode(passthrough_node.clone()), + visible: false, + ..Default::default() + } + }; + return (NodeId(i as u64), document_node); + } + let single_wire_type = match inputs.len() { 1 => inputs.iter().next(), - _ => collapse_item_list_pair(inputs), + _ => None, }; ( NodeId(i as u64), From 6e1fb6b5f0e4d4fbb9c6787610defa5b8e278c08 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Fri, 3 Jul 2026 15:09:07 -0700 Subject: [PATCH 015/165] Define a real promote node backing the PromoteNode registry identifiers --- node-graph/interpreted-executor/src/node_registry.rs | 4 ++-- node-graph/nodes/gcore/src/ops.rs | 6 ++++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/node-graph/interpreted-executor/src/node_registry.rs b/node-graph/interpreted-executor/src/node_registry.rs index 9eebd0f973..0353aa03e4 100644 --- a/node-graph/interpreted-executor/src/node_registry.rs +++ b/node-graph/interpreted-executor/src/node_registry.rs @@ -306,7 +306,7 @@ fn node_registry() -> HashMap"]), |mut args| { Box::pin(async move { - let node = graphene_std::ops::IntoNode::new( + let node = graphene_std::ops::PromoteNode::new( graphene_std::any::downcast_node::(args.pop().unwrap()), graphene_std::any::FutureWrapperNode::new(graphene_std::value::ClonedNode::new(std::marker::PhantomData::<$to>)), ); @@ -315,7 +315,7 @@ fn node_registry() -> HashMap + Send>>>::new(), graphene_std::any::FutureWrapperNode::new(graphene_std::value::ClonedNode::new(std::marker::PhantomData::<$to>)), ); diff --git a/node-graph/nodes/gcore/src/ops.rs b/node-graph/nodes/gcore/src/ops.rs index 378d73569c..d4ef3f3cab 100644 --- a/node-graph/nodes/gcore/src/ops.rs +++ b/node-graph/nodes/gcore/src/ops.rs @@ -15,6 +15,12 @@ fn into<'i, T: 'i + Send + Into, O: 'i + Send>(_: impl Ctx, value: T, _out_ty value.into() } +/// Wraps a bare value onto a ranked wire as an `Item`, or passes an already ranked `Item` or `List` wire through unchanged. +#[node_macro::node(category(""), skip_impl)] +fn promote<'i, T: 'i + Send + Into, O: 'i + Send>(_: impl Ctx, value: T, _out_ty: PhantomData) -> O { + value.into() +} + #[node_macro::node(category(""), skip_impl)] async fn convert<'i, T: 'i + Send + Convert, O: 'i + Send, C: 'i + Send>(ctx: impl Ctx + ExtractFootprint, value: T, converter: C, _out_ty: PhantomData) -> O { value.convert(*ctx.try_footprint().unwrap_or(&Footprint::DEFAULT), converter).await From cb17c05c946d1e5bc94a9ec19d4446c587cd9c59 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Fri, 3 Jul 2026 15:49:31 -0700 Subject: [PATCH 016/165] Zip ranked Item connectors by frame slot in the mapped element-wise variant --- node-graph/node-macro/src/codegen.rs | 69 ++++++++++++++++--------- node-graph/node-macro/src/validation.rs | 18 +++++-- 2 files changed, 57 insertions(+), 30 deletions(-) diff --git a/node-graph/node-macro/src/codegen.rs b/node-graph/node-macro/src/codegen.rs index 37a397dcab..31cf0b16e1 100644 --- a/node-graph/node-macro/src/codegen.rs +++ b/node-graph/node-macro/src/codegen.rs @@ -302,15 +302,11 @@ pub(crate) fn generate_node_code(crate_ident: &CrateIdent, parsed: &ParsedNodeFn .iter() .zip(node_generics.iter()) .zip(future_idents.iter()) - .enumerate() - .map(|(index, ((field, name), fut_ident))| match (&field.ty, *is_async) { + .map(|((field, name), fut_ident)| match (&field.ty, *is_async) { (ParsedFieldType::Regular(RegularParsedField { ty, .. }), _) => { - let ty = match (index, primary_wire) { - // An `Item`-declared primary contributes its element type to the wire wrapping - (0, Some(wrap)) => { - let element_ty = peel_item(ty).unwrap_or_else(|| ty.clone()); - wrap.apply(core_types, &element_ty) - } + let ty = match (peel_item(ty), primary_wire) { + // An `Item`-declared connector contributes its element type to the wire wrapping + (Some(element_ty), Some(wrap)) => wrap.apply(core_types, &element_ty), _ => ty.clone(), }; let all_lifetime_ty = substitute_lifetimes(ty.clone(), "all"); @@ -348,12 +344,14 @@ pub(crate) fn generate_node_code(crate_ident: &CrateIdent, parsed: &ParsedNodeFn }; let struct_where_clause = make_struct_where_clause(build_field_clauses(element_wise.then_some(WireWrapper::Item)), Vec::new()); - // The mapped variant clones every non-primary parameter once per row, so those parameter types must be Clone + // The mapped variant clones bare parameters and clones ranked connectors' items per frame slot, so both need Clone let param_clone_clauses: Vec = regular_fields .iter() - .skip(1) .filter_map(|field| match &field.ty { - ParsedFieldType::Regular(RegularParsedField { ty, .. }) => Some(quote!(#ty: Clone)), + ParsedFieldType::Regular(RegularParsedField { ty, .. }) => match peel_item(ty) { + Some(element_ty) => Some(quote!(#element_ty: Clone)), + None => Some(quote!(#ty: Clone)), + }, ParsedFieldType::Node(_) => None, }) .collect(); @@ -419,22 +417,35 @@ pub(crate) fn generate_node_code(crate_ident: &CrateIdent, parsed: &ParsedNodeFn #serialize_impl }; - // The mapped variant calls the kernel once per item of the primary input's list, broadcasting the other parameters by clone + // The mapped variant zips every ranked connector by frame slot (longest-list, last-element repeats), broadcasting bare parameters by clone let mapped_eval_impl = element_wise.then(|| { - let primary_name = ®ular_field_names[0]; - let per_row_args: Vec<_> = regular_fields + let ranked_names: Vec<_> = regular_fields .iter() - .enumerate() - .map(|(index, field)| { + .filter(|field| matches!(&field.ty, ParsedFieldType::Regular(RegularParsedField { ty, .. }) if peel_item(ty).is_some())) + .map(|field| &field.pat_ident.ident) + .collect(); + + let per_slot_args: Vec<_> = regular_fields + .iter() + .map(|field| { let name = &field.pat_ident.ident; - match (index, &field.ty) { - (0, _) => quote!(__item), - (_, ParsedFieldType::Regular(_)) => quote!(#name.clone()), - (_, ParsedFieldType::Node(_)) => quote!(#name), + match &field.ty { + ParsedFieldType::Regular(RegularParsedField { ty, .. }) if peel_item(ty).is_some() => quote! { + #name.clone_item(__slot_index.min(#name.len() - 1)).expect("A zip slot index is always within bounds") + }, + ParsedFieldType::Regular(_) => quote!(#name.clone()), + ParsedFieldType::Node(_) => quote!(#name), } }) .collect(); + // The frame index is stamped onto each slot's context so lazy connectors can generate uniquely per slot + let has_lazy_connectors = regular_fields.iter().any(|field| matches!(&field.ty, ParsedFieldType::Node(_))); + let slot_context = match has_lazy_connectors { + true => quote!(#core_types::OwnedContextImpl::from(__input.clone()).with_index(__slot_index).into_context()), + false => quote!(__input.clone()), + }; + let list_element_ty = peel_item(output_type).unwrap_or_else(|| output_type.clone()); quote! { @@ -445,9 +456,15 @@ pub(crate) fn generate_node_code(crate_ident: &CrateIdent, parsed: &ParsedNodeFn Box::pin(async move { #eval_prelude - let mut __output = #core_types::list::List::with_capacity(#primary_name.len()); - for __item in #primary_name { - let __result = self::#fn_name(__input.clone() #(, &self.#data_field_names)* #(, #per_row_args)*) #await_keyword; + let __frame_length = [#(#ranked_names.len()),*].into_iter().max().unwrap_or(0); + if [#(#ranked_names.len()),*].into_iter().any(|length| length == 0) { + return #core_types::list::List::new(); + } + + let mut __output = #core_types::list::List::with_capacity(__frame_length); + for __slot_index in 0..__frame_length { + let __slot_context = #slot_context; + let __result = self::#fn_name(__slot_context #(, &self.#data_field_names)* #(, #per_slot_args)*) #await_keyword; __output.push(__result); } @@ -863,8 +880,10 @@ fn generate_register_node_impl(parsed: &ParsedNodeFn, field_names: &[&Ident], st for (j, types) in parameter_types.iter().enumerate() { let field_name = field_names[j]; let (input_type, output_type) = &types[i.min(types.len() - 1)]; - let output_type = match (j, wire) { - (0, Some(wrap)) => { + // Rankedness comes from the field's declared type; its #[implementations(...)] entries are bare element types + let field_is_ranked = matches!(®ular_fields[j].ty, ParsedFieldType::Regular(RegularParsedField { ty, .. }) if peel_item(ty).is_some()); + let output_type = match (field_is_ranked, wire) { + (true, Some(wrap)) => { let element_ty = peel_item(output_type).unwrap_or_else(|| output_type.clone()); wrap.apply(&gcore, &element_ty) } diff --git a/node-graph/node-macro/src/validation.rs b/node-graph/node-macro/src/validation.rs index 2cc98ce819..44590b1f98 100644 --- a/node-graph/node-macro/src/validation.rs +++ b/node-graph/node-macro/src/validation.rs @@ -23,7 +23,11 @@ pub fn validate_node_fn(parsed: &ParsedNodeFn) -> syn::Result<()> { } fn validate_no_item_parameters(parsed: &ParsedNodeFn) { - // The primary input (index 0) may be Item, which declares the node as an element-wise kernel + let primary_is_item = matches!( + parsed.fields.first().map(|field| &field.ty), + Some(ParsedFieldType::Regular(RegularParsedField { ty, .. })) if outer_wrapper_is(ty, "Item") + ); + for field in parsed.fields.iter().skip(1) { let ParsedField { ty: ParsedFieldType::Regular(RegularParsedField { ty, implementations, .. }), @@ -34,14 +38,18 @@ fn validate_no_item_parameters(parsed: &ParsedNodeFn) { continue; }; - if outer_wrapper_is(ty, "Item") || implementations.iter().any(|ty| outer_wrapper_is(ty, "Item")) { + // Ranked parameters join the element-wise frame, so they require an element-wise (Item-primary) node + if outer_wrapper_is(ty, "Item") && !primary_is_item { emit_error!( pat_ident.span(), - "Parameter `{}` must not be typed `Item`.", - pat_ident.ident; - help = "Only the primary input may be typed `Item`, which declares the node as an element-wise kernel."; + "The `Item` parameter `{}` requires the node's primary input to also be typed `Item`", + pat_ident.ident ); } + + if outer_wrapper_is(ty, "Item") && implementations.iter().any(|ty| outer_wrapper_is(ty, "Item") || outer_wrapper_is(ty, "List")) { + emit_error!(pat_ident.span(), "The #[implementations(...)] of the ranked parameter `{}` must be bare element types", pat_ident.ident); + } } } From 445b836a8726ba20f15aa94881ce657767054c78 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Fri, 3 Jul 2026 16:09:08 -0700 Subject: [PATCH 017/165] Register ItemToListNode singleton raise adapters --- .../interpreted-executor/src/node_registry.rs | 41 ++++++++++++++++++- node-graph/libraries/core-types/src/list.rs | 6 +++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/node-graph/interpreted-executor/src/node_registry.rs b/node-graph/interpreted-executor/src/node_registry.rs index 0353aa03e4..3b57f0f58d 100644 --- a/node-graph/interpreted-executor/src/node_registry.rs +++ b/node-graph/interpreted-executor/src/node_registry.rs @@ -326,6 +326,33 @@ fn node_registry() -> HashMap { + ( + ProtoNodeIdentifier::new(concat!["graphene_core::ops::ItemToListNode<", stringify!($element), ">"]), + |mut args| { + Box::pin(async move { + let node = graphene_std::ops::PromoteNode::new( + graphene_std::any::downcast_node::>(args.pop().unwrap()), + graphene_std::any::FutureWrapperNode::new(graphene_std::value::ClonedNode::new(std::marker::PhantomData::>)), + ); + let any: DynAnyNode, _> = graphene_std::any::DynAnyNode::new(node); + Box::new(any) as TypeErasedBox + }) + }, + { + let node = graphene_std::ops::PromoteNode::new( + graphene_std::any::PanicNode::> + Send>>>::new(), + graphene_std::any::FutureWrapperNode::new(graphene_std::value::ClonedNode::new(std::marker::PhantomData::>)), + ); + let params = vec![fn_type_fut!(Context, Item<$element>)]; + let node_io = NodeIO::<'_, Context>::to_async_node_io(&node, params); + node_io + }, + ) + }; + } // ============= // PROMOTE NODES // ============= @@ -344,6 +371,18 @@ fn node_registry() -> HashMap = vec![ + item_to_list_node!(element: Vector), + item_to_list_node!(element: Raster), + item_to_list_node!(element: Graphic), + item_to_list_node!(element: Color), + item_to_list_node!(element: GradientStops), + item_to_list_node!(element: String), + item_to_list_node!(element: f64), + item_to_list_node!(element: DVec2), + item_to_list_node!(element: bool), + ]; + node_types.extend(item_to_list_nodes); // ============= // CONVERT NODES // ============= @@ -385,7 +424,7 @@ fn node_registry() -> HashMap From for Item { } } +impl From> for List { + fn from(item: Item) -> Self { + Self::new_from_item(item) + } +} + unsafe impl StaticType for Item { type Static = Item; } From 0a68bb46b536c6452ee738d03671144563ee8d99 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Fri, 3 Jul 2026 16:09:08 -0700 Subject: [PATCH 018/165] Resolve Item wires against List connectors by inserting promotion adapters at construction --- node-graph/graph-craft/src/proto.rs | 58 +++++++++++++++++++ .../src/dynamic_executor.rs | 37 +++++++++++- 2 files changed, 94 insertions(+), 1 deletion(-) diff --git a/node-graph/graph-craft/src/proto.rs b/node-graph/graph-craft/src/proto.rs index 2fa7eb93a3..c6f07a78bc 100644 --- a/node-graph/graph-craft/src/proto.rs +++ b/node-graph/graph-craft/src/proto.rs @@ -634,6 +634,7 @@ pub struct TypingContext { lookup: Cow<'static, HashMap>>, inferred: HashMap, constructor: HashMap, + promotions: HashMap>, } impl TypingContext { @@ -658,9 +659,20 @@ impl TypingContext { pub fn remove_inference(&mut self, node_id: NodeId) -> Option { self.constructor.remove(&node_id); + self.promotions.remove(&node_id); self.inferred.remove(&node_id) } + /// Returns the input positions of a node which type resolution marked for Item -> List promotion, with each element's simplified name. + pub fn promotions(&self, node_id: NodeId) -> Option<&Vec<(usize, String)>> { + self.promotions.get(&node_id) + } + + /// Looks up the sole constructor registered under an adapter identifier, such as an Item -> List promotion adapter. + pub fn adapter_constructor(&self, identifier: &ProtoNodeIdentifier) -> Option { + self.lookup.get(identifier).and_then(|implementations| implementations.values().next().copied()) + } + /// Returns the node constructor for a given node id. pub fn constructor(&self, node_id: NodeId) -> Option { self.constructor.get(&node_id).copied() @@ -768,6 +780,52 @@ impl TypingContext { match valid_impls.as_slice() { [] => { + // Retry allowing an Item input to feed a List connector, satisfied at construction time by an inserted promotion adapter + fn promotable_element(from: &Type, to: &Type) -> Option { + fn wrapped_name<'a>(ty: &'a Type, wrapper: &str) -> Option<&'a str> { + let Type::Concrete(descriptor) = ty.nested_type() else { return None }; + descriptor.name.strip_prefix("core_types::list::")?.strip_prefix(wrapper)?.strip_prefix('<')?.strip_suffix('>') + } + + let (Type::Fn(from_input, from_output), Type::Fn(to_input, to_output)) = (from, to) else { + return None; + }; + if !valid_type(from_input, to_input) { + return None; + } + + if wrapped_name(from_output, "Item")? != wrapped_name(to_output, "List")? { + return None; + } + let element = to_output.nested_type().identifier_name(); + element.strip_prefix("List<").and_then(|rest| rest.strip_suffix('>')).map(str::to_string) + } + + let mut promotable_matches = impls + .keys() + .filter(|node_io| collect_generics(node_io).is_empty() && valid_type(&node_io.call_argument, call_argument) && inputs.len() == node_io.inputs.len()) + .filter_map(|node_io| { + let mut required_promotions = Vec::new(); + for (index, (provided, expected)) in inputs.iter().zip(node_io.inputs.iter()).enumerate() { + if valid_type(provided, expected) { + continue; + } + required_promotions.push((index, promotable_element(provided, expected)?)); + } + Some((node_io, required_promotions)) + }) + .collect::>(); + + if promotable_matches.len() == 1 { + let (node_io, required_promotions) = promotable_matches.remove(0); + let node_io = node_io.clone(); + + self.inferred.insert(node_id, node_io.clone()); + self.constructor.insert(node_id, impls[&node_io]); + self.promotions.insert(node_id, required_promotions); + return Ok(node_io); + } + let convert_node_index_offset = node.original_location.auto_convert_index.unwrap_or(0); let mut best_errors = usize::MAX; let mut error_inputs = Vec::new(); diff --git a/node-graph/interpreted-executor/src/dynamic_executor.rs b/node-graph/interpreted-executor/src/dynamic_executor.rs index 9a56b2e6ae..046ce1ad37 100644 --- a/node-graph/interpreted-executor/src/dynamic_executor.rs +++ b/node-graph/interpreted-executor/src/dynamic_executor.rs @@ -413,7 +413,20 @@ impl BorrowTree { ConstructionArgs::Inline(_) => unimplemented!("Inline nodes are not supported yet"), ConstructionArgs::Nodes(ids) => { let ids = ids.to_vec(); - let construction_nodes = self.node_deps(&ids); + let mut construction_nodes = self.node_deps(&ids); + + // Wrap arguments the typing pass marked for Item -> List promotion with their adapter node + if let Some(promotions) = typing_context.promotions(id) { + for (argument_index, element_name) in promotions { + let identifier = graph_craft::ProtoNodeIdentifier::with_owned_string(format!("graphene_core::ops::ItemToListNode<{element_name}>")); + let adapter_constructor = typing_context + .adapter_constructor(&identifier) + .ok_or_else(|| vec![GraphError::new(&proto_node, GraphErrorType::NoConstructor)])?; + let adapter = adapter_constructor(vec![construction_nodes[*argument_index].clone()]).await; + construction_nodes[*argument_index] = NodeContainer::new(adapter); + } + } + let constructor = typing_context.constructor(id).ok_or_else(|| vec![GraphError::new(&proto_node, GraphErrorType::NoConstructor)])?; let node = constructor(construction_nodes).await; let node = NodeContainer::new(node); @@ -484,6 +497,28 @@ mod test { assert!(wrong_type.is_none(), "An Item wire should not downcast as a List"); } + #[test] + fn item_wire_promotes_to_list_connector() { + let value_node = ProtoNode::value(ConstructionArgs::Value(TaggedValue::TypeDefault(descriptor!(Item)).into()), vec![NodeId(0)]); + + let mut flatten_node = ProtoNode::value(ConstructionArgs::Nodes(vec![NodeId(0)]), vec![NodeId(1)]); + flatten_node.identifier = ProtoNodeIdentifier::new("graphene_core::vector::FlattenPathNode"); + + let network = ProtoNetwork { + inputs: vec![], + output: NodeId(1), + nodes: vec![(NodeId(0), value_node), (NodeId(1), flatten_node)], + }; + let mut typing_context = TypingContext::new(&crate::node_registry::NODE_REGISTRY); + typing_context.update(&network).expect("An Item wire should resolve a List connector via promotion"); + assert!(typing_context.promotions(NodeId(1)).is_some(), "The typing pass should record the promotion"); + let tree = futures::executor::block_on(BorrowTree::new(network, &typing_context)).expect("The promotion adapter should instantiate"); + + let context: Context = None; + let result: Option> = futures::executor::block_on(tree.eval(NodeId(1), context)); + assert!(result.is_some(), "The promoted wire should execute end-to-end"); + } + #[test] fn bare_value_promotes_to_item_wire() { let value_node = ProtoNode::value(ConstructionArgs::Value(TaggedValue::F64(3.).into()), vec![NodeId(0)]); From 759d00fe702fb8fc624de6e512f88a660749db22 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Fri, 3 Jul 2026 16:16:11 -0700 Subject: [PATCH 019/165] Rank the Offset Points distance connector and prove mixed-rank resolution end-to-end --- .../src/dynamic_executor.rs | 46 +++++++++++++++++++ node-graph/nodes/vector/src/vector_nodes.rs | 3 +- 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/node-graph/interpreted-executor/src/dynamic_executor.rs b/node-graph/interpreted-executor/src/dynamic_executor.rs index 046ce1ad37..0fda23526f 100644 --- a/node-graph/interpreted-executor/src/dynamic_executor.rs +++ b/node-graph/interpreted-executor/src/dynamic_executor.rs @@ -519,6 +519,52 @@ mod test { assert!(result.is_some(), "The promoted wire should execute end-to-end"); } + /// Builds a network feeding the given content plus a promoted bare distance into Offset Points, whose distance connector is ranked `Item`. + fn offset_points_network(content: TaggedValue) -> ProtoNetwork { + let content_node = ProtoNode::value(ConstructionArgs::Value(content.into()), vec![NodeId(0)]); + let distance_node = ProtoNode::value(ConstructionArgs::Value(TaggedValue::F64(10.).into()), vec![NodeId(1)]); + + let mut promote_node = ProtoNode::value(ConstructionArgs::Nodes(vec![NodeId(1)]), vec![NodeId(2)]); + promote_node.identifier = ProtoNodeIdentifier::new("graphene_core::ops::PromoteNode"); + + let mut offset_points_node = ProtoNode::value(ConstructionArgs::Nodes(vec![NodeId(0), NodeId(2)]), vec![NodeId(3)]); + offset_points_node.identifier = ProtoNodeIdentifier::new("core_types::vector::OffsetPointsNode"); + + ProtoNetwork { + inputs: vec![], + output: NodeId(3), + nodes: vec![(NodeId(0), content_node), (NodeId(1), distance_node), (NodeId(2), promote_node), (NodeId(3), offset_points_node)], + } + } + + #[test] + fn mixed_rank_connectors_resolve_via_promotion() { + let network = offset_points_network(TaggedValue::TypeDefault(descriptor!(List))); + let mut typing_context = TypingContext::new(&crate::node_registry::NODE_REGISTRY); + typing_context + .update(&network) + .expect("A List primary with an Item parameter should resolve the mapped variant via promotion"); + assert!(typing_context.promotions(NodeId(3)).is_some(), "The Item distance should be marked for promotion"); + let tree = futures::executor::block_on(BorrowTree::new(network, &typing_context)).expect("Construction should wrap the promoted argument"); + + let context: Context = None; + let result: Option> = futures::executor::block_on(tree.eval(NodeId(3), context)); + assert!(result.is_some(), "The zipped mapped variant should execute end-to-end"); + } + + #[test] + fn all_item_connectors_resolve_without_promotion() { + let network = offset_points_network(TaggedValue::TypeDefault(descriptor!(Item))); + let mut typing_context = TypingContext::new(&crate::node_registry::NODE_REGISTRY); + typing_context.update(&network).expect("All-Item connectors should resolve the rank-0 variant exactly"); + assert!(typing_context.promotions(NodeId(3)).is_none(), "No promotion should be needed at rank 0"); + let tree = futures::executor::block_on(BorrowTree::new(network, &typing_context)).expect("The rank-0 variant should instantiate"); + + let context: Context = None; + let result: Option> = futures::executor::block_on(tree.eval(NodeId(3), context)); + assert!(result.is_some(), "The rank-0 variant should execute and stay rank 0"); + } + #[test] fn bare_value_promotes_to_item_wire() { let value_node = ProtoNode::value(ConstructionArgs::Value(TaggedValue::F64(3.).into()), vec![NodeId(0)]); diff --git a/node-graph/nodes/vector/src/vector_nodes.rs b/node-graph/nodes/vector/src/vector_nodes.rs index 7bfba16586..b5c6fcaf07 100644 --- a/node-graph/nodes/vector/src/vector_nodes.rs +++ b/node-graph/nodes/vector/src/vector_nodes.rs @@ -2086,8 +2086,9 @@ async fn offset_points( /// The distance to offset each anchor point along its normal. Positive values move outward, negative values move inward. #[default(10.)] #[unit(" px")] - distance: f64, + distance: Item, ) -> Item { + let distance = *distance.element(); let transform_attribute: DAffine2 = content.attribute_cloned_or_default(ATTR_TRANSFORM); let inverse_linear = inverse_linear_or_repair(transform_attribute.matrix2); From fe5407e0bad65d87ebdcda6e2a857a1405ad2d2e Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Fri, 3 Jul 2026 19:06:12 -0700 Subject: [PATCH 020/165] Implement Clampable for Item and List wires with per-variant clamp bounds --- node-graph/libraries/core-types/src/misc.rs | 21 ++++++++++ node-graph/node-macro/src/codegen.rs | 44 ++++++++++++++------- 2 files changed, 50 insertions(+), 15 deletions(-) diff --git a/node-graph/libraries/core-types/src/misc.rs b/node-graph/libraries/core-types/src/misc.rs index 8fb447662d..f30ebe586d 100644 --- a/node-graph/libraries/core-types/src/misc.rs +++ b/node-graph/libraries/core-types/src/misc.rs @@ -61,6 +61,27 @@ impl Clampable for DVec2 { } } +// Implement for ranked wires (element-wise clamping across the frame) +use crate::list::{Item, List}; +impl Clampable for Item { + fn clamp_hard_min(self, min: f64) -> Self { + let (element, attributes) = self.into_parts(); + Item::from_parts(element.clamp_hard_min(min), attributes) + } + fn clamp_hard_max(self, max: f64) -> Self { + let (element, attributes) = self.into_parts(); + Item::from_parts(element.clamp_hard_max(max), attributes) + } +} +impl Clampable for List { + fn clamp_hard_min(self, min: f64) -> Self { + self.into_iter().map(|item| item.clamp_hard_min(min)).collect() + } + fn clamp_hard_max(self, max: f64) -> Self { + self.into_iter().map(|item| item.clamp_hard_max(max)).collect() + } +} + #[cfg(feature = "serde")] #[derive(serde::Deserialize)] struct LegacyTable { diff --git a/node-graph/node-macro/src/codegen.rs b/node-graph/node-macro/src/codegen.rs index 31cf0b16e1..3e2e39d9ca 100644 --- a/node-graph/node-macro/src/codegen.rs +++ b/node-graph/node-macro/src/codegen.rs @@ -282,18 +282,30 @@ pub(crate) fn generate_node_code(crate_ident: &CrateIdent, parsed: &ParsedNodeFn let all_implementation_types = all_implementation_types.chain(input.implementations.iter().cloned()); let input_type = &parsed.input.ty; - let mut clampable_clauses = Vec::new(); - - for field in ®ular_fields { - // Add Clampable bound if this field uses hard_min or hard_max, applying to the Output type of the future, which is #ty - if let ParsedFieldType::Regular(RegularParsedField { - ty, number_hard_min, number_hard_max, .. - }) = &field.ty - && (number_hard_min.is_some() || number_hard_max.is_some()) - { - clampable_clauses.push(quote!(#ty: #core_types::misc::Clampable)); - } - } + + // Add Clampable bounds for fields with hard bounds, applying to each variant's evaluated wire type + let build_clampable_clauses = |primary_wire: Option| -> Vec { + regular_fields + .iter() + .filter_map(|field| { + let ParsedFieldType::Regular(RegularParsedField { + ty, number_hard_min, number_hard_max, .. + }) = &field.ty + else { + return None; + }; + if number_hard_min.is_none() && number_hard_max.is_none() { + return None; + } + + let ty = match (peel_item(ty), primary_wire) { + (Some(element_ty), Some(wrap)) => wrap.apply(core_types, &element_ty), + _ => ty.clone(), + }; + Some(quote!(#ty: #core_types::misc::Clampable)) + }) + .collect() + }; future_idents.extend((0..regular_fields.len()).map(|id| format_ident!("F{}", id))); // Builds every field's where-clause bounds, optionally wrapping the primary field's wire type for the element-wise variants @@ -331,7 +343,7 @@ pub(crate) fn generate_node_code(crate_ident: &CrateIdent, parsed: &ParsedNodeFn predicates: Default::default(), }); - let make_struct_where_clause = |field_clauses: Vec, extra_clauses: Vec| { + let make_struct_where_clause = |field_clauses: Vec, clampable_clauses: Vec, extra_clauses: Vec| { let mut struct_where_clause = where_clause.clone(); let extra_where: Punctuated = parse_quote!( #(#field_clauses,)* @@ -342,7 +354,8 @@ pub(crate) fn generate_node_code(crate_ident: &CrateIdent, parsed: &ParsedNodeFn struct_where_clause.predicates.extend(extra_where); struct_where_clause }; - let struct_where_clause = make_struct_where_clause(build_field_clauses(element_wise.then_some(WireWrapper::Item)), Vec::new()); + let primary_wire = element_wise.then_some(WireWrapper::Item); + let struct_where_clause = make_struct_where_clause(build_field_clauses(primary_wire), build_clampable_clauses(primary_wire), Vec::new()); // The mapped variant clones bare parameters and clones ranked connectors' items per frame slot, so both need Clone let param_clone_clauses: Vec = regular_fields @@ -355,7 +368,8 @@ pub(crate) fn generate_node_code(crate_ident: &CrateIdent, parsed: &ParsedNodeFn ParsedFieldType::Node(_) => None, }) .collect(); - let mapped_struct_where_clause = element_wise.then(|| make_struct_where_clause(build_field_clauses(Some(WireWrapper::List)), param_clone_clauses)); + let mapped_struct_where_clause = + element_wise.then(|| make_struct_where_clause(build_field_clauses(Some(WireWrapper::List)), build_clampable_clauses(Some(WireWrapper::List)), param_clone_clauses)); // Only regular fields are parameters to new() let new_args: Vec<_> = node_generics From 5d54e820cf50f00b45bb4d88f6538d97a31bce53 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Fri, 3 Jul 2026 19:06:12 -0700 Subject: [PATCH 021/165] Rank the Round Corners radius connector, exercising hard bounds on a ranked wire --- node-graph/nodes/vector/src/vector_nodes.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/node-graph/nodes/vector/src/vector_nodes.rs b/node-graph/nodes/vector/src/vector_nodes.rs index b5c6fcaf07..fee86a6679 100644 --- a/node-graph/nodes/vector/src/vector_nodes.rs +++ b/node-graph/nodes/vector/src/vector_nodes.rs @@ -445,7 +445,7 @@ async fn round_corners( source: Item, #[hard(0..)] #[default(10.)] - radius: PixelLength, + radius: Item, #[range] #[hard(0..1)] #[default(0.5)] @@ -456,6 +456,7 @@ async fn round_corners( #[default(5.)] min_angle_threshold: Angle, ) -> Item { + let radius = *radius.element(); let source_transform: DAffine2 = source.attribute_cloned_or_default(ATTR_TRANSFORM); let source_transform_inverse = source_transform.inverse(); let (source, attributes) = source.into_parts(); From d42e2db04f8048d7094e6785a9256819128ce70e Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Fri, 3 Jul 2026 19:20:46 -0700 Subject: [PATCH 022/165] Implement ApplyTransform for Item --- node-graph/libraries/core-types/src/list.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/node-graph/libraries/core-types/src/list.rs b/node-graph/libraries/core-types/src/list.rs index 5b5526de2a..ba00db9d3e 100644 --- a/node-graph/libraries/core-types/src/list.rs +++ b/node-graph/libraries/core-types/src/list.rs @@ -1398,6 +1398,20 @@ impl From> for List { } } +impl ApplyTransform for Item { + /// Right-multiplies the modification into the item's transform attribute. + fn apply_transform(&mut self, modification: &DAffine2) { + let transform = self.attribute_mut_or_insert_default::(ATTR_TRANSFORM); + *transform *= *modification; + } + + /// Left-multiplies the modification into the item's transform attribute. + fn left_apply_transform(&mut self, modification: &DAffine2) { + let transform = self.attribute_mut_or_insert_default::(ATTR_TRANSFORM); + *transform = *modification * *transform; + } +} + unsafe impl StaticType for Item { type Static = Item; } From 69306caae24aaa435d7e45b8d83cbe8fb5dadab6 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Fri, 3 Jul 2026 19:20:46 -0700 Subject: [PATCH 023/165] Add Item wire implementations to the Transform node, keeping rank-0 chains rank 0 --- .../src/dynamic_executor.rs | 36 +++++++++++++++++++ .../nodes/transform/src/transform_nodes.rs | 7 ++++ 2 files changed, 43 insertions(+) diff --git a/node-graph/interpreted-executor/src/dynamic_executor.rs b/node-graph/interpreted-executor/src/dynamic_executor.rs index 0fda23526f..0ca83534cd 100644 --- a/node-graph/interpreted-executor/src/dynamic_executor.rs +++ b/node-graph/interpreted-executor/src/dynamic_executor.rs @@ -565,6 +565,42 @@ mod test { assert!(result.is_some(), "The rank-0 variant should execute and stay rank 0"); } + #[test] + fn transform_composes_onto_item_wire() { + use glam::{DAffine2, DVec2}; + + let content_node = ProtoNode::value(ConstructionArgs::Value(TaggedValue::TypeDefault(descriptor!(Item)).into()), vec![NodeId(0)]); + let translation_node = ProtoNode::value(ConstructionArgs::Value(TaggedValue::DVec2(DVec2::new(5., 0.)).into()), vec![NodeId(1)]); + let rotation_node = ProtoNode::value(ConstructionArgs::Value(TaggedValue::F64(0.).into()), vec![NodeId(2)]); + let scale_node = ProtoNode::value(ConstructionArgs::Value(TaggedValue::DVec2(DVec2::ONE).into()), vec![NodeId(3)]); + let skew_node = ProtoNode::value(ConstructionArgs::Value(TaggedValue::DVec2(DVec2::ZERO).into()), vec![NodeId(4)]); + + let mut transform_node = ProtoNode::value(ConstructionArgs::Nodes(vec![NodeId(0), NodeId(1), NodeId(2), NodeId(3), NodeId(4)]), vec![NodeId(5)]); + transform_node.identifier = graphene_std::transform_nodes::transform::IDENTIFIER; + + let network = ProtoNetwork { + inputs: vec![], + output: NodeId(5), + nodes: vec![ + (NodeId(0), content_node), + (NodeId(1), translation_node), + (NodeId(2), rotation_node), + (NodeId(3), scale_node), + (NodeId(4), skew_node), + (NodeId(5), transform_node), + ], + }; + let mut typing_context = TypingContext::new(&crate::node_registry::NODE_REGISTRY); + typing_context.update(&network).expect("Transform should resolve its Item wire variant"); + let tree = futures::executor::block_on(BorrowTree::new(network, &typing_context)).expect("Transform's Item variant should instantiate"); + + let context: Context = None; + let result: Option> = futures::executor::block_on(tree.eval(NodeId(5), context)); + let item = result.expect("A rank-0 chain through Transform should stay rank 0"); + let transform = item.attribute_cloned_or_default::(core_types::ATTR_TRANSFORM); + assert_eq!(transform.translation, DVec2::new(5., 0.), "The translation should compose onto the item's transform attribute"); + } + #[test] fn bare_value_promotes_to_item_wire() { let value_node = ProtoNode::value(ConstructionArgs::Value(TaggedValue::F64(3.).into()), vec![NodeId(0)]); diff --git a/node-graph/nodes/transform/src/transform_nodes.rs b/node-graph/nodes/transform/src/transform_nodes.rs index ae831b9a62..c20b6de878 100644 --- a/node-graph/nodes/transform/src/transform_nodes.rs +++ b/node-graph/nodes/transform/src/transform_nodes.rs @@ -16,6 +16,13 @@ async fn transform( #[implementations( Context -> DAffine2, Context -> DVec2, + Context -> Item, + Context -> Item, + Context -> Item, + Context -> Item>, + Context -> Item>, + Context -> Item, + Context -> Item, Context -> List, Context -> List, Context -> List, From dba71c215a929d06c95691d8fcbf49e1cfe4b9d3 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Fri, 3 Jul 2026 19:55:55 -0700 Subject: [PATCH 024/165] Detect element-wise nodes by lazy primary connectors declaring Output = Item --- node-graph/node-macro/src/codegen.rs | 11 ++++++++--- node-graph/node-macro/src/validation.rs | 9 +++++---- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/node-graph/node-macro/src/codegen.rs b/node-graph/node-macro/src/codegen.rs index 3e2e39d9ca..95cca50bcf 100644 --- a/node-graph/node-macro/src/codegen.rs +++ b/node-graph/node-macro/src/codegen.rs @@ -798,12 +798,17 @@ impl WireWrapper { } } -/// Returns the element type of the node's primary input if it is declared `Item`, which marks the node as element-wise. +/// Returns the element type of the node's primary input if it is declared `Item` (directly, or as a lazy +/// connector's `Output = Item`), which marks the node as element-wise. fn primary_item_element(parsed: &ParsedNodeFn) -> Option { let field = parsed.fields.first()?; + if field.is_data_field { + return None; + } + match &field.ty { - ParsedFieldType::Regular(RegularParsedField { ty, .. }) if !field.is_data_field => peel_item(ty), - _ => None, + ParsedFieldType::Regular(RegularParsedField { ty, .. }) => peel_item(ty), + ParsedFieldType::Node(NodeParsedField { output_type, .. }) => peel_item(output_type), } } diff --git a/node-graph/node-macro/src/validation.rs b/node-graph/node-macro/src/validation.rs index 44590b1f98..f4ecdf2c3c 100644 --- a/node-graph/node-macro/src/validation.rs +++ b/node-graph/node-macro/src/validation.rs @@ -23,10 +23,11 @@ pub fn validate_node_fn(parsed: &ParsedNodeFn) -> syn::Result<()> { } fn validate_no_item_parameters(parsed: &ParsedNodeFn) { - let primary_is_item = matches!( - parsed.fields.first().map(|field| &field.ty), - Some(ParsedFieldType::Regular(RegularParsedField { ty, .. })) if outer_wrapper_is(ty, "Item") - ); + let primary_is_item = match parsed.fields.first().map(|field| &field.ty) { + Some(ParsedFieldType::Regular(RegularParsedField { ty, .. })) => outer_wrapper_is(ty, "Item"), + Some(ParsedFieldType::Node(NodeParsedField { output_type, .. })) => outer_wrapper_is(output_type, "Item"), + None => false, + }; for field in parsed.fields.iter().skip(1) { let ParsedField { From 2d2b81bf38d72c4ec73fe45b5532030207d3889b Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Fri, 3 Jul 2026 19:55:55 -0700 Subject: [PATCH 025/165] Convert Transform to an Item kernel with ranked parameters, delivering the broadcast milestone --- .../src/dynamic_executor.rs | 86 +++++++++++++------ .../nodes/transform/src/transform_nodes.rs | 37 ++++---- 2 files changed, 78 insertions(+), 45 deletions(-) diff --git a/node-graph/interpreted-executor/src/dynamic_executor.rs b/node-graph/interpreted-executor/src/dynamic_executor.rs index 0ca83534cd..95d7177e7b 100644 --- a/node-graph/interpreted-executor/src/dynamic_executor.rs +++ b/node-graph/interpreted-executor/src/dynamic_executor.rs @@ -565,42 +565,80 @@ mod test { assert!(result.is_some(), "The rank-0 variant should execute and stay rank 0"); } + /// Builds a Transform network: content (node 0) plus four parameter values, each promoted onto Item wires as the preprocessor would. + fn transform_network(content: TaggedValue, rotation: TaggedValue) -> ProtoNetwork { + let mut nodes = vec![(NodeId(0), ProtoNode::value(ConstructionArgs::Value(content.into()), vec![NodeId(0)]))]; + + let parameters = [ + (TaggedValue::DVec2(glam::DVec2::new(5., 0.)), "DVec2"), + (rotation, "f64"), + (TaggedValue::DVec2(glam::DVec2::ONE), "DVec2"), + (TaggedValue::DVec2(glam::DVec2::ZERO), "DVec2"), + ]; + let mut transform_inputs = vec![NodeId(0)]; + let mut next_id = 1; + for (value, element) in parameters { + let value_id = NodeId(next_id); + let promote_id = NodeId(next_id + 1); + next_id += 2; + + nodes.push((value_id, ProtoNode::value(ConstructionArgs::Value(value.into()), vec![value_id]))); + let mut promote_node = ProtoNode::value(ConstructionArgs::Nodes(vec![value_id]), vec![promote_id]); + promote_node.identifier = ProtoNodeIdentifier::with_owned_string(format!("graphene_core::ops::PromoteNode<{element}>")); + nodes.push((promote_id, promote_node)); + transform_inputs.push(promote_id); + } + + let output = NodeId(next_id); + let mut transform_node = ProtoNode::value(ConstructionArgs::Nodes(transform_inputs), vec![output]); + transform_node.identifier = graphene_std::transform_nodes::transform::IDENTIFIER; + nodes.push((output, transform_node)); + + ProtoNetwork { inputs: vec![], output, nodes } + } + #[test] fn transform_composes_onto_item_wire() { use glam::{DAffine2, DVec2}; - let content_node = ProtoNode::value(ConstructionArgs::Value(TaggedValue::TypeDefault(descriptor!(Item)).into()), vec![NodeId(0)]); - let translation_node = ProtoNode::value(ConstructionArgs::Value(TaggedValue::DVec2(DVec2::new(5., 0.)).into()), vec![NodeId(1)]); - let rotation_node = ProtoNode::value(ConstructionArgs::Value(TaggedValue::F64(0.).into()), vec![NodeId(2)]); - let scale_node = ProtoNode::value(ConstructionArgs::Value(TaggedValue::DVec2(DVec2::ONE).into()), vec![NodeId(3)]); - let skew_node = ProtoNode::value(ConstructionArgs::Value(TaggedValue::DVec2(DVec2::ZERO).into()), vec![NodeId(4)]); - - let mut transform_node = ProtoNode::value(ConstructionArgs::Nodes(vec![NodeId(0), NodeId(1), NodeId(2), NodeId(3), NodeId(4)]), vec![NodeId(5)]); - transform_node.identifier = graphene_std::transform_nodes::transform::IDENTIFIER; - - let network = ProtoNetwork { - inputs: vec![], - output: NodeId(5), - nodes: vec![ - (NodeId(0), content_node), - (NodeId(1), translation_node), - (NodeId(2), rotation_node), - (NodeId(3), scale_node), - (NodeId(4), skew_node), - (NodeId(5), transform_node), - ], - }; + let network = transform_network(TaggedValue::TypeDefault(descriptor!(Item)), TaggedValue::F64(0.)); + let output = network.output; let mut typing_context = TypingContext::new(&crate::node_registry::NODE_REGISTRY); - typing_context.update(&network).expect("Transform should resolve its Item wire variant"); - let tree = futures::executor::block_on(BorrowTree::new(network, &typing_context)).expect("Transform's Item variant should instantiate"); + typing_context.update(&network).expect("Transform should resolve its rank-0 variant"); + assert!(typing_context.promotions(output).is_none(), "All-Item connectors should need no promotion"); + let tree = futures::executor::block_on(BorrowTree::new(network, &typing_context)).expect("Transform's rank-0 variant should instantiate"); let context: Context = None; - let result: Option> = futures::executor::block_on(tree.eval(NodeId(5), context)); + let result: Option> = futures::executor::block_on(tree.eval(output, context)); let item = result.expect("A rank-0 chain through Transform should stay rank 0"); let transform = item.attribute_cloned_or_default::(core_types::ATTR_TRANSFORM); assert_eq!(transform.translation, DVec2::new(5., 0.), "The translation should compose onto the item's transform attribute"); } + #[test] + fn transform_broadcasts_item_content_across_a_framed_parameter() { + use glam::DAffine2; + + let network = transform_network(TaggedValue::TypeDefault(descriptor!(Item)), TaggedValue::F64Array(vec![0., 90.])); + let output = network.output; + let mut typing_context = TypingContext::new(&crate::node_registry::NODE_REGISTRY); + typing_context + .update(&network) + .expect("A framed rotation should resolve the mapped variant via promotion of the other connectors"); + assert!(typing_context.promotions(output).is_some(), "The Item-typed connectors should be raised into the frame"); + let tree = futures::executor::block_on(BorrowTree::new(network, &typing_context)).expect("The mapped variant should instantiate"); + + let context: Context = None; + let result: Option> = futures::executor::block_on(tree.eval(output, context)); + let list = result.expect("The broadcast should produce a List"); + assert_eq!(list.len(), 2, "One output item per frame slot"); + + let first: DAffine2 = list.attribute_cloned_or_default(core_types::ATTR_TRANSFORM, 0); + let second: DAffine2 = list.attribute_cloned_or_default(core_types::ATTR_TRANSFORM, 1); + assert!((first.matrix2.col(0).y - 0.).abs() < 1e-10, "Slot 0 should be unrotated"); + assert!((second.matrix2.col(0).y - 1.).abs() < 1e-10, "Slot 1 should be rotated 90 degrees"); + } + #[test] fn bare_value_promotes_to_item_wire() { let value_node = ProtoNode::value(ConstructionArgs::Value(TaggedValue::F64(3.).into()), vec![NodeId(0)]); diff --git a/node-graph/nodes/transform/src/transform_nodes.rs b/node-graph/nodes/transform/src/transform_nodes.rs index c20b6de878..8603b19d9c 100644 --- a/node-graph/nodes/transform/src/transform_nodes.rs +++ b/node-graph/nodes/transform/src/transform_nodes.rs @@ -1,6 +1,6 @@ use core::f64; use core_types::color::Color; -use core_types::list::{Item, List, ListDyn}; +use core_types::list::{Item, ListDyn}; use core_types::transform::{ApplyTransform, ScaleType, Transform}; use core_types::{ATTR_TRANSFORM, CloneVarArgs, Context, Ctx, ExtractAll, InjectFootprint, ModifyFootprint, OwnedContextImpl}; use glam::{DAffine2, DMat2, DVec2}; @@ -9,13 +9,13 @@ use graphic_types::Vector; use graphic_types::raster_types::{CPU, GPU, Raster}; use vector_types::GradientStops; -/// Applies the specified transform to the input value, which may be a graphic type or another transform. +/// Applies the specified transform to the input content. #[node_macro::node(category("Math: Transform"))] -async fn transform( +async fn transform( ctx: impl Ctx + CloneVarArgs + ExtractAll + ModifyFootprint, #[implementations( - Context -> DAffine2, - Context -> DVec2, + Context -> Item, + Context -> Item, Context -> Item, Context -> Item, Context -> Item, @@ -23,22 +23,17 @@ async fn transform( Context -> Item>, Context -> Item, Context -> Item, - Context -> List, - Context -> List, - Context -> List, - Context -> List>, - Context -> List>, - Context -> List, - Context -> List, )] - content: impl Node, Output = T>, - #[widget(ParsedWidgetOverride::Custom = "transform_translation")] translation: DVec2, - #[widget(ParsedWidgetOverride::Custom = "transform_rotation")] rotation: f64, + content: impl Node, Output = Item>, + #[widget(ParsedWidgetOverride::Custom = "transform_translation")] translation: Item, + #[widget(ParsedWidgetOverride::Custom = "transform_rotation")] rotation: Item, #[widget(ParsedWidgetOverride::Custom = "transform_scale")] #[default(1., 1.)] - scale: DVec2, - #[widget(ParsedWidgetOverride::Custom = "transform_skew")] skew: DVec2, -) -> T { + scale: Item, + #[widget(ParsedWidgetOverride::Custom = "transform_skew")] skew: Item, +) -> Item { + let (translation, rotation, scale, skew) = (*translation.element(), *rotation.element(), *scale.element(), *skew.element()); + let trs = DAffine2::from_scale_angle_translation(scale, rotation.to_radians(), translation); let skew = DAffine2::from_cols_array(&[1., skew.y.to_radians().tan(), skew.x.to_radians().tan(), 1., 0., 0.]); let matrix = trs * skew; @@ -51,11 +46,11 @@ async fn transform( ctx = ctx.with_footprint(footprint); } - let mut transform_target = content.eval(ctx.into_context()).await; + let mut item = content.eval(ctx.into_context()).await; - transform_target.left_apply_transform(&matrix); + item.left_apply_transform(&matrix); - transform_target + item } /// Resets the desired components of the input transform to their default values. If all components are reset, the output will be set to the identity transform. From 00e7b75ae3688b2733feeba7c29475b6a4138a35 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Fri, 3 Jul 2026 20:15:42 -0700 Subject: [PATCH 026/165] Rename Apply Transform to Bake Transform, baking item transforms on Vector, DAffine2, and DVec2 --- .../messages/portfolio/document_migration.rs | 8 +++++-- .../libraries/core-types/src/transform.rs | 17 +++++++++++++++ .../vector-types/src/vector/vector_types.rs | 9 ++++++++ .../vector/src/vector_modification_nodes.rs | 21 +++++++------------ 4 files changed, 40 insertions(+), 15 deletions(-) diff --git a/editor/src/messages/portfolio/document_migration.rs b/editor/src/messages/portfolio/document_migration.rs index ec9483d4a2..1fd1d7e559 100644 --- a/editor/src/messages/portfolio/document_migration.rs +++ b/editor/src/messages/portfolio/document_migration.rs @@ -735,8 +735,12 @@ const NODE_REPLACEMENTS: &[NodeReplacement<'static>] = &[ // vector // ================================ NodeReplacement { - node: graphene_std::vector::apply_transform::IDENTIFIER, - aliases: &["graphene_core::vector::ApplyTransformNode", "graphene_core::vector::vector_modification::ApplyTransformNode"], + node: graphene_std::vector::bake_transform::IDENTIFIER, + aliases: &[ + "graphene_core::vector::ApplyTransformNode", + "graphene_core::vector::vector_modification::ApplyTransformNode", + "vector_nodes::vector_modification_nodes::ApplyTransformNode", + ], }, NodeReplacement { node: graphene_std::vector::area::IDENTIFIER, diff --git a/node-graph/libraries/core-types/src/transform.rs b/node-graph/libraries/core-types/src/transform.rs index 60c32887e8..7f2aa272a6 100644 --- a/node-graph/libraries/core-types/src/transform.rs +++ b/node-graph/libraries/core-types/src/transform.rs @@ -217,6 +217,23 @@ impl From<()> for Footprint { } } +/// Consumes an item's `transform` attribute by baking it into the underlying value itself. +pub trait BakeTransform { + fn bake_transform(&mut self, transform: &DAffine2); +} + +impl BakeTransform for DAffine2 { + fn bake_transform(&mut self, transform: &DAffine2) { + *self = *transform * *self; + } +} + +impl BakeTransform for DVec2 { + fn bake_transform(&mut self, transform: &DAffine2) { + *self = transform.transform_point2(*self); + } +} + pub trait ApplyTransform { fn apply_transform(&mut self, modification: &DAffine2); fn left_apply_transform(&mut self, modification: &DAffine2); diff --git a/node-graph/libraries/vector-types/src/vector/vector_types.rs b/node-graph/libraries/vector-types/src/vector/vector_types.rs index 38809ae5f5..1e2d111d17 100644 --- a/node-graph/libraries/vector-types/src/vector/vector_types.rs +++ b/node-graph/libraries/vector-types/src/vector/vector_types.rs @@ -71,6 +71,15 @@ impl core_types::ops::ListConvert for Vector { } } +impl core_types::transform::BakeTransform for Vector { + fn bake_transform(&mut self, transform: &glam::DAffine2) { + for (_, point) in self.point_domain.positions_mut() { + *point = transform.transform_point2(*point); + } + self.segment_domain.transform(*transform); + } +} + impl Vector { /// Add a subpath to this vector path. pub fn append_subpath(&mut self, subpath: impl Borrow>, preserve_id: bool) { diff --git a/node-graph/nodes/vector/src/vector_modification_nodes.rs b/node-graph/nodes/vector/src/vector_modification_nodes.rs index e473c8184f..1ba00eac3a 100644 --- a/node-graph/nodes/vector/src/vector_modification_nodes.rs +++ b/node-graph/nodes/vector/src/vector_modification_nodes.rs @@ -1,7 +1,8 @@ -use core_types::list::List; +use core_types::list::{Item, List}; +use core_types::transform::BakeTransform; use core_types::uuid::NodeId; use core_types::{ATTR_EDITOR_CLICK_TARGET, ATTR_EDITOR_LAYER_PATH, ATTR_TRANSFORM, Ctx}; -use glam::DAffine2; +use glam::{DAffine2, DVec2}; use graphic_types::Vector; use vector_types::vector::VectorModification; @@ -33,18 +34,12 @@ async fn path_modify(_ctx: impl Ctx, mut vector: List, modification: Box vector } -/// Applies the vector path's local transformation to its geometry and resets the transform to the identity. +/// Bakes the content's transform attribute into its underlying value, removing the attribute. #[node_macro::node(category("Vector"))] -async fn apply_transform(_ctx: impl Ctx, mut vector: List) -> List { - let (elements, transforms) = vector.element_and_attribute_slices_mut::(ATTR_TRANSFORM); - for (element, transform) in elements.iter_mut().zip(transforms.iter_mut()) { - for (_, point) in element.point_domain.positions_mut() { - *point = transform.transform_point2(*point); - } - element.segment_domain.transform(*transform); - - *transform = DAffine2::IDENTITY; +async fn bake_transform(_ctx: impl Ctx, #[implementations(Vector, DAffine2, DVec2)] mut content: Item) -> Item { + if let Some(transform) = content.remove_attribute::(ATTR_TRANSFORM) { + content.element_mut().bake_transform(&transform); } - vector + content } From 5cad9de88e29082262c2a51b88608accdf4cdbce Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Fri, 3 Jul 2026 20:35:51 -0700 Subject: [PATCH 027/165] Promote bare wires onto Item connectors at resolution via WrapItemNode adapters --- node-graph/graph-craft/src/proto.rs | 33 +++++++++++--- .../src/dynamic_executor.rs | 41 ++++++++++++++++- .../interpreted-executor/src/node_registry.rs | 44 ++++++++++++++++++- 3 files changed, 108 insertions(+), 10 deletions(-) diff --git a/node-graph/graph-craft/src/proto.rs b/node-graph/graph-craft/src/proto.rs index c6f07a78bc..7b118086da 100644 --- a/node-graph/graph-craft/src/proto.rs +++ b/node-graph/graph-craft/src/proto.rs @@ -663,7 +663,7 @@ impl TypingContext { self.inferred.remove(&node_id) } - /// Returns the input positions of a node which type resolution marked for Item -> List promotion, with each element's simplified name. + /// Returns the input positions of a node which type resolution marked for rank promotion, with each adapter's identifier name. pub fn promotions(&self, node_id: NodeId) -> Option<&Vec<(usize, String)>> { self.promotions.get(&node_id) } @@ -781,11 +781,18 @@ impl TypingContext { match valid_impls.as_slice() { [] => { // Retry allowing an Item input to feed a List connector, satisfied at construction time by an inserted promotion adapter - fn promotable_element(from: &Type, to: &Type) -> Option { + fn promotable_adapter(from: &Type, to: &Type) -> Option { fn wrapped_name<'a>(ty: &'a Type, wrapper: &str) -> Option<&'a str> { let Type::Concrete(descriptor) = ty.nested_type() else { return None }; descriptor.name.strip_prefix("core_types::list::")?.strip_prefix(wrapper)?.strip_prefix('<')?.strip_suffix('>') } + fn peel_identifier(ty: &Type, wrapper: &str) -> Option { + let name = ty.nested_type().identifier_name(); + name.strip_prefix(wrapper) + .and_then(|rest| rest.strip_prefix('<')) + .and_then(|rest| rest.strip_suffix('>')) + .map(str::to_string) + } let (Type::Fn(from_input, from_output), Type::Fn(to_input, to_output)) = (from, to) else { return None; @@ -794,11 +801,23 @@ impl TypingContext { return None; } - if wrapped_name(from_output, "Item")? != wrapped_name(to_output, "List")? { - return None; + // An `Item` wire may feed a `List` connector via a singleton raise + if let (Some(item_inner), Some(list_inner)) = (wrapped_name(from_output, "Item"), wrapped_name(to_output, "List")) + && item_inner == list_inner + { + let element = peel_identifier(to_output, "List")?; + return Some(format!("graphene_core::ops::ItemToListNode<{element}>")); } - let element = to_output.nested_type().identifier_name(); - element.strip_prefix("List<").and_then(|rest| rest.strip_suffix('>')).map(str::to_string) + + // A bare wire may feed an `Item` connector via a wrap + if let (Type::Concrete(from_descriptor), Some(item_inner)) = (from_output.nested_type(), wrapped_name(to_output, "Item")) + && from_descriptor.name == item_inner + { + let element = peel_identifier(to_output, "Item")?; + return Some(format!("graphene_core::ops::WrapItemNode<{element}>")); + } + + None } let mut promotable_matches = impls @@ -810,7 +829,7 @@ impl TypingContext { if valid_type(provided, expected) { continue; } - required_promotions.push((index, promotable_element(provided, expected)?)); + required_promotions.push((index, promotable_adapter(provided, expected)?)); } Some((node_io, required_promotions)) }) diff --git a/node-graph/interpreted-executor/src/dynamic_executor.rs b/node-graph/interpreted-executor/src/dynamic_executor.rs index 95d7177e7b..0a362c720e 100644 --- a/node-graph/interpreted-executor/src/dynamic_executor.rs +++ b/node-graph/interpreted-executor/src/dynamic_executor.rs @@ -417,8 +417,8 @@ impl BorrowTree { // Wrap arguments the typing pass marked for Item -> List promotion with their adapter node if let Some(promotions) = typing_context.promotions(id) { - for (argument_index, element_name) in promotions { - let identifier = graph_craft::ProtoNodeIdentifier::with_owned_string(format!("graphene_core::ops::ItemToListNode<{element_name}>")); + for (argument_index, adapter_name) in promotions { + let identifier = graph_craft::ProtoNodeIdentifier::with_owned_string(adapter_name.clone()); let adapter_constructor = typing_context .adapter_constructor(&identifier) .ok_or_else(|| vec![GraphError::new(&proto_node, GraphErrorType::NoConstructor)])?; @@ -639,6 +639,43 @@ mod test { assert!((second.matrix2.col(0).y - 1.).abs() < 1e-10, "Slot 1 should be rotated 90 degrees"); } + #[test] + fn bare_wires_promote_to_item_connectors_at_resolution() { + use glam::{DAffine2, DVec2}; + + let values = [ + TaggedValue::DAffine2(DAffine2::IDENTITY), + TaggedValue::DVec2(DVec2::new(7., 0.)), + TaggedValue::F64(0.), + TaggedValue::DVec2(DVec2::ONE), + TaggedValue::DVec2(DVec2::ZERO), + ]; + let mut nodes: Vec<_> = values + .into_iter() + .enumerate() + .map(|(index, value)| (NodeId(index as u64), ProtoNode::value(ConstructionArgs::Value(value.into()), vec![NodeId(index as u64)]))) + .collect(); + let mut transform_node = ProtoNode::value(ConstructionArgs::Nodes((0..5).map(NodeId).collect()), vec![NodeId(5)]); + transform_node.identifier = graphene_std::transform_nodes::transform::IDENTIFIER; + nodes.push((NodeId(5), transform_node)); + + let network = ProtoNetwork { + inputs: vec![], + output: NodeId(5), + nodes, + }; + let mut typing_context = TypingContext::new(&crate::node_registry::NODE_REGISTRY); + typing_context.update(&network).expect("Bare wires should resolve Item connectors via wrap promotion"); + assert_eq!(typing_context.promotions(NodeId(5)).map(Vec::len), Some(5), "All five bare inputs should be wrapped"); + let tree = futures::executor::block_on(BorrowTree::new(network, &typing_context)).expect("The wrap adapters should instantiate"); + + let context: Context = None; + let result: Option> = futures::executor::block_on(tree.eval(NodeId(5), context)); + let item = result.expect("A bare matrix should flow through Transform as an Item"); + let transform = item.attribute_cloned_or_default::(core_types::ATTR_TRANSFORM); + assert_eq!(transform.translation, DVec2::new(7., 0.), "The translation should compose onto the gained transform attribute"); + } + #[test] fn bare_value_promotes_to_item_wire() { let value_node = ProtoNode::value(ConstructionArgs::Value(TaggedValue::F64(3.).into()), vec![NodeId(0)]); diff --git a/node-graph/interpreted-executor/src/node_registry.rs b/node-graph/interpreted-executor/src/node_registry.rs index 3b57f0f58d..b5ce41ed3b 100644 --- a/node-graph/interpreted-executor/src/node_registry.rs +++ b/node-graph/interpreted-executor/src/node_registry.rs @@ -353,6 +353,33 @@ fn node_registry() -> HashMap { + ( + ProtoNodeIdentifier::new(concat!["graphene_core::ops::WrapItemNode<", stringify!($element), ">"]), + |mut args| { + Box::pin(async move { + let node = graphene_std::ops::PromoteNode::new( + graphene_std::any::downcast_node::(args.pop().unwrap()), + graphene_std::any::FutureWrapperNode::new(graphene_std::value::ClonedNode::new(std::marker::PhantomData::>)), + ); + let any: DynAnyNode, _> = graphene_std::any::DynAnyNode::new(node); + Box::new(any) as TypeErasedBox + }) + }, + { + let node = graphene_std::ops::PromoteNode::new( + graphene_std::any::PanicNode:: + Send>>>::new(), + graphene_std::any::FutureWrapperNode::new(graphene_std::value::ClonedNode::new(std::marker::PhantomData::>)), + ); + let params = vec![fn_type_fut!(Context, $element)]; + let node_io = NodeIO::<'_, Context>::to_async_node_io(&node, params); + node_io + }, + ) + }; + } // ============= // PROMOTE NODES // ============= @@ -366,6 +393,7 @@ fn node_registry() -> HashMap HashMap = vec![ + wrap_item_node!(element: Vector), + wrap_item_node!(element: Raster), + wrap_item_node!(element: Graphic), + wrap_item_node!(element: Color), + wrap_item_node!(element: GradientStops), + wrap_item_node!(element: String), + wrap_item_node!(element: f64), + wrap_item_node!(element: DVec2), + wrap_item_node!(element: DAffine2), + wrap_item_node!(element: bool), + ]; + node_types.extend(wrap_item_nodes); // ============= // CONVERT NODES // ============= @@ -424,7 +466,7 @@ fn node_registry() -> HashMap Date: Fri, 3 Jul 2026 21:01:55 -0700 Subject: [PATCH 028/165] Rank the numeric, vector, and boolean parameters across the migrated element-wise nodes --- node-graph/nodes/blending/src/lib.rs | 14 ++- node-graph/nodes/brush/src/brush.rs | 4 +- node-graph/nodes/raster/src/dehaze.rs | 4 +- node-graph/nodes/raster/src/filter.rs | 12 +- node-graph/nodes/raster/src/std_nodes.rs | 4 +- .../nodes/transform/src/transform_nodes.rs | 12 +- node-graph/nodes/vector/src/vector_nodes.rs | 106 +++++++++++++----- 7 files changed, 108 insertions(+), 48 deletions(-) diff --git a/node-graph/nodes/blending/src/lib.rs b/node-graph/nodes/blending/src/lib.rs index 73d63e9421..dd26166daf 100644 --- a/node-graph/nodes/blending/src/lib.rs +++ b/node-graph/nodes/blending/src/lib.rs @@ -32,21 +32,23 @@ fn opacity( /// Whether the *Opacity* property is enabled, multiplying the existing opacity by the chosen percentage. #[widget(ParsedWidgetOverride::Hidden)] #[default(true)] - has_opacity: bool, + has_opacity: Item, /// How visible the content should be, including any content clipped to it. /// Ranges from the default of 100% (fully opaque) to 0% (fully transparent). #[widget(ParsedWidgetOverride::Custom = "optional_percentage")] #[default(100.)] - opacity: Percentage, + opacity: Item, /// Whether the *Fill* property is enabled, multiplying the existing fill by the chosen percentage. #[widget(ParsedWidgetOverride::Hidden)] - has_fill: bool, + has_fill: Item, /// How visible the content should be, independent of any content clipped to it. /// Ranges from 0% (fully transparent) to the default of 100% (fully opaque). #[widget(ParsedWidgetOverride::Custom = "optional_percentage")] #[default(100.)] - fill: Percentage, + fill: Item, ) -> Item { + let (has_opacity, opacity, has_fill, fill) = (*has_opacity.element(), *opacity.element(), *has_fill.element(), *fill.element()); + if has_opacity { let multiplied = content.attribute_cloned_or(ATTR_OPACITY, 1.) * (opacity / 100.); content.set_attribute(ATTR_OPACITY, multiplied); @@ -68,8 +70,10 @@ fn clipping_mask( #[implementations(Graphic, Vector, Raster, Color, GradientStops, String)] mut content: Item, /// Whether the content inherits the alpha of the content beneath it. - clip: bool, + clip: Item, ) -> Item { + let clip = *clip.element(); + content.set_attribute(ATTR_CLIPPING_MASK, clip); content } diff --git a/node-graph/nodes/brush/src/brush.rs b/node-graph/nodes/brush/src/brush.rs index 2d0349be8b..fb80cd39c5 100644 --- a/node-graph/nodes/brush/src/brush.rs +++ b/node-graph/nodes/brush/src/brush.rs @@ -223,7 +223,7 @@ async fn brush( let mut brush_plan = cache.compute_brush_plan(list_item, &draw_strokes); - let mut actual_image = extend_image_to_bounds((), brush_plan.background, background_bounds); + let mut actual_image = extend_image_to_bounds((), brush_plan.background, Item::new_from_element(background_bounds)); let final_stroke_idx = brush_plan.strokes.len().saturating_sub(1); for (idx, stroke) in brush_plan.strokes.into_iter().enumerate() { @@ -260,7 +260,7 @@ async fn brush( ); let blit_target = if idx == 0 { let target = core::mem::take(&mut brush_plan.first_stroke_texture); - List::new_from_item(extend_image_to_bounds((), target, stroke_to_layer)) + List::new_from_item(extend_image_to_bounds((), target, Item::new_from_element(stroke_to_layer))) } else { empty_image((), stroke_to_layer, List::new_from_element(Color::TRANSPARENT)) // EmptyImageNode::new(CopiedNode::new(stroke_to_layer), CopiedNode::new(Color::TRANSPARENT)).eval(()) diff --git a/node-graph/nodes/raster/src/dehaze.rs b/node-graph/nodes/raster/src/dehaze.rs index 9abdd31f70..3d503a4d67 100644 --- a/node-graph/nodes/raster/src/dehaze.rs +++ b/node-graph/nodes/raster/src/dehaze.rs @@ -8,7 +8,9 @@ use raster_types::{CPU, Raster}; use std::cmp::{max, min}; #[node_macro::node(category("Raster: Filter"))] -async fn dehaze(_: impl Ctx, image_frame: Item>, strength: Percentage) -> Item> { +async fn dehaze(_: impl Ctx, image_frame: Item>, strength: Item) -> Item> { + let strength = *strength.element(); + let (image, attributes) = image_frame.into_parts(); // Prepare the image data for processing diff --git a/node-graph/nodes/raster/src/filter.rs b/node-graph/nodes/raster/src/filter.rs index ebd73350f0..bfe2001f2e 100644 --- a/node-graph/nodes/raster/src/filter.rs +++ b/node-graph/nodes/raster/src/filter.rs @@ -95,12 +95,14 @@ async fn blur( #[range] #[hard(0..)] #[soft(..100)] - radius: PixelLength, + radius: Item, /// Use a lower-quality box kernel instead of a circular Gaussian kernel. This is faster but produces boxy artifacts. - box_blur: bool, + box_blur: Item, /// Opt to incorrectly apply the filter with color calculations in gamma space for compatibility with the results from other software. - gamma: bool, + gamma: Item, ) -> Item> { + let (radius, box_blur, gamma) = (*radius.element(), *box_blur.element(), *gamma.element()); + let (image, attributes) = image_frame.into_parts(); let blurred_image = if radius < 0.1 { @@ -125,8 +127,10 @@ async fn median_filter( #[range] #[hard(0..)] #[soft(..50)] - radius: PixelLength, + radius: Item, ) -> Item> { + let radius = *radius.element(); + let (image, attributes) = image_frame.into_parts(); let filtered_image = if radius < 0.5 { diff --git a/node-graph/nodes/raster/src/std_nodes.rs b/node-graph/nodes/raster/src/std_nodes.rs index 7ec898263d..ae9d857a58 100644 --- a/node-graph/nodes/raster/src/std_nodes.rs +++ b/node-graph/nodes/raster/src/std_nodes.rs @@ -223,7 +223,9 @@ pub fn mask( } #[node_macro::node(category(""))] -pub fn extend_image_to_bounds(_: impl Ctx, image: Item>, bounds: DAffine2) -> Item> { +pub fn extend_image_to_bounds(_: impl Ctx, image: Item>, bounds: Item) -> Item> { + let bounds = *bounds.element(); + let image_transform: DAffine2 = image.attribute_cloned_or_default(ATTR_TRANSFORM); let image_aabb = Bbox::unit().affine_transform(image_transform).to_axis_aligned_bbox(); let bounds_aabb = Bbox::unit().affine_transform(bounds.transform()).to_axis_aligned_bbox(); diff --git a/node-graph/nodes/transform/src/transform_nodes.rs b/node-graph/nodes/transform/src/transform_nodes.rs index 8603b19d9c..fd8d5b320f 100644 --- a/node-graph/nodes/transform/src/transform_nodes.rs +++ b/node-graph/nodes/transform/src/transform_nodes.rs @@ -67,10 +67,12 @@ fn reset_transform( GradientStops, )] mut content: Item, - #[default(true)] reset_translation: bool, - reset_rotation: bool, - reset_scale: bool, + #[default(true)] reset_translation: Item, + reset_rotation: Item, + reset_scale: Item, ) -> Item { + let (reset_translation, reset_rotation, reset_scale) = (*reset_translation.element(), *reset_rotation.element(), *reset_scale.element()); + let item_transform = content.attribute_mut_or_insert_default::(ATTR_TRANSFORM); if reset_translation { @@ -106,8 +108,10 @@ fn replace_transform( GradientStops, )] mut content: Item, - transform: DAffine2, + transform: Item, ) -> Item { + let transform = *transform.element(); + content.set_attribute(ATTR_TRANSFORM, transform.transform()); content } diff --git a/node-graph/nodes/vector/src/vector_nodes.rs b/node-graph/nodes/vector/src/vector_nodes.rs index fee86a6679..4f1e5cf280 100644 --- a/node-graph/nodes/vector/src/vector_nodes.rs +++ b/node-graph/nodes/vector/src/vector_nodes.rs @@ -449,14 +449,14 @@ async fn round_corners( #[range] #[hard(0..1)] #[default(0.5)] - roundness: f64, - #[default(100.)] edge_length_limit: Percentage, + roundness: Item, + #[default(100.)] edge_length_limit: Item, #[range] #[hard(0..180)] #[default(5.)] - min_angle_threshold: Angle, + min_angle_threshold: Item, ) -> Item { - let radius = *radius.element(); + let (radius, roundness, edge_length_limit, min_angle_threshold) = (*radius.element(), *roundness.element(), *edge_length_limit.element(), *min_angle_threshold.element()); let source_transform: DAffine2 = source.attribute_cloned_or_default(ATTR_TRANSFORM); let source_transform_inverse = source_transform.inverse(); let (source, attributes) = source.into_parts(); @@ -553,9 +553,11 @@ pub fn merge_by_distance( mut content: Item, #[default(0.1)] #[hard(0.0001..)] - distance: PixelLength, + distance: Item, algorithm: MergeByDistanceAlgorithm, ) -> Item { + let distance = *distance.element(); + match algorithm { MergeByDistanceAlgorithm::Spatial => { let transform: DAffine2 = content.attribute_cloned_or_default(ATTR_TRANSFORM); @@ -765,7 +767,9 @@ pub mod extrude_algorithms { } #[node_macro::node(category("Vector: Modifier"), path(core_types::vector))] -async fn extrude(_: impl Ctx, mut source: Item, direction: DVec2, joining_algorithm: ExtrudeJoiningAlgorithm) -> Item { +async fn extrude(_: impl Ctx, mut source: Item, direction: Item, joining_algorithm: ExtrudeJoiningAlgorithm) -> Item { + let direction = *direction.element(); + extrude_algorithms::extrude(source.element_mut(), direction, joining_algorithm); source } @@ -984,11 +988,13 @@ async fn auto_tangents( #[default(0.5)] #[range] #[soft(0..1)] - spread: f64, + spread: Item, /// If active, existing non-zero handles won't be affected. #[default(true)] - preserve_existing: bool, + preserve_existing: Item, ) -> Item { + let (spread, preserve_existing) = (*spread.element(), *preserve_existing.element()); + let transform: DAffine2 = source.attribute_cloned_or_default(ATTR_TRANSFORM); let (source, attributes) = source.into_parts(); @@ -1161,7 +1167,9 @@ fn as_vector(_: impl Ctx, value: List) -> List { /// Creates a polyline from a series of vector points, replacing any existing segments and regions that may already exist. #[node_macro::node(category("Vector"), name("Points to Polyline"), path(core_types::vector))] -async fn points_to_polyline(_: impl Ctx, mut points: Item, #[default(true)] closed: bool) -> Item { +async fn points_to_polyline(_: impl Ctx, mut points: Item, #[default(true)] closed: Item) -> Item { + let closed = *closed.element(); + let vector = points.element_mut(); let mut segment_domain = SegmentDomain::new(); @@ -1189,7 +1197,9 @@ async fn points_to_polyline(_: impl Ctx, mut points: Item, #[default(tru } #[node_macro::node(category("Vector: Modifier"), path(core_types::vector), properties("offset_path_properties"))] -async fn offset_path(_: impl Ctx, mut content: Item, distance: f64, join: StrokeJoin, #[default(4.)] miter_limit: f64) -> Item { +async fn offset_path(_: impl Ctx, mut content: Item, distance: Item, join: StrokeJoin, #[default(4.)] miter_limit: Item) -> Item { + let (distance, miter_limit) = (*distance.element(), *miter_limit.element()); + let transform_attribute: DAffine2 = content.attribute_cloned_or_default(ATTR_TRANSFORM); let transform = Affine::new(transform_attribute.to_cols_array()); let vector = std::mem::take(content.element_mut()); @@ -1463,18 +1473,20 @@ async fn sample_polyline( #[default(100.)] #[hard(0..)] #[unit(" px")] - separation: f64, + separation: Item, #[default(100)] #[hard(2..)] quantity: u32, #[hard(0..)] #[unit(" px")] - start_offset: f64, + start_offset: Item, #[hard(0..)] #[unit(" px")] - stop_offset: f64, - adaptive_spacing: bool, + stop_offset: Item, + adaptive_spacing: Item, ) -> Item { + let (separation, start_offset, stop_offset, adaptive_spacing) = (*separation.element(), *start_offset.element(), *stop_offset.element(), *adaptive_spacing.element()); + let pathseg_perimeter = |segment: PathSeg| { if is_linear(segment) { Line::new(segment.start(), segment.end()).perimeter(DEFAULT_ACCURACY) @@ -1544,8 +1556,10 @@ async fn simplify( /// The maximum distance the simplified path may deviate from the original. #[default(5.)] #[unit(" px")] - tolerance: Length, + tolerance: Item, ) -> Item { + let tolerance = *tolerance.element(); + if tolerance <= 0. { return content; } @@ -1583,8 +1597,10 @@ async fn decimate( /// The maximum distance a point can deviate from the simplified path before it is kept. #[default(5.)] #[unit(" px")] - tolerance: Length, + tolerance: Item, ) -> Item { + let tolerance = *tolerance.element(); + // Tolerance of 0 means no simplification is possible, so return immediately if tolerance <= 0. { return content; @@ -1704,12 +1720,14 @@ async fn cut_path( /// The path to insert a cut into. mut content: Item, /// The factor from the start to the end of the path, 0–1 for one subpath, 1–2 for a second subpath, and so on. - progression: Progression, + progression: Item, /// Swap the direction of the path. - reverse: bool, + reverse: Item, /// Traverse the path using each segment's Bézier curve parameterization instead of the Euclidean distance. Faster to compute but doesn't respect actual distances. - parameterized_distance: bool, + parameterized_distance: Item, ) -> Item { + let (progression, reverse, parameterized_distance) = (*progression.element(), *reverse.element(), *parameterized_distance.element()); + let euclidian = !parameterized_distance; let bezpaths = content.element().stroke_bezpath_iter().collect::>(); @@ -1899,9 +1917,11 @@ async fn scatter_points( #[range] #[hard(0.01..)] #[soft(1..100)] - separation: f64, + separation: Item, seed: SeedValue, ) -> Item { + let separation = *separation.element(); + let mut rng = rand::rngs::StdRng::seed_from_u64(seed.into()); let mut result = Vector::default(); @@ -2038,13 +2058,15 @@ async fn jitter_points( /// The maximum extent of the random distance each point can be offset. #[default(5.)] #[unit(" px")] - max_distance: f64, + max_distance: Item, /// Seed used to determine unique variations on all randomized offsets. seed: SeedValue, /// Whether to offset anchor points along their normal direction (perpendicular to the path) or in a random direction. Free-floating and branching points have no normal direction, so they receive a random-angled offset regardless of this setting. #[default(true)] - along_normals: bool, + along_normals: Item, ) -> Item { + let (max_distance, along_normals) = (*max_distance.element(), *along_normals.element()); + let mut rng = rand::rngs::StdRng::seed_from_u64(seed.into()); let transform_attribute: DAffine2 = content.attribute_cloned_or_default(ATTR_TRANSFORM); let inverse_linear = inverse_linear_or_repair(transform_attribute.matrix2); @@ -3031,7 +3053,9 @@ fn bevel_algorithm(mut vector: Vector, transform: DAffine2, distance: f64) -> Ve } #[node_macro::node(category("Vector: Modifier"), path(core_types::vector))] -fn bevel(_: impl Ctx, source: Item, #[default(10.)] distance: Length) -> Item { +fn bevel(_: impl Ctx, source: Item, #[default(10.)] distance: Item) -> Item { + let distance = *distance.element(); + let transform: DAffine2 = source.attribute_cloned_or_default(ATTR_TRANSFORM); let (element, attributes) = source.into_parts(); @@ -3288,7 +3312,17 @@ mod test { #[tokio::test] async fn sample_polyline() { let path = BezPath::from_vec(vec![PathEl::MoveTo(Point::ZERO), PathEl::CurveTo(Point::ZERO, Point::new(100., 0.), Point::new(100., 0.))]); - let sample_polyline = super::sample_polyline(Footprint::default(), vector_item_from_bezpath(path), PointSpacingType::Separation, 30., 0, 0., 0., false).await; + let sample_polyline = super::sample_polyline( + Footprint::default(), + vector_item_from_bezpath(path), + PointSpacingType::Separation, + Item::new_from_element(30.), + 0, + Item::new_from_element(0.), + Item::new_from_element(0.), + Item::new_from_element(false), + ) + .await; let sample_polyline = sample_polyline.element(); assert_eq!(sample_polyline.point_domain.positions().len(), 4); for (pos, expected) in sample_polyline.point_domain.positions().iter().zip([DVec2::X * 0., DVec2::X * 30., DVec2::X * 60., DVec2::X * 90.]) { @@ -3298,7 +3332,17 @@ mod test { #[tokio::test] async fn sample_polyline_adaptive_spacing() { let path = BezPath::from_vec(vec![PathEl::MoveTo(Point::ZERO), PathEl::CurveTo(Point::ZERO, Point::new(100., 0.), Point::new(100., 0.))]); - let sample_polyline = super::sample_polyline(Footprint::default(), vector_item_from_bezpath(path), PointSpacingType::Separation, 18., 0, 45., 10., true).await; + let sample_polyline = super::sample_polyline( + Footprint::default(), + vector_item_from_bezpath(path), + PointSpacingType::Separation, + Item::new_from_element(18.), + 0, + Item::new_from_element(45.), + Item::new_from_element(10.), + Item::new_from_element(true), + ) + .await; let sample_polyline = sample_polyline.element(); assert_eq!(sample_polyline.point_domain.positions().len(), 4); for (pos, expected) in sample_polyline.point_domain.positions().iter().zip([DVec2::X * 45., DVec2::X * 60., DVec2::X * 75., DVec2::X * 90.]) { @@ -3310,7 +3354,7 @@ mod test { let poisson_points = super::scatter_points( Footprint::default(), vector_item_from_bezpath(Ellipse::from_rect(Rect::new(-50., -50., 50., 50.)).to_path(DEFAULT_ACCURACY)), - 10. * std::f64::consts::SQRT_2, + Item::new_from_element(10. * std::f64::consts::SQRT_2), 0, ) .await; @@ -3409,7 +3453,7 @@ mod test { #[tokio::test] async fn bevel_rect() { let source = Rect::new(0., 0., 100., 100.).to_path(DEFAULT_ACCURACY); - let beveled = super::bevel(Footprint::default(), vector_item_from_bezpath(source), 2_f64.sqrt() * 10.); + let beveled = super::bevel(Footprint::default(), vector_item_from_bezpath(source), Item::new_from_element(2_f64.sqrt() * 10.)); let beveled = beveled.element(); assert_eq!(beveled.point_domain.positions().len(), 8); @@ -3437,7 +3481,7 @@ mod test { source.line_to(Point::ZERO); source.push(curve.as_path_el()); - let beveled = super::bevel((), vector_item_from_bezpath(source), 2_f64.sqrt() * 10.); + let beveled = super::bevel((), vector_item_from_bezpath(source), Item::new_from_element(2_f64.sqrt() * 10.)); let beveled = beveled.element(); assert_eq!(beveled.point_domain.positions().len(), 4); @@ -3464,7 +3508,7 @@ mod test { let transform = DAffine2::from_scale_angle_translation(DVec2::splat(10.), 1., DVec2::new(99., 77.)); let vector_item = Item::new_from_element(Vector::from_bezpath(source)).with_attribute(ATTR_TRANSFORM, transform); - let beveled = super::bevel((), vector_item, 2_f64.sqrt() * 100.); + let beveled = super::bevel((), vector_item, Item::new_from_element(2_f64.sqrt() * 100.)); let beveled = beveled.element(); assert_eq!(beveled.point_domain.positions().len(), 4); @@ -3487,7 +3531,7 @@ mod test { source.line_to(Point::new(100., 100.)); source.line_to(Point::new(0., 100.)); - let beveled = super::bevel(Footprint::default(), vector_item_from_bezpath(source), 999.); + let beveled = super::bevel(Footprint::default(), vector_item_from_bezpath(source), Item::new_from_element(999.)); let beveled = beveled.element(); assert_eq!(beveled.point_domain.positions().len(), 6); @@ -3511,7 +3555,7 @@ mod test { let subpath = BezPath::from_path_segments([line, point, curve].into_iter()); - let beveled_item = super::bevel(Footprint::default(), vector_item_from_bezpath(subpath), 5.); + let beveled_item = super::bevel(Footprint::default(), vector_item_from_bezpath(subpath), Item::new_from_element(5.)); let beveled = beveled_item.element(); assert_eq!(beveled.point_domain.positions().len(), 6); From 2e7fe14d6e018403b5de645ddc2ba5026c3ca548 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Fri, 3 Jul 2026 21:20:32 -0700 Subject: [PATCH 029/165] Rank the enum, integer, and seed parameters, registering their rank adapters via a consolidated macro --- .../interpreted-executor/src/node_registry.rs | 80 ++++++++----------- node-graph/nodes/blending/src/lib.rs | 4 +- node-graph/nodes/vector/src/vector_nodes.rs | 37 ++++----- 3 files changed, 57 insertions(+), 64 deletions(-) diff --git a/node-graph/interpreted-executor/src/node_registry.rs b/node-graph/interpreted-executor/src/node_registry.rs index b5ce41ed3b..17d723bd3b 100644 --- a/node-graph/interpreted-executor/src/node_registry.rs +++ b/node-graph/interpreted-executor/src/node_registry.rs @@ -20,6 +20,8 @@ use graphene_std::render_node::RenderIntermediate; use graphene_std::transform::Footprint; use graphene_std::uuid::NodeId; use graphene_std::vector::Vector; +use graphene_std::vector::misc::{ExtrudeJoiningAlgorithm, MergeByDistanceAlgorithm, PointSpacingType}; +use graphene_std::vector::style::StrokeJoin; use graphene_std::{Artboard, Context, Graphic, NodeIO, NodeIOTypes, ProtoNodeIdentifier, concrete, fn_type_fut, future}; use node_registry_macros::{async_node, convert_node, into_node}; use std::collections::HashMap; @@ -380,51 +382,39 @@ fn node_registry() -> HashMap), - promote_node!(element: Graphic), - promote_node!(element: Color), - promote_node!(element: GradientStops), - promote_node!(element: String), - promote_node!(element: f64), - promote_node!(element: DVec2), - promote_node!(element: DAffine2), - promote_node!(element: bool), - ] - .into_iter() - .flatten(), - ); - let item_to_list_nodes: Vec<(ProtoNodeIdentifier, NodeConstructor, NodeIOTypes)> = vec![ - item_to_list_node!(element: Vector), - item_to_list_node!(element: Raster), - item_to_list_node!(element: Graphic), - item_to_list_node!(element: Color), - item_to_list_node!(element: GradientStops), - item_to_list_node!(element: String), - item_to_list_node!(element: f64), - item_to_list_node!(element: DVec2), - item_to_list_node!(element: DAffine2), - item_to_list_node!(element: bool), - ]; - node_types.extend(item_to_list_nodes); - let wrap_item_nodes: Vec<(ProtoNodeIdentifier, NodeConstructor, NodeIOTypes)> = vec![ - wrap_item_node!(element: Vector), - wrap_item_node!(element: Raster), - wrap_item_node!(element: Graphic), - wrap_item_node!(element: Color), - wrap_item_node!(element: GradientStops), - wrap_item_node!(element: String), - wrap_item_node!(element: f64), - wrap_item_node!(element: DVec2), - wrap_item_node!(element: DAffine2), - wrap_item_node!(element: bool), - ]; - node_types.extend(wrap_item_nodes); + // ================== + // RANK ADAPTER NODES + // ================== + // Registers all three rank adapters (PromoteNode, ItemToListNode, WrapItemNode) for each promotable element type + macro_rules! rank_adapter_nodes { + ($($element:ty),* $(,)?) => {{ + let mut entries: Vec<(ProtoNodeIdentifier, NodeConstructor, NodeIOTypes)> = Vec::new(); + $( + entries.extend(promote_node!(element: $element)); + entries.push(item_to_list_node!(element: $element)); + entries.push(wrap_item_node!(element: $element)); + )* + entries + }}; + } + node_types.extend(rank_adapter_nodes!( + Vector, + Raster, + Graphic, + Color, + GradientStops, + String, + f64, + DVec2, + DAffine2, + bool, + u32, + BlendMode, + MergeByDistanceAlgorithm, + ExtrudeJoiningAlgorithm, + StrokeJoin, + PointSpacingType, + )); // ============= // CONVERT NODES // ============= diff --git a/node-graph/nodes/blending/src/lib.rs b/node-graph/nodes/blending/src/lib.rs index dd26166daf..68acd206f3 100644 --- a/node-graph/nodes/blending/src/lib.rs +++ b/node-graph/nodes/blending/src/lib.rs @@ -14,8 +14,10 @@ fn blend_mode( #[implementations(Graphic, Vector, Raster, Color, GradientStops, String)] mut content: Item, /// The choice of equation that controls how brightness and color blends between overlapping pixels. - blend_mode: BlendMode, + blend_mode: Item, ) -> Item { + let blend_mode = *blend_mode.element(); + content.set_attribute(ATTR_BLEND_MODE, blend_mode); content } diff --git a/node-graph/nodes/vector/src/vector_nodes.rs b/node-graph/nodes/vector/src/vector_nodes.rs index 4f1e5cf280..ab9e150bbb 100644 --- a/node-graph/nodes/vector/src/vector_nodes.rs +++ b/node-graph/nodes/vector/src/vector_nodes.rs @@ -554,9 +554,9 @@ pub fn merge_by_distance( #[default(0.1)] #[hard(0.0001..)] distance: Item, - algorithm: MergeByDistanceAlgorithm, + algorithm: Item, ) -> Item { - let distance = *distance.element(); + let (distance, algorithm) = (*distance.element(), *algorithm.element()); match algorithm { MergeByDistanceAlgorithm::Spatial => { @@ -767,8 +767,8 @@ pub mod extrude_algorithms { } #[node_macro::node(category("Vector: Modifier"), path(core_types::vector))] -async fn extrude(_: impl Ctx, mut source: Item, direction: Item, joining_algorithm: ExtrudeJoiningAlgorithm) -> Item { - let direction = *direction.element(); +async fn extrude(_: impl Ctx, mut source: Item, direction: Item, joining_algorithm: Item) -> Item { + let (direction, joining_algorithm) = (*direction.element(), *joining_algorithm.element()); extrude_algorithms::extrude(source.element_mut(), direction, joining_algorithm); source @@ -1197,8 +1197,8 @@ async fn points_to_polyline(_: impl Ctx, mut points: Item, #[default(tru } #[node_macro::node(category("Vector: Modifier"), path(core_types::vector), properties("offset_path_properties"))] -async fn offset_path(_: impl Ctx, mut content: Item, distance: Item, join: StrokeJoin, #[default(4.)] miter_limit: Item) -> Item { - let (distance, miter_limit) = (*distance.element(), *miter_limit.element()); +async fn offset_path(_: impl Ctx, mut content: Item, distance: Item, join: Item, #[default(4.)] miter_limit: Item) -> Item { + let (distance, join, miter_limit) = (*distance.element(), *join.element(), *miter_limit.element()); let transform_attribute: DAffine2 = content.attribute_cloned_or_default(ATTR_TRANSFORM); let transform = Affine::new(transform_attribute.to_cols_array()); @@ -1469,14 +1469,14 @@ pub async fn flatten_path(_: impl Ctx, #[implementations(Lis async fn sample_polyline( _: impl Ctx, mut content: Item, - spacing: PointSpacingType, + spacing: Item, #[default(100.)] #[hard(0..)] #[unit(" px")] separation: Item, #[default(100)] #[hard(2..)] - quantity: u32, + quantity: Item, #[hard(0..)] #[unit(" px")] start_offset: Item, @@ -1485,7 +1485,8 @@ async fn sample_polyline( stop_offset: Item, adaptive_spacing: Item, ) -> Item { - let (separation, start_offset, stop_offset, adaptive_spacing) = (*separation.element(), *start_offset.element(), *stop_offset.element(), *adaptive_spacing.element()); + let (spacing, separation, quantity) = (*spacing.element(), *separation.element(), *quantity.element()); + let (start_offset, stop_offset, adaptive_spacing) = (*start_offset.element(), *stop_offset.element(), *adaptive_spacing.element()); let pathseg_perimeter = |segment: PathSeg| { if is_linear(segment) { @@ -1918,9 +1919,9 @@ async fn scatter_points( #[hard(0.01..)] #[soft(1..100)] separation: Item, - seed: SeedValue, + seed: Item, ) -> Item { - let separation = *separation.element(); + let (separation, seed) = (*separation.element(), *seed.element()); let mut rng = rand::rngs::StdRng::seed_from_u64(seed.into()); @@ -2060,12 +2061,12 @@ async fn jitter_points( #[unit(" px")] max_distance: Item, /// Seed used to determine unique variations on all randomized offsets. - seed: SeedValue, + seed: Item, /// Whether to offset anchor points along their normal direction (perpendicular to the path) or in a random direction. Free-floating and branching points have no normal direction, so they receive a random-angled offset regardless of this setting. #[default(true)] along_normals: Item, ) -> Item { - let (max_distance, along_normals) = (*max_distance.element(), *along_normals.element()); + let (max_distance, seed, along_normals) = (*max_distance.element(), *seed.element(), *along_normals.element()); let mut rng = rand::rngs::StdRng::seed_from_u64(seed.into()); let transform_attribute: DAffine2 = content.attribute_cloned_or_default(ATTR_TRANSFORM); @@ -3315,9 +3316,9 @@ mod test { let sample_polyline = super::sample_polyline( Footprint::default(), vector_item_from_bezpath(path), - PointSpacingType::Separation, + Item::new_from_element(PointSpacingType::Separation), Item::new_from_element(30.), - 0, + Item::new_from_element(0), Item::new_from_element(0.), Item::new_from_element(0.), Item::new_from_element(false), @@ -3335,9 +3336,9 @@ mod test { let sample_polyline = super::sample_polyline( Footprint::default(), vector_item_from_bezpath(path), - PointSpacingType::Separation, + Item::new_from_element(PointSpacingType::Separation), Item::new_from_element(18.), - 0, + Item::new_from_element(0), Item::new_from_element(45.), Item::new_from_element(10.), Item::new_from_element(true), @@ -3355,7 +3356,7 @@ mod test { Footprint::default(), vector_item_from_bezpath(Ellipse::from_rect(Rect::new(-50., -50., 50., 50.)).to_path(DEFAULT_ACCURACY)), Item::new_from_element(10. * std::f64::consts::SQRT_2), - 0, + Item::new_from_element(0), ) .await; let poisson_points = poisson_points.element(); From 048d4834784f808a046900ec854639c5c257b736 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Fri, 3 Jul 2026 21:35:58 -0700 Subject: [PATCH 030/165] Amend the audit with the DashPattern value type resolution --- rank-polymorphism-node-audit.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/rank-polymorphism-node-audit.md b/rank-polymorphism-node-audit.md index 700b91d3e3..89c5f11cac 100644 --- a/rank-polymorphism-node-audit.md +++ b/rank-polymorphism-node-audit.md @@ -49,7 +49,8 @@ By area: math 57 (100% element-wise), vector 58, graphic 31, text 33, raster 31, 7. **Nodes define their own valid domains.** Negative from-end indexing and clamping are legitimate node semantics, not failures; `Default::default()` applies only when the node itself has no defined answer. Index Points keeps its behavior unchanged. 8. **Some / Unwrap Option / Size Of deleted** — vestigial and unused. 9. **Byte-blob unification adopted:** a rank-0 blob type replaces `List`; Post Request's body, String to Bytes, and Image to Bytes become element-wise. -10. **Text to Vector splits in two:** the compound-path-per-string node (element-wise) and a Separate Glyphs expander. +10. **Dash patterns become a `DashPattern` value type** (amending the Stroke row's rank-1 `List` cell): `List` is reserved for frames and attribute-carrying collections; compound values whose inner elements never hold attributes (dash sequences, corner radii) are value types, keeping their connectors rank 0 and frameable. Cascades to the Vec-looking TaggedValue variants. +11. **Text to Vector splits in two:** the compound-path-per-string node (element-wise) and a Separate Glyphs expander. ## Deletions, merges, and splits From ebbefc9c404b2b5f764e3478e912a298bbfe6d0b Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Sun, 5 Jul 2026 01:24:30 -0700 Subject: [PATCH 031/165] Migrate the string family to Item element-wise kernels --- .../interpreted-executor/src/node_registry.rs | 2 + node-graph/nodes/text/src/json.rs | 56 ++-- node-graph/nodes/text/src/lib.rs | 269 +++++++++++------- node-graph/nodes/text/src/regex.rs | 30 +- 4 files changed, 221 insertions(+), 136 deletions(-) diff --git a/node-graph/interpreted-executor/src/node_registry.rs b/node-graph/interpreted-executor/src/node_registry.rs index 17d723bd3b..0ee49df51c 100644 --- a/node-graph/interpreted-executor/src/node_registry.rs +++ b/node-graph/interpreted-executor/src/node_registry.rs @@ -17,6 +17,7 @@ use graphene_std::raster::color::Color; use graphene_std::raster::*; use graphene_std::raster::{CPU, Raster}; use graphene_std::render_node::RenderIntermediate; +use graphene_std::text_nodes::StringCapitalization; use graphene_std::transform::Footprint; use graphene_std::uuid::NodeId; use graphene_std::vector::Vector; @@ -414,6 +415,7 @@ fn node_registry() -> HashMap, /// Removes optional spaces within curly brackets and after colons and commas. - compact: bool, + compact: Item, /// Break arrays and objects across multiple lines when they exceed the line break length. #[default(true)] #[name("Multi-Line")] - multi_line: bool, + multi_line: Item, /// The indentation string used for each nesting level. Escape sequences like `\t` (the tab character) are supported. Two or four spaces are also common choices. #[default("\\t")] - indent: String, + indent: Item, /// The maximum line length before a container (array or object) is broken across lines. Set this to 0 to always break containers. (Requires *Multi-Line* to take effect.) /// /// This is not a maximum line length guarantee. Deep nesting and long keys or values may exceed this length. #[default(120)] - break_length: u32, + break_length: Item, /// Always break a container (array or object) across lines if it holds another container, even if it would fit within the break length. (Requires *Multi-Line* to take effect.) #[default(true)] - break_nested: bool, -) -> String { - let cleaned = strip_trailing_commas(&json); + break_nested: Item, +) -> Item { + let mut json = json; + let (compact, multi_line, break_length, break_nested) = (*compact.element(), *multi_line.element(), *break_length.element(), *break_nested.element()); + let indent = indent.element().clone(); + + let cleaned = strip_trailing_commas(json.element()); let Ok(value) = serde_json::from_str::(&cleaned) else { return json }; let indent = unescape_string(indent); let colon = if compact { ":" } else { ": " }; let comma_space = if compact { "," } else { ", " }; let line_width = break_length as usize; - if multi_line { + let result = if multi_line { format_value(&value, 0, &indent, colon, comma_space, compact, break_nested, line_width) } else { format_inline(&value, colon, comma_space, compact) - } + }; + + *json.element_mut() = result; + json } /// Strips trailing commas before `]` and `}` to accept JSON-with-trailing-commas input. @@ -188,7 +195,7 @@ fn query_json( _: impl Ctx, /// The JSON string to extract a value from. #[name("JSON")] - json: String, + mut json: Item, /// Determines which contained value to extract from within the JSON. /// /// The path syntax is like JavaScript's accessor syntax that follows an array/object value. It also supports negative indexing to count backwards from the end. Additionally, `[]` accesses all array and object values instead of just one. @@ -198,19 +205,28 @@ fn query_json( /// Use `.size` or `["size"]` to get the `size` property of `{ "size": 10 }`. The latter form is required if the key contains spaces or special characters like `["this key with spaces!"]`. /// Use chained accessors like `.fonts[0].name` to query deeper. /// Use the `[]` accessor to query all elements, like `.fonts[].weights[]` to get every weight of every font. - path: String, + path: Item, /// Strips the surrounding double quotes from string values, returning the raw text. Other types are never wrapped in quotes. #[default(true)] - unquote_strings: bool, -) -> String { - let cleaned = strip_trailing_commas(&json); - let Ok(value): Result = serde_json::from_str(&cleaned) else { return String::new() }; - let Some(segments) = parse_json_path(path.trim()) else { return String::new() }; + unquote_strings: Item, +) -> Item { + let path = path.element().clone(); + let unquote_strings = *unquote_strings.element(); - let mut results = Vec::new(); - resolve_all(&value, &segments, !unquote_strings, &mut results); + let cleaned = strip_trailing_commas(json.element()); + + let result = match (serde_json::from_str::(&cleaned), parse_json_path(path.trim())) { + (Ok(value), Some(segments)) => { + let mut results = Vec::new(); + resolve_all(&value, &segments, !unquote_strings, &mut results); + + results.into_iter().next().map(|(text, _ty)| text).unwrap_or_default() + } + _ => String::new(), + }; - results.into_iter().next().map(|(text, _ty)| text).unwrap_or_default() + *json.element_mut() = result; + json } /// Extracts every matched value from a JSON string using a path expression (see that parameter's description for its syntax). A list of zero or more resultant strings is produced. The `[]` path accessor is used to read more than one value. diff --git a/node-graph/nodes/text/src/lib.rs b/node-graph/nodes/text/src/lib.rs index 489ce33d54..2a16650028 100644 --- a/node-graph/nodes/text/src/lib.rs +++ b/node-graph/nodes/text/src/lib.rs @@ -190,28 +190,37 @@ fn string_value(_: impl Ctx, _primary: (), string: TextArea) -> String { /// Type-asserts a value to be a string. #[node_macro::node(category("Debug"))] -fn as_string(_: impl Ctx, value: String) -> String { +fn as_string(_: impl Ctx, value: Item) -> Item { value } /// Joins two strings together. #[node_macro::node(category("Text"))] -fn string_concatenate(_: impl Ctx, #[implementations(String)] first: String, second: TextArea) -> String { - first + &second +fn string_concatenate(_: impl Ctx, #[implementations(String)] first: Item, second: Item