Skip to content

Grida Canvas - Curve Decorations#538

Merged
softmarshmallow merged 17 commits intomainfrom
canary
Feb 14, 2026
Merged

Grida Canvas - Curve Decorations#538
softmarshmallow merged 17 commits intomainfrom
canary

Conversation

@softmarshmallow
Copy link
Copy Markdown
Member

@softmarshmallow softmarshmallow commented Feb 8, 2026

day-318-grida-canvas-curve-decoration.mp4

Curve Decorations (2D)

Renderer-agnostic model for attaching glyphs (arrowheads, markers, ticks) to 2D paths using the path's local frame.

feature id status description
curve-decoration draft Endpoint/mid-path markers, orientation, scale, offset

Abstract

This working group defines curve decorations: how arbitrary glyphs (arrowheads, dots, diamonds, dimension ticks) are attached to a 2D path and oriented by the path's local tangent/normal. Raster backends such as Skia only provide three native stroke caps (butt/round/square); custom endpoint styles are implemented as explicit marker geometry. The model unifies endpoint markers, mid-path markers, and repeated directional symbols under a single, arc-length-based placement and local-frame semantics, and is intended to be compatible with SVG markers, Skia, PDF, and CAD-style pipelines.


Motivation

Design tools commonly expose "line endpoints" like arrowheads, dots, diamonds, and dimension ticks. Those must be rendered as explicit marker geometry attached to a path, not as backend stroke caps.

This spec defines a renderer-agnostic model for attaching such glyphs to a 2D path using the path's local frame. It is intended to unify:

  • endpoint markers (arrowheads, dots, squares, etc.)
  • mid-path markers (at joins / along length)
  • repeated directional symbols
  • measurement / diagram markers

Path model and parameterization

Although we use "curve" informally, the engine deals with paths: piecewise contours comprised of line/quad/cubic segments.

We distinguish two parameterizations:

Curve parameter (segment parameter)

Each segment has its own parameter (e.g. Bézier $t\in[0,1]$). This is useful for geometry math but not stable for placement by distance.

Arc-length parameter (recommended for placement)

For placement, we use arc-length $\ell$ measured along a contour, or its normalized fraction $u\in[0,1]$:

  • $\ell \in [0, L]$ where $L$ is the contour length
  • $u = \ell / L \in [0,1]$

Unless explicitly stated otherwise, placement in this spec uses arc-length (absolute $\ell$ or normalized $u$).


Local frame on a path

For a placement position on a contour, we define:

  • position: $p$
  • unit tangent: $\hat{t}$
  • unit normal: $\hat{n}$

Tangent

At arc-length $\ell$, tangent is the direction of travel along the contour:

$$ \hat{t}(\ell) = \frac{d\gamma}{d\ell} $$

Normal (2D convention)

To make "normal offset" unambiguous, we define the left normal:

$$ \hat{n} = (-\hat{t}_y,; \hat{t}_x) $$

This means normal offsets are relative to the path direction. If orientation reverses $\hat{t}$, $\hat{n}$ also flips.

Degenerate tangent fallback

Real paths can contain degenerate segments (zero length, repeated points) where the tangent is undefined. Implementations should:

  • skip orientation for that decoration, or
  • search for the nearest non-zero tangent along the contour (preferred for endpoints/joins)

This document treats the fallback as an implementation policy, but the behavior must be deterministic.


Marker glyph

A MarkerGlyph is geometry defined in its own local "marker space".

Required properties:

  • geometry: an arbitrary 2D path/primitive set (filled and/or stroked)
  • anchor: a point $a$ in marker space that will be placed at $p$
  • forward axis: a unit vector $\hat{f}$ in marker space that represents the glyph's "forward" direction

Optional properties:

  • style override (fill/stroke/paint)
  • intrinsic rotation offset (if $\hat{f}$ is not the +X axis)

This is conceptually similar to SVG <marker> (refX/refY as anchor, orient as tangent alignment), but kept renderer-agnostic.


Placement

A Placement determines where decorations appear.

Attachment domains

For a given contour:

  • start: contour start ($\ell = 0$)
  • end: contour end ($\ell = L$)
  • joins: interior vertices (segment boundaries) on a piecewise path
  • at: explicit arc-length positions (absolute $\ell$) or fractions (normalized $u$)
  • every: repeated placement at regular arc-length intervals

Notes:

  • Endpoints are meaningful only for open contours. For closed contours, start and end coincide; endpoint placement is typically ignored or treated as at(u=0).
  • joins implies a piecewise path model; joins do not exist on a single analytic curve without segmentation.

Arc-length vs parameter (important)

If a placement is specified by "parameter value" on a Bézier segment, it is not proportional to distance and is rarely what users expect. For design-tool semantics, arc-length placement should be the default.

If we ever expose curve-parameter placement, it should be a separate explicit mode (e.g. at_param).

Multi-contour paths

Paths may have multiple contours (subpaths). Placement resolution must specify whether it applies:

  • per contour (SVG-style start/mid/end), or
  • to a flattened "entire path" ordering

For initial 2D editor semantics, per contour is recommended.


Orientation policy

Orientation controls how marker space is rotated relative to the local frame.

Policies

  • none: no tangent alignment (fixed world rotation)
  • auto: align glyph forward axis $\hat{f}$ to $\hat{t}$
  • auto-start-reverse: like SVG orient="auto-start-reverse"
    • end marker uses $\hat{t}$
    • start marker uses $-\hat{t}$ (so it points outward)

Join tangent selection (for joins)

At a join there are two natural tangents:

  • incoming: tangent approaching the vertex
  • outgoing: tangent leaving the vertex

For orientation at joins, define one of:

  • incoming
  • outgoing
  • bisector (angle bisector between incoming/outgoing; may require miter-limit style clamping)

Scale policy

Scale controls how marker glyphs size in world space.

  • absolute: fixed world-unit size
  • stroke-relative: proportional to effective stroke width at placement

If stroke width varies along the path (width profile), stroke-relative should use the local effective width at the placement position.


Offset

Offset is an optional translation relative to the local frame:

  • tangent offset: $o_t$ along $\hat{t}$
  • normal offset: $o_n$ along $\hat{n}$

World translation contribution:

$$ \Delta = o_t \hat{t} + o_n \hat{n} $$

Offsets are essential for:

  • pulling an arrowhead "back" so the tip sits on the endpoint
  • drawing dimension ticks slightly off the stroke centerline

Transform composition (conceptual)

For an arc-length position $\ell$, with position $p$, tangent $\hat{t}$, and marker anchor $a$, the marker transform is conceptually:

$$ M(\ell) = \text{Translate}(p + \Delta) \cdot \text{Rotate}(\theta) \cdot \text{Scale}(k) \cdot \text{Translate}(-a) $$

Where:

  • $\theta = \text{atan2}(\hat{t}_y, \hat{t}x) + \theta{\text{intrinsic}}$ for auto policies
  • for none, $\theta$ is a fixed world rotation
  • $k$ comes from the scale policy
  • $\Delta$ comes from offset (see Offset section)

The exact multiplication order depends on engine conventions, but the intent is: anchor → scale → orient → place.


Rendering semantics (policy-level)

Marker glyphs are separate geometry. Two practical semantics matter:

Draw order

Default: stroke path first, then draw markers on top. This matches most design tools.

Cutback / trimming (optional)

Many arrowheads look visually incorrect if the base stroke runs under the marker. A common solution is to cut back the stroked path near endpoints by a marker-dependent distance before stroking.

Cutback is not required to define the decoration model, but should be acknowledged as a likely implementation policy.


Relationship to existing models

SVG markers

SVG is a specific instance of this model:

  • placements: start/mid/end
  • orientation: auto / auto-start-reverse
  • marker anchor: refX/refY
  • scaling: markerUnits (often strokeWidth)

Curve Decorations are intended to be at least as expressive, while remaining backend-agnostic.

Stroke caps (backend caps vs decorations)

Classic stroke caps (butt/round/square) are natively supported by many renderers (including Skia) and should usually remain a paint/stroke style property for performance and fidelity.

Custom "caps" (arrowheads, diamonds, circles, etc.) are best represented as endpoint placements of curve decorations.

In other words:

  • backend cap styles are still used when available
  • curve decorations cover the generalized marker cases

Minimal conceptual schema

Conceptually:

CurveDecoration
 ├─ glyph: MarkerGlyph
 ├─ placement: Placement
 ├─ orient: OrientationPolicy
 ├─ scale: ScalePolicy
 └─ offset: Offset

This decomposition covers common 2D editor needs without introducing overlapping primitives.


Implementation note (Skia viability)

Skia provides arc-length traversal utilities that return contour length $L$ and position plus tangent at distance $\ell$. Endpoint and along-path placement are therefore straightforward: evaluate the local frame at $\ell=0$ and $\ell=L$ for endpoint markers, and negate the tangent for start markers when using auto-start-reverse.


Design goals and non-goals

Design goals

The model aims to be:

  • renderer-agnostic: Skia, SVG, PDF, CAD-style pipelines
  • precise: explicit arc-length placement and local frame definitions
  • extensible: repeated markers, join semantics, variable stroke widths
  • collaboration-friendly: stable parameterization options for CRDT usage

Non-goals (initial scope)

Out of scope for this spec version:

  • continuous extrusion / procedural brushes
  • full along-curve ornament fields (texture-like decoration)
  • 3D curve decorations

These can be layered later on top of the same "attach glyphs to a path" primitive.


Terminology

Term Meaning
Path / contour Piecewise curve, possibly multiple subpaths
Curve Decoration Glyph attached to a path via local frame evaluation
MarkerGlyph Geometry in marker space with anchor + forward axis
Placement Where to attach (endpoints, joins, arc-length positions, repeated)
Orientation policy How to align glyph relative to tangent

Summary by CodeRabbit

  • New Features

    • Stroke marker presets for lines/vectors (arrow, triangle, circle, square, diamond, vertical bar) and endpoint rendering.
    • Arrow draw tool (Shift+L) to create arrowed lines.
    • Editor UI: stroke decoration controls to set start/end endpoint decorations.
  • Documentation

    • Added Curve Decorations (2D) reference guide.
  • Chores

    • Removed Prettier config and added Oxfmt formatting config.

@vercel
Copy link
Copy Markdown

vercel Bot commented Feb 8, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
docs Ready Ready Preview, Comment Feb 14, 2026 10:14am
grida Ready Ready Preview, Comment Feb 14, 2026 10:14am
5 Skipped Deployments
Project Deployment Actions Updated (UTC)
code Ignored Ignored Feb 14, 2026 10:14am
legacy Ignored Ignored Feb 14, 2026 10:14am
backgrounds Skipped Skipped Feb 14, 2026 10:14am
blog Skipped Skipped Feb 14, 2026 10:14am
viewer Skipped Skipped Feb 14, 2026 10:14am

Request Review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Feb 8, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds a stroke marker decoration system: new StrokeMarkerPreset types, per-node marker_start_shape/marker_end_shape fields, FlatBuffers/TS mappings, marker geometry and rendering (cutback-aware trimming), editor UI/tools/controls for selecting presets, example goldens, and related docs/config cleanup.

Changes

Cohort / File(s) Summary
Configuration
\.oxfmtrc.jsonc, \.prettierrc, package.json
Removed Prettier config and devDependency; minor .oxfmtrc comment cleanup.
Core types & exports
crates/grida-canvas/src/cg/types.rs, packages/grida-canvas-cg/lib.ts, packages/grida-canvas-schema/grida.ts
Introduce public StrokeMarkerPreset (variants: none, right_triangle_open, equilateral_triangle, circle, square, diamond, vertical_bar) and helpers; TS type added to cg public API.
FlatBuffers schema
format/grida.fbs
Add StrokeMarkerPreset enum and marker_start_shape/marker_end_shape fields to LineNode and VectorNode.
Marker subsystem
crates/grida-canvas/src/shape/marker.rs, crates/grida-canvas/src/shape/mod.rs
New marker geometry/rendering module: MarkerAnchor/BuiltinMarker/MarkerShape, cutback solvers, path trimming, and draw_* APIs.
Painter & rendering integration
crates/grida-canvas/src/painter/layer.rs, crates/grida-canvas/src/painter/painter.rs, crates/grida-canvas/src/painter/painter_debug_node.rs
Propagate marker fields into layers; add stroke_width propagation; add draw_stroke_decorations() and integrate cutback-aware trimming and marker rendering into shape/vector pipelines.
Node schema & construction
crates/grida-canvas/src/node/schema.rs, crates/grida-canvas/src/node/factory.rs, crates/grida-canvas/src/layout/engine.rs
Add marker_start_shape/marker_end_shape to LineNodeRec and VectorNodeRec and initialize with default in constructors/tests.
IO & serialization
crates/grida-canvas/src/io/io_figma.rs, crates/grida-canvas/src/io/io_grida.rs, packages/grida-canvas-io/format.ts
Encode/decode marker fields for Figma/Grida and add STROKE_MARKER_PRESET_ENCODE/DECODE mappings for FlatBuffers↔TS.
Editor API & state
editor/grida-canvas/editor.i.ts, editor/grida-canvas/editor.ts
Add editor actions: changeNodePropertyStrokeDecorationStart/End; extend GestureDraw mode to include "arrow".
Editor reducers & tools
editor/grida-canvas/reducers/node.reducer.ts, editor/grida-canvas/reducers/event-target.cem-vector.reducer.ts
Add safe-properties handlers for marker_start/end; add "arrow" tool path that initializes marker_end_shape to right_triangle_open and shares draw flow with line.
Feature support mapping
editor/grida-canvas/utils/supports.ts
Add strokeDecorationStart/End feature properties and support checks (line, vector) for DOM and canvas backends.
UI actions & hotkeys
editor/grida-canvas-react/provider.tsx, editor/grida-canvas/viewport/hotkeys.tsx, editor/grida-canvas-hosted/playground/uxhost-*
Expose strokeDecorationStart/End action hooks; add Shift+L hotkey for arrow tool; add hosted toolbar/playground entries for "arrow".
Editor UI controls
editor/scaffolds/sidecontrol/controls/stroke-decoration.tsx, editor/scaffolds/sidecontrol/controls/icons/stroke-decoration-icons.tsx, editor/scaffolds/sidecontrol/chunks/section-strokes.tsx
New StrokeDecorationControl component, responsive/fixed SVG icons and mappings (iconByValue/markerByValue), and UI integration in Strokes panel.
Property UI extension
editor/scaffolds/sidecontrol/ui/index.tsx
Add renderTriggerValue prop to PropertyEnum to allow custom trigger rendering for icons.
Examples & tests
crates/grida-canvas/examples/golden_*.rs, crates/grida-canvas/tests/*, crates/grida-dev/examples/grida_vector.rs
Add three golden examples demonstrating decorations; update tests/examples to initialize new marker fields.
Docs & guides
docs/wg/feat-2d/curve-decoration.md, docs/editor/shortcuts/index.md, AGENTS.md, format/AGENTS.md, format/TODO.md
Add curve-decoration spec, Arrow tool shortcut, AGENTS docs, and FlatBuffers TODO/backlog items.
Misc small changes
crates/grida-canvas-fonts/src/parse_ui.rs, crates/grida-canvas/src/devtools/hit_overlay.rs, crates/grida-canvas/src/layout/tree.rs, packages/grida-canvas-vn/vn.ts, crates/grida-canvas-wasm/package.json
Minor signature/lifetime tweak, no-op placeholder, unused parameter rename, doc comments, and version bump.

Sequence Diagram

sequenceDiagram
    actor User
    participant EditorUI as Editor UI
    participant EditorStore as Editor Store
    participant Painter as Painter
    participant RenderEngine as Render Engine

    User->>EditorUI: choose marker preset (start/end)
    EditorUI->>EditorStore: changeNodePropertyStrokeDecoration*(node_id, preset)
    EditorStore->>EditorStore: dispatch node/change marker_*_shape
    EditorStore->>Painter: node updated (includes marker_start_shape/end)
    Painter->>Painter: build layers (carry marker fields, stroke_width)
    Painter->>RenderEngine: compute cutback = cutback_depth(preset, stroke_width)
    RenderEngine->>RenderEngine: if cutback>0 trim path and compute stroke geometry
    RenderEngine->>Painter: render stroke (trimmed/untrimmed as required)
    Painter->>RenderEngine: draw_stroke_decorations(path, start, end, stroke_width)
    RenderEngine->>RenderEngine: position & draw marker shapes at endpoints via PathMeasure
    RenderEngine->>User: rasterized canvas with endpoint markers
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The PR title 'Grida Canvas - Curve Decorations' directly aligns with the main objective: adding stroke decorations (markers/glyphs) for 2D paths. The title is specific, concise, and clearly summarizes the core feature being introduced.
Docstring Coverage ✅ Passed Docstring coverage is 91.43% which is sufficient. The required threshold is 80.00%.
Merge Conflict Detection ✅ Passed ✅ No merge conflicts detected when merging into main
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch canary

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

- Introduced a new document detailing the renderer-agnostic model for attaching glyphs (arrowheads, markers, ticks) to 2D paths.
- Defined parameters for curve and arc-length placement, local frame orientation, and marker glyph properties.
- Established policies for orientation, scale, and offset to enhance the rendering of decorations on paths.
- Aimed to unify various marker types and improve compatibility with existing graphics standards.
- Introduced a new example demonstrating the placement model for curve decorations, showcasing various features such as endpoint markers, auto-start-reverse, join markers, and repeated placements.
- Included functionality to generate and save a visual representation of the decorations applied to different path types (straight, polyline, curve).
- Updated documentation to reflect the new example and its relevance to the curve-decoration feature.
- Enhanced the AGENTS.md file to present project structure in a tabular format for better clarity and organization.
- Introduced a new format/AGENTS.md file detailing the guidelines for LLM agents working with the .grida file format, including schema evolution strategies and compatibility considerations.
@softmarshmallow softmarshmallow added breaking cg Core Graphics labels Feb 9, 2026
- Added a new `StrokeDecoration` enum to represent various marker decorations at stroke endpoints, including arrows, circles, diamonds, and triangles.
- Implemented functionality to draw these decorations in the rendering pipeline, ensuring they are correctly positioned and oriented based on stroke paths.
- Created a new example demonstrating the use of stroke decorations in the Grida canvas, showcasing different decoration types and their visual representation.
- Updated relevant data structures and serialization methods to support stroke decorations in line and vector nodes.
- Enhanced the editor interface to allow users to set stroke decorations for line and vector nodes.
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🤖 Fix all issues with AI agents
In `@crates/grida-canvas/examples/golden_curve_decoration.rs`:
- Around line 94-104: The write to goldens/curve_decoration.png can panic if the
goldens directory doesn't exist; before calling std::fs::write (which is invoked
with the data variable after image.encode and before println!), ensure the
directory exists by creating it (use std::fs::create_dir_all on the parent
directory of the concat!(env!("CARGO_MANIFEST_DIR"),
"/goldens/curve_decoration.png") path) and handle or unwrap the result
appropriately so the example doesn't panic when run the first time.

In `@crates/grida-canvas/examples/golden_stroke_decoration.rs`:
- Around line 294-299: The code writes to the path stored in `out` without
ensuring its parent directory exists; before calling `std::fs::write(out, ...)`
(in the example around `out` and the write/println), call
`std::fs::create_dir_all` on the parent directory of `out` (use
`std::path::Path::new(out).parent()`) to create the `goldens` directory if
missing, then proceed with the write and keep existing error handling (e.g.,
unwrap) or propagate errors as appropriate.

In `@crates/grida-canvas/src/shape/marker.rs`:
- Line 14: Remove the unused import Point from the skia_safe import list in
crates/grida-canvas/src/shape/marker.rs: update the use statement that currently
references Canvas, Paint, PaintStyle, Path, PathBuilder, PathMeasure, Point to
drop Point so the import only includes symbols actually used (Canvas, Paint,
PaintStyle, Path, PathBuilder, PathMeasure), then run cargo build to confirm the
unused-import error is resolved.

In `@docs/wg/feat-2d/curve-decoration.md`:
- Around line 280-286: The circular cutback formula for c (c = r - sqrt(r^2 -
(w/2)^2)) currently jumps to c = 2r at the boundary w/2 = r; update the text to
avoid the discontinuity by either (a) changing the clamp wording to explicitly
state it's a conservative clamp and why, or (b) replace the branch with a
clamped expression c = min(2*r, r - sqrt(r^2 - (w/2)^2)) and optionally mention
smoothing for a visually continuous transition; reference the variables c, r,
and w in the description so readers can locate and understand the change.

In `@packages/grida-canvas-vn/vn.ts`:
- Around line 12-16: The doc comment for VectorNetworkVertex contains a dangling
{`@link` VectorNetworkVertexWithOverrides} that doesn't exist; fix by either
removing the {`@link` VectorNetworkVertexWithOverrides} reference from the JSDoc
above the export type VectorNetworkVertex, or if intended, add a new exported
type/interface named VectorNetworkVertexWithOverrides (matching the referenced
name) and update the comment to point to it; ensure the symbol you add or the
edited comment remains consistent with VectorNetworkVertex usage.
🧹 Nitpick comments (6)
crates/grida-canvas/src/vectornetwork/vn.rs (1)

5-13: Add PartialEq derive for consistency with VectorNetworkSegment.

Since StrokeDecoration derives PartialEq and VectorNetworkSegment (line 15) derives it, VectorVertexOverride should too for consistency and to enable comparison-based testing.

♻️ Suggested change
-#[derive(Debug, Clone)]
+#[derive(Debug, Clone, PartialEq)]
 pub struct VectorVertexOverride {
     pub vertex_index: usize,
     pub stroke_decoration: StrokeDecoration,
 }
crates/grida-canvas/src/io/io_grida.rs (1)

1828-1829: Prefer explicit StrokeDecoration::None over unwrap_or_default() for clarity.
Keeps deserialization semantics stable if Default ever changes.

♻️ Suggested change
-            stroke_decoration_start: node.base.stroke_decoration_start.unwrap_or_default(),
-            stroke_decoration_end: node.base.stroke_decoration_end.unwrap_or_default(),
+            stroke_decoration_start: node
+                .base
+                .stroke_decoration_start
+                .unwrap_or(StrokeDecoration::None),
+            stroke_decoration_end: node
+                .base
+                .stroke_decoration_end
+                .unwrap_or(StrokeDecoration::None),
-            stroke_decoration_start: node.base.stroke_decoration_start.unwrap_or_default(),
-            stroke_decoration_end: node.base.stroke_decoration_end.unwrap_or_default(),
+            stroke_decoration_start: node
+                .base
+                .stroke_decoration_start
+                .unwrap_or(StrokeDecoration::None),
+            stroke_decoration_end: node
+                .base
+                .stroke_decoration_end
+                .unwrap_or(StrokeDecoration::None),

Also applies to: 1886-1887

crates/grida-canvas/examples/golden_stroke_decoration.rs (1)

391-426: Consider using math2 primitives for the custom marker geometry.
The star/cross builders do manual trig; math2 vectors/angle helpers would keep geometry math consistent with the rest of the crate and ease reuse.
As per coding guidelines: Use math2 crate for geometry and common math operations.

crates/grida-canvas/src/painter/layer.rs (1)

425-427: Consider a helper or Default impl to reduce boilerplate for non-decoratable nodes.

Every non-Line/non-Vector shape node repeats the same three lines:

stroke_decoration_start: StrokeDecoration::None,
stroke_decoration_end: StrokeDecoration::None,
stroke_width: 0.0,

This appears ~10 times. A small constructor or Default-based builder for PainterPictureShapeLayer would reduce the surface area for future field additions and make adding new decoration-like features less error-prone.

Also applies to: 488-490, 541-543, 588-590, 635-637, 682-684, 729-731, 910-912, 999-1001, 1032-1034

editor/scaffolds/sidecontrol/controls/icons/stroke-decoration-icons.tsx (2)

195-217: VerticalBarFilledIcon uses stroke rendering while all other "filled" icons use fill="currentColor".

This icon uses fill="none" with a stroke path to draw the bar, whereas the other filled variants (triangle, circle, square, diamond) set fill="currentColor" and use closed <path>/<rect>/<circle> elements. The visual result may differ subtly (e.g., stroke join artifacts, line cap behavior). Consider using a filled <rect> for consistency — similar to how MarkerVerticalBarFilled (line 322) already does it with a filled rect.


366-388: Minor: if (!icon) return null on line 376 is unreachable.

iconByValue has an entry for every cg.StrokeDecoration value (including "none"), so icon will always be a valid ReactNode. The guard is harmless but dead code.

Comment thread crates/grida-canvas/examples/golden_curve_decoration.rs
Comment thread crates/grida-canvas/examples/golden_stroke_decoration.rs
Comment thread crates/grida-canvas/src/painter/painter.rs
Comment thread crates/grida-canvas/src/shape/marker.rs Outdated
Comment on lines +280 to +286
For a circle with radius $r$ and forward edge at the anchor:

$$
c = r - \sqrt{r^2 - (w/2)^2}
$$

When $w/2 \geq r$, the stroke is wider than the circle and $c = 2r$ (full diameter).
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Minor: circular cutback formula has a discontinuity at w/2 = r.

The formula c = r − √(r² − (w/2)²) yields c = r at w/2 = r, but the clamped branch states c = 2r. This creates a jump from r to 2r at the exact boundary. Consider noting this is a conservative clamp, or switching to c = min(2r, ...) with a smooth transition — though in practice the visual difference at that threshold is negligible.

🤖 Prompt for AI Agents
In `@docs/wg/feat-2d/curve-decoration.md` around lines 280 - 286, The circular
cutback formula for c (c = r - sqrt(r^2 - (w/2)^2)) currently jumps to c = 2r at
the boundary w/2 = r; update the text to avoid the discontinuity by either (a)
changing the clamp wording to explicitly state it's a conservative clamp and
why, or (b) replace the branch with a clamped expression c = min(2*r, r -
sqrt(r^2 - (w/2)^2)) and optionally mention smoothing for a visually continuous
transition; reference the variables c, r, and w in the description so readers
can locate and understand the change.

Comment on lines +12 to 16
*
* For basic vertices, this is a simple `[x, y]` tuple (Vector2).
* For vertices with decoration overrides, use {@link VectorNetworkVertexWithOverrides}.
*/
export type VectorNetworkVertex = Vector2;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# Search for VectorNetworkVertexWithOverrides definition across the codebase
rg -n 'VectorNetworkVertexWithOverrides' --type=ts

Repository: gridaco/grida

Length of output: 181


🏁 Script executed:

# Search for VectorNetworkVertexWithOverrides in all file types
rg 'VectorNetworkVertexWithOverrides' --type-list | head -20
rg 'VectorNetworkVertexWithOverrides' | grep -v '\.ts:.*@link'

Repository: gridaco/grida

Length of output: 446


🏁 Script executed:

# Search for VectorNetworkVertexWithOverrides in all files
rg 'VectorNetworkVertexWithOverrides'

Repository: gridaco/grida

Length of output: 178


Remove dangling @link reference: VectorNetworkVertexWithOverrides is not defined in the codebase.

The doc comment at line 14 references {@link VectorNetworkVertexWithOverrides}, but this type does not exist anywhere in the repository. Either define the type or remove the @link to prevent broken references for TypeScript tooling.

🤖 Prompt for AI Agents
In `@packages/grida-canvas-vn/vn.ts` around lines 12 - 16, The doc comment for
VectorNetworkVertex contains a dangling {`@link` VectorNetworkVertexWithOverrides}
that doesn't exist; fix by either removing the {`@link`
VectorNetworkVertexWithOverrides} reference from the JSDoc above the export type
VectorNetworkVertex, or if intended, add a new exported type/interface named
VectorNetworkVertexWithOverrides (matching the referenced name) and update the
comment to point to it; ensure the symbol you add or the edited comment remains
consistent with VectorNetworkVertex usage.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@crates/grida-canvas/src/shape/marker.rs`:
- Around line 127-131: The ArrowLines branch in BuiltinMarker::ArrowLines sets
cutback to a fixed stroke_width * 0.5 which ignores the anchor parameter t;
change MarkerShape creation in that branch to compute cutback via
lerp_cutback(terminal_cutback, depth, t) (using the same depth = size as already
computed) so the cutback scales with the anchor, or if fixed cutback is
intentional add a clarifying comment on why ArrowLines does not use
lerp_cutback; update the ArrowLines arm that builds path with
shift_path(&build_arrow_lines(size), t * depth) to use the lerp_cutback result
instead of the hard-coded value.
🧹 Nitpick comments (3)
crates/grida-canvas/src/devtools/hit_overlay.rs (1)

90-90: Dead computation: path is built but immediately dropped.

Lines 71–89 clone the path from cache and apply two matrix transforms, only for the result to be discarded here. Even in devtools code this is unnecessary work on every frame. Consider gating the computation behind a feature flag or cfg, or simply commenting out lines 71–89 until the path stroking feature lands.

crates/grida-canvas/src/shape/marker.rs (2)

126-165: Duplicated magic constants across marker_shape, cutback_depth, and shape builders.

The scaling factors (e.g., 0.8, 0.866, 1.5, 0.6, 1.2, 0.85) are repeated in marker_shape, cutback_depth, and the build_* functions. If one is changed without updating the others, the cutback/shape geometry will silently go out of sync.

Consider having each shape builder return a MarkerShape (or at least export its depth / half_height) so the constants live in one place. Alternatively, define named constants per preset.

Sketch: centralize constants per preset
// Example for TriangleFilled
mod triangle {
    pub const SCALE: f32 = 0.8;
    pub const DEPTH_FACTOR: f32 = 1.5;   // depth = size * SCALE * DEPTH_FACTOR
    pub const HEIGHT_FACTOR: f32 = 0.866; // half_h = size * SCALE * HEIGHT_FACTOR
}

Then reference triangle::SCALE etc. in build_triangle_terminal, marker_shape, and cutback_depth.


367-389: draw_decoration_at duplicates transform logic from draw_marker_shape_at.

The canvas save/translate/rotate/draw/restore sequence on lines 382–386 is identical to lines 203–207 in draw_marker_shape_at. Consider having draw_decoration_at delegate to draw_marker_shape_at after resolving the marker path and paint:

Proposed refactor
 pub fn draw_decoration_at(
     canvas: &Canvas,
     path_measure: &mut PathMeasure,
     distance: f32,
     decoration: StrokeDecoration,
     stroke_width: f32,
     paint: &Paint,
     reverse: bool,
 ) {
     let size = marker_size(stroke_width);
     if let Some(marker) = marker_path(decoration, size, stroke_width) {
-        if let Some((pos, tangent)) = path_measure.pos_tan(distance) {
-            let angle = tangent.y.atan2(tangent.x);
-            let flip = if reverse { PI } else { 0.0 };
-            let dp = decoration_paint(paint, decoration, stroke_width);
-            canvas.save();
-            canvas.translate(pos);
-            canvas.rotate((angle + flip) * 180.0 / PI, None);
-            canvas.draw_path(&marker, &dp);
-            canvas.restore();
-        }
+        let dp = decoration_paint(paint, decoration, stroke_width);
+        draw_marker_shape_at(canvas, path_measure, distance, &marker, &dp, reverse);
     }
 }

Comment thread crates/grida-canvas/src/shape/marker.rs Outdated
Comment on lines +127 to +131
BuiltinMarker::ArrowLines => {
let depth = size;
let path = shift_path(&build_arrow_lines(size), t * depth);
MarkerShape { path, cutback: stroke_width * 0.5 }
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

ArrowLines cutback ignores anchor parameter t.

Every other preset delegates to lerp_cutback(terminal_cutback, depth, t) so that the cutback scales with the anchor position. ArrowLines hard-codes stroke_width * 0.5 regardless of t. This means marker_shape(ArrowLines, Centroid, …) or marker_shape(ArrowLines, Offset(0.7), …) will shift the geometry but leave cutback unchanged—potentially causing a visible gap or overlap at non-terminal anchors.

If non-terminal anchors are not expected for ArrowLines, a comment explaining why would prevent future confusion. Otherwise, consider using lerp_cutback:

Proposed fix
 BuiltinMarker::ArrowLines => {
     let depth = size;
+    let terminal_cutback = stroke_width * 0.5;
     let path = shift_path(&build_arrow_lines(size), t * depth);
-    MarkerShape { path, cutback: stroke_width * 0.5 }
+    MarkerShape { path, cutback: lerp_cutback(terminal_cutback, depth, t) }
 }
🤖 Prompt for AI Agents
In `@crates/grida-canvas/src/shape/marker.rs` around lines 127 - 131, The
ArrowLines branch in BuiltinMarker::ArrowLines sets cutback to a fixed
stroke_width * 0.5 which ignores the anchor parameter t; change MarkerShape
creation in that branch to compute cutback via lerp_cutback(terminal_cutback,
depth, t) (using the same depth = size as already computed) so the cutback
scales with the anchor, or if fixed cutback is intentional add a clarifying
comment on why ArrowLines does not use lerp_cutback; update the ArrowLines arm
that builds path with shift_path(&build_arrow_lines(size), t * depth) to use the
lerp_cutback result instead of the hard-coded value.

Comment on lines +392 to +429
pub fn draw_endpoint_decorations(
canvas: &Canvas,
path: &Path,
start: StrokeDecoration,
end: StrokeDecoration,
stroke_width: f32,
stroke_paint: &Paint,
) {
if !start.has_marker() && !end.has_marker() {
return;
}

let mut measure = PathMeasure::new(path, false, None);
let length = measure.length();

if start.has_marker() {
draw_decoration_at(
canvas,
&mut measure,
0.0,
start,
stroke_width,
stroke_paint,
true,
);
}
if end.has_marker() {
draw_decoration_at(
canvas,
&mut measure,
length,
end,
stroke_width,
stroke_paint,
false,
);
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Decorations on zero-length or degenerate paths.

If the path has zero length (e.g., a single-point path), measure.length() returns 0.0. Both start and end decorations would call pos_tan(0.0), which on a degenerate path may return None (handled by the if let in draw_decoration_at), or may return a zero tangent vector causing atan2(0, 0) → undefined orientation. The PR spec mentions a deterministic fallback for degenerate tangents, but no fallback is implemented here.

This is unlikely to cause a crash (Skia will still render, just with an arbitrary rotation), but worth noting if the spec's fallback guarantee matters for visual consistency.

…lated functionality

- Renamed `StrokeDecoration` to `StrokeMarkerPreset` for clarity and consistency across the codebase.
- Updated examples and documentation to reflect the new naming convention.
- Adjusted rendering logic to utilize the new marker presets, ensuring accurate visual representation of markers.
- Enhanced the marker functionality by introducing new marker types and updating existing ones.
- Modified serialization and data structures to accommodate the changes in marker representation.
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@crates/grida-canvas/src/io/io_grida.rs`:
- Around line 834-843: The serde attributes on the fields marker_start_shape and
marker_end_shape currently only accept snake_case names and thus drop camelCase
JSON keys; update their #[serde(...)] on the structs containing pub
marker_start_shape: Option<StrokeMarkerPreset> and pub marker_end_shape:
Option<StrokeMarkerPreset> to include camelCase aliases (e.g., add alias =
"markerStartShape" for marker_start_shape and alias = "markerEndShape" for
marker_end_shape) while preserving rename = "..." and default so both snake_case
and camelCase payloads are accepted.

In `@docs/wg/feat-2d/curve-decoration.md`:
- Around line 397-410: The fenced code block containing the pseudo-schema
(showing MarkerGlyph and CurveDecoration) needs a language identifier to satisfy
MD040; update the block fence to use a plaintext identifier (e.g., ```text) so
the block starts with ```text followed by the existing
MarkerGlyph/CurveDecoration content and ends with ``` to silence the lint
warning.
🧹 Nitpick comments (6)
editor/scaffolds/sidecontrol/controls/icons/stroke-decoration-icons.tsx (1)

195-217: Inconsistent rendering technique between fixed and responsive vertical-bar icons.

The fixed StrokeDecorationVerticalBarFilledIcon uses fill="none" with a stroked <path> (line 206–214), while the responsive MarkerVerticalBarFilled uses fill="currentColor" with a <rect> (line 327–333). At small sizes the stroked variant will appear thicker/thinner than the filled rect depending on the stroke width, producing a visual mismatch between the dropdown item and the trigger preview.

Consider aligning both to use the same approach (filled <rect> is simpler and matches the other filled markers).

♻️ Suggested: use a filled rect for the fixed icon as well
 export function StrokeDecorationVerticalBarFilledIcon({
   className,
 }: {
   className?: string;
 }) {
   const w = LINE_STROKE * 1.4;
   const h = w * 5;
   const left = 24 - w;
-  const top = LINE_Y - h / 2;
-  const bottom = LINE_Y + h / 2;
   return (
-    <svg {...svgProps} className={cn(iconSlotClass, className)} fill="none">
+    <svg {...svgProps} className={cn(iconSlotClass, className)} fill="currentColor">
       <LineSegment />
-      <path
-        d={`M${LINE_X2} ${LINE_Y} L${left} ${LINE_Y} L${left} ${top} L24 ${top} L24 ${bottom} L${left} ${bottom} L${left} ${LINE_Y}`}
-        stroke="currentColor"
-        strokeWidth={LINE_STROKE}
-        strokeLinecap="butt"
-        strokeLinejoin="miter"
-      />
+      <rect x={left} y={LINE_Y - h / 2} width={w} height={h} />
     </svg>
   );
 }

Also applies to: 322-336

packages/grida-canvas-schema/grida.ts (1)

2409-2419: Consider extracting a shared IStrokeMarker interface.

Both VectorNode (lines 2409–2419) and LineNode (lines 2448–2457) declare identical marker_start_shape / marker_end_shape fields with the same types and docs. A small shared interface would reduce duplication and make it easier to add marker properties consistently to future node types (e.g., if BooleanPathOperationNode ever needs markers).

♻️ Example shared interface
// In the `i` namespace alongside IStroke:
export interface IStrokeMarker {
  /**
   * Marker shape at the start endpoint.
   * `@default` "none"
   */
  marker_start_shape?: cg.StrokeMarkerPreset;
  /**
   * Marker shape at the end endpoint.
   * `@default` "none"
   */
  marker_end_shape?: cg.StrokeMarkerPreset;
}

Then have VectorNode and LineNode extend i.IStrokeMarker.

Also applies to: 2448-2457

editor/grida-canvas/editor.i.ts (1)

3626-3633: Stroke decoration methods look good; consider adding NodeID[] overload for batch operations.

The methods follow the established changeNodeProperty* naming convention, use the correct cg.StrokeMarkerPreset type, and are logically placed alongside other stroke properties. However, changeNodePropertyStrokes and changeNodePropertyFills both support NodeID[] overloads for batch operations, while these decoration methods do not. If batch marker changes are needed (e.g., applying the same marker to multiple selected nodes), adding the array overload would improve API consistency.

crates/grida-canvas/examples/golden_stroke_decoration_presets.rs (1)

31-39: Avoid duplicated preset labels to prevent drift.

Line 31/123: The labels are defined twice. Consider driving the label list from the same presets vector used to build the scene.

Also applies to: 123-131

crates/grida-canvas/src/painter/layer.rs (2)

425-427: Consider a Default impl or constructor to reduce boilerplate for non-decorated nodes.

The triple marker_start_shape: StrokeMarkerPreset::None, marker_end_shape: StrokeMarkerPreset::None, stroke_width: 0.0 is repeated across ~10 node variants. A small helper (e.g., a Default derivation on PainterPictureShapeLayer or a builder method) would reduce noise and make it harder to forget updating all sites if new decoration fields are added later.

Also applies to: 488-490, 541-543, 588-590, 635-637, 682-684, 729-731, 910-912, 999-1001, 1032-1034


157-162: The stroke_width field on PainterPictureShapeLayer may be confusing given the existing stroke_path context.

This field is specifically for decoration sizing, not for stroke geometry computation. Consider renaming it to something like decoration_stroke_width or marker_stroke_width to clarify its purpose and avoid ambiguity with the stroke width already baked into stroke_path. Note that PainterPictureVectorLayer already has its own stroke_width field (line 194) used for stroke geometry, so the semantic overlap is limited to the shape layer — but a clearer name would help.

Comment on lines +834 to +843
#[serde(
rename = "marker_start_shape",
default
)]
pub marker_start_shape: Option<StrokeMarkerPreset>,
#[serde(
rename = "marker_end_shape",
default
)]
pub marker_end_shape: Option<StrokeMarkerPreset>,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add camelCase aliases for marker_start_shape/end_shape to preserve JSON compatibility.

Line 834/839: Most JSON fields accept both snake_case and camelCase. Without aliases, payloads that use markerStartShape/markerEndShape will silently drop decorations.

🛠️ Proposed fix
     #[serde(
-        rename = "marker_start_shape",
+        rename = "marker_start_shape",
+        alias = "markerStartShape",
         default
     )]
     pub marker_start_shape: Option<StrokeMarkerPreset>,
     #[serde(
-        rename = "marker_end_shape",
+        rename = "marker_end_shape",
+        alias = "markerEndShape",
         default
     )]
     pub marker_end_shape: Option<StrokeMarkerPreset>,
🤖 Prompt for AI Agents
In `@crates/grida-canvas/src/io/io_grida.rs` around lines 834 - 843, The serde
attributes on the fields marker_start_shape and marker_end_shape currently only
accept snake_case names and thus drop camelCase JSON keys; update their
#[serde(...)] on the structs containing pub marker_start_shape:
Option<StrokeMarkerPreset> and pub marker_end_shape: Option<StrokeMarkerPreset>
to include camelCase aliases (e.g., add alias = "markerStartShape" for
marker_start_shape and alias = "markerEndShape" for marker_end_shape) while
preserving rename = "..." and default so both snake_case and camelCase payloads
are accepted.

Comment on lines +397 to +410
```
MarkerGlyph
├─ geometry: Path/Primitive
├─ anchor: Point
├─ forward_axis: Vec2
└─ cutback_depth: f32 (computed from geometry + stroke width)

CurveDecoration
├─ glyph: MarkerGlyph
├─ placement: Placement
├─ orient: OrientationPolicy
├─ scale: ScalePolicy
└─ offset: Offset
```
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add a language identifier to the fenced code block.

Static analysis flags this block (MD040). Since it's a pseudo-schema rather than a specific language, use text or plaintext as the language identifier.

-```
+```text
 MarkerGlyph
🧰 Tools
🪛 markdownlint-cli2 (0.20.0)

[warning] 397-397: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
In `@docs/wg/feat-2d/curve-decoration.md` around lines 397 - 410, The fenced code
block containing the pseudo-schema (showing MarkerGlyph and CurveDecoration)
needs a language identifier to satisfy MD040; update the block fence to use a
plaintext identifier (e.g., ```text) so the block starts with ```text followed
by the existing MarkerGlyph/CurveDecoration content and ends with ``` to silence
the lint warning.

@vercel vercel Bot temporarily deployed to Preview – viewer February 14, 2026 10:12 Inactive
@vercel vercel Bot temporarily deployed to Preview – blog February 14, 2026 10:12 Inactive
@vercel vercel Bot temporarily deployed to Preview – backgrounds February 14, 2026 10:12 Inactive
@softmarshmallow softmarshmallow merged commit 159b74e into main Feb 14, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

breaking cg Core Graphics

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant