Skip to content

Conversation

@softmarshmallow
Copy link
Member

@softmarshmallow softmarshmallow commented Oct 29, 2025

Advanced Stroke models with lower level controls

Core

  • stroke_dash_array
  • stroke_cap
  • stroke_join
    • miter
    • bavel
    • round
  • stroke_miter_limit
  • stroke_width (each side)

Editor

  • stroke class select

Impls

Pt1 stroke dash array

day-299-grida-canvas-stroke-pt1-dasharray.mp4

Pt2 stroke rect width

day-300-grida-canvas-stroke-pt2-stroke-rect.mp4

Pt3 join and miter limit

day-301-grida-canvas-stroke-pt3-stroke-join-and-miter-limit.mp4

Summary by CodeRabbit

  • New Features
    • Per-side rectangle stroke widths, stroke join styles (miter/round/bevel), miter limit control, and dash-pattern editing.
  • Improvements
    • Unified stroke styling model and UI controls for join/miter/dash/per-side widths.
    • More accurate and consistent stroke rendering (solid and dashed) across alignments and shapes; added demos and tests to showcase results.

@codesandbox
Copy link

codesandbox bot commented Oct 29, 2025

Review or Edit in CodeSandbox

Open the branch in Web EditorVS CodeInsiders

Open Preview

@vercel
Copy link

vercel bot commented Oct 29, 2025

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

Project Deployment Preview Comments Updated (UTC)
docs Ready Ready Preview Comment Nov 3, 2025 7:37am
grida Ready Ready Preview Comment Nov 3, 2025 7:37am
5 Skipped Deployments
Project Deployment Preview Comments Updated (UTC)
code Ignored Ignored Nov 3, 2025 7:37am
legacy Ignored Ignored Nov 3, 2025 7:37am
backgrounds Skipped Skipped Nov 3, 2025 7:37am
blog Skipped Skipped Nov 3, 2025 7:37am
viewer Skipped Skipped Nov 3, 2025 7:37am

@coderabbitai
Copy link

coderabbitai bot commented Oct 29, 2025

Walkthrough

Introduces a unified stroke system: per-side (rectangular) stroke widths, composite StrokeStyle (align/cap/join/miter/dash), new StrokeWidth/StrokeDashArray types, expanded stroke_geometry APIs and rendering paths, plus editor UI/commands and schema updates to expose the new stroke model.

Changes

Cohort / File(s) Summary
Core CG types & utilities
crates/grida-canvas/src/cg/types.rs, crates/grida-canvas/src/cg/stroke_width.rs, crates/grida-canvas/src/cg/stroke_dasharray.rs
Adds StrokeCap, StrokeJoin, StrokeMiterLimit, StrokeWidth variants (None/Uniform/Rectangular), UnknownStrokeWidth, RectangularStrokeWidth, StrokeDashArray with conversions, defaults, and tests.
Module re-exports / prelude
crates/grida-canvas/src/cg/mod.rs, crates/grida-canvas/src/cg/prelude.rs
Introduces prelude, stroke_dasharray, stroke_width modules and re-exports for convenient imports.
Node schema & geometry
crates/grida-canvas/src/node/schema.rs, crates/grida-canvas/src/node/factory.rs
Replaces per-node stroke fields with stroke_style: StrokeStyle and generalized stroke_width types; adds rectangular_stroke_width() support and updates node constructors.
Stroke geometry core
crates/grida-canvas/src/shape/stroke.rs, crates/grida-canvas/src/shape/stroke_rect.rs
Expands stroke_geometry() signature to accept cap/join/miter and Option<&StrokeDashArray>; adds stroke_geometry_rectangular() for per-side rectangular strokes.
Painter & vector pipeline
crates/grida-canvas/src/painter/*, crates/grida-canvas/src/vectornetwork/vn_painter.rs
Propagates new stroke parameters (cap/join/miter/dash) through painter and vector network; refactors StrokeOptions and VNPainter usage.
Cache / render bounds
crates/grida-canvas/src/cache/geometry.rs
Adds computation path for rectangular strokes and integrates into render-bound calculations.
I/O and format conversions
crates/grida-canvas/src/io/io_figma.rs, crates/grida-canvas/src/io/io_grida.rs, packages/grida-canvas-io-figma/lib.ts
Adds helpers to build UnknownStrokeWidth from external formats; maps Figma strokeJoin; JSON schema fields added for per-side widths and stroke join/miter/dash.
Examples, tests, docs
crates/grida-canvas/examples/*, crates/grida-canvas/tests/*, docs/wg/feat-painting/stroke-rect.md
Many examples/tests updated to .into() conversions and stroke_style usage; new demos and unit tests for rectangular strokes and dash-width consistency; documentation added.
Shape/skia mappings & math
crates/grida-canvas/src/sk/mappings.rs, packages/grida-cmath/index.ts, packages/grida-cmath/__tests__/*
Adds conversions for cap/join to Skia types; adds cmath.miter (ratio/angle) functions and tests.
Editor API, reducers & UI
editor/grida-canvas/editor.i.ts, editor/grida-canvas/editor.ts, editor/grida-canvas/reducers/*, editor/scaffolds/sidecontrol/controls/*, editor/scaffolds/sidecontrol/chunks/section-strokes.tsx
Adds per-side stroke mutators, strokeJoin/miter/dash setters, new React controls (StrokeJoinControl, StrokeMiterLimitControl, StrokeDashArrayControl, StrokeWidth4Control, StrokeClassControl), integrates per-side UI and dispatch paths.
Schema & JS/TS types
packages/grida-canvas-schema/grida.ts, packages/grida-canvas-cg/lib.ts
Adds strokeDashArray, strokeJoin, strokeMiterLimit, IRectangularStrokeWidth and exposes StrokeJoin type; extends public node interfaces to include per-side stroke widths.
Misc: import consolidation & small edits
many crates/grida-canvas/src/**/*, crates/grida-canvas-wasm/package.json
Replaces scattered types::* imports with prelude::*; package.json version bump (0.0.80-canary.3 → 0.0.81-canary.2).

Sequence Diagram(s)

sequenceDiagram
    participant UI as Editor UI
    participant Reducer as Reducer / Store
    participant Editor as EditorDocumentStore
    participant Painter as Painter
    participant Shape as Shape Module

    UI->>Reducer: changeNodePropertyStrokeTopWidth(nodeId, value)
    Reducer->>Editor: dispatch node/change/strokeTopWidth
    Editor->>Reducer: update node model (strokeTopWidth)
    UI->>Painter: request renderScene()
    Painter->>Shape: request stroke path for Rectangle node
    Shape->>Shape: if rectangular_stroke_width() present
    alt rectangular stroke
        Shape->>Shape: stroke_geometry_rectangular(bounds, rect_stroke, radii, align, miter, dash)
    else uniform stroke
        Shape->>Shape: stroke_geometry(path, width, align, cap, join, miter, dash)
    end
    Shape-->>Painter: path (filled stroke geometry)
    Painter-->>UI: rendered canvas
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~65 minutes

Areas requiring extra attention:

  • crates/grida-canvas/src/shape/stroke_rect.rs and stroke_geometry signature changes — correctness of RRect inflation/deflation, corner radii, dash clipping, and miter handling.
  • crates/grida-canvas/src/cg/stroke_width.rs — conversion rules between UnknownStrokeWidth, RectangularStrokeWidth, SingularStrokeWidth, and StrokeWidth.
  • Integration points in painter/vectornetwork (vn_painter.rs, painter.rs) — ensure all call sites supply new parameters and types (StrokeDashArray).
  • Cache/bounds (cache/geometry.rs) — validate bounds inflation for per-side widths and alignment semantics.
  • Editor wiring and reducers — ensure dispatch, clamping, and per-side vs uniform semantics are consistent with UI controls.

Possibly related PRs

Suggested labels

cg

Poem

🐰 I hopped in code with whiskers bright,
Four-side strokes now set just right,
Caps and joins and miters fair,
Dashes dance around the square,
A tiny rabbit cheers: “Nice render, night!”

Pre-merge checks and finishing touches

✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title Check ✅ Passed The pull request title "Grida Canvas - Advanced Stroke Styles dasharray, join, cap and stroke rect width" directly reflects the primary objective of the changeset. The PR implements exactly these stroke features: dash array patterns, stroke join styles (miter, round, bevel), stroke cap styles, stroke miter limit, and per-side rectangular stroke widths. The title is concise, single-sentence, and uses backticks to highlight specific technical features, making it clear and scannable. A teammate reviewing the git history would immediately understand this PR adds advanced stroke styling capabilities to the Grida Canvas rendering system.
✨ 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.

…e and add comprehensive tests. Improved handling of negative values, zero lengths, and odd-length duplication for dash patterns.
Copy link

@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: 8

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
packages/grida-canvas-schema/grida.ts (1)

2557-2578: Rectangle default should also include miter limit.

We now require strokeJoin and support strokeMiterLimit, but rectangle prototypes still rely on implicit defaults. Please add strokeMiterLimit: 4 (or the canonical default) alongside strokeJoin so freshly created rectangles start from a valid state and match the renderer’s expectations.

crates/grida-canvas/src/shape/stroke.rs (1)

88-113: Handle empty normalized dash patterns correctly

When StrokeDashArray::normalized() returns an empty slice—e.g., for [0.0, 4.0]—we enter this branch, skip the path effect, and still stroke source_path. That produces a solid outline even though every dash length is zero, contradicting the new API docs (“all-zero dashes → invisible”) and SVG’s behavior (all dash lengths zero means nothing should render). Please detect this case before calling PathEffect::dash and return an empty path so open paths hide their stroke instead of reverting to solid. Something like:

-    if let Some(dashes) = stroke_dash_array {
-        if let Some(pe) = PathEffect::dash(&dashes.normalized(), 0.0) {
+    if let Some(dashes) = stroke_dash_array {
+        let normalized = dashes.normalized();
+        if normalized.is_empty() {
+            if dashes.as_slice().iter().step_by(2).all(|&x| x == 0.0) {
+                return Path::new();
+            }
+        } else if let Some(pe) = PathEffect::dash(&normalized, 0.0) {
             // Use a hairline StrokeRec for filtering to avoid double-width application
             let filter_rec = StrokeRec::new(InitStyle::Hairline);
             if let Some((dashed, _)) =
-                pe.filter_path(source_path, &filter_rec, source_path.bounds())
+                pe.filter_path(source_path, &filter_rec, source_path.bounds())
             {
                 path_to_stroke = dashed.snapshot();
             }
         }
     }

This keeps invalid negative values (SVG says “render solid”) but makes zero-length dash arrays truly disappear.

crates/grida-canvas/src/node/schema.rs (1)

1207-1219: Missing rectangular_stroke_width() implementation for ImageNodeRec.

ImageNodeRec uses stroke_width: StrokeWidth (line 1159) which supports per-side rectangular strokes, but doesn't implement rectangular_stroke_width() like ContainerNodeRec and RectangleNodeRec do. This means the painter will always fall back to uniform stroke rendering even when rectangular stroke data is present.

Apply this diff to add the missing implementation:

     fn render_bounds_stroke_width(&self) -> f32 {
         if self.has_stroke_geometry() {
             self.stroke_width.max()
         } else {
             0.0
         }
     }
+
+    fn rectangular_stroke_width(&self) -> Option<RectangularStrokeWidth> {
+        match &self.stroke_width {
+            StrokeWidth::Rectangular(rect_stroke) => Some(rect_stroke.clone()),
+            _ => None,
+        }
+    }
 }
🧹 Nitpick comments (6)
crates/grida-canvas/examples/golden_stroke_width_4.rs (1)

17-19: Remove unnecessary async wrapper.

The scene() function is marked async but performs no asynchronous operations—it simply calls the synchronous create_stroke_width_4_scene(). This adds unnecessary complexity and may mislead readers about the function's behavior.

Apply this diff to simplify:

-async fn scene() -> Scene {
+fn scene() -> Scene {
     create_stroke_width_4_scene()
 }

Then update main:

 #[tokio::main]
 async fn main() {
-    let scene = scene().await;
+    let scene = scene();
packages/grida-canvas-schema/grida.ts (1)

2089-2174: Confirm rectangle-like nodes always emit per-side widths.

Extending ImageNode, VideoNode, and ContainerNode with IRectangularStrokeWidth makes sense, but none of these nodes currently expose stroke color/activation. Verify the renderer/editor consume these fields together; otherwise the extra surface might become dangling data. Consider lining this up with the planned stroke refactor before merging.

packages/grida-cmath/index.ts (1)

5694-5729: Add domain guards for robustness in cmath.miter.

Handle degenerate and non-finite inputs to avoid NaN/Infinity leaks and make behavior explicit.

   export function ratio(angleDeg: number): number {
-      const halfRad = (angleDeg * Math.PI) / 360;
-      return 1 / Math.sin(halfRad);
+      if (!Number.isFinite(angleDeg)) return NaN;
+      if (angleDeg <= 0) return Infinity;        // perfectly sharp corner
+      if (angleDeg >= 180) return 1;             // flat join
+      const halfRad = (angleDeg * Math.PI) / 360;
+      const s = Math.sin(halfRad);
+      return s === 0 ? Infinity : 1 / s;
   }
 ...
   export function angle(ratio: number): number {
-      if (ratio <= 1) return 180;
-      return (2 * Math.asin(1 / ratio) * 180) / Math.PI;
+      if (!Number.isFinite(ratio) || ratio <= 0) return 180;
+      if (ratio <= 1) return 180;
+      const v = 1 / ratio;
+      // Clamp to [-1,1] for numerical safety
+      const clamped = Math.max(-1, Math.min(1, v));
+      return (2 * Math.asin(clamped) * 180) / Math.PI;
   }
crates/grida-canvas/examples/golden_vector_line.rs (1)

44-50: Minor polish: make miter limit explicit and reuse dash pattern.

Clarity/perf tweaks, no behavior change.

-        let dash_array = if i % 2 == 1 {
-            Some(StrokeDashArray::from(vec![10.0, 5.0]))
-        } else {
-            None
-        };
+        // Reuse a shared dash pattern to avoid reallocations
+        static DASH: &[f32] = &[10.0, 5.0];
+        let dash_array = if i % 2 == 1 {
+            Some(StrokeDashArray::from(DASH))
+        } else {
+            None
+        };

         let stroke_options = StrokeOptions {
             stroke_width: 2.0,
             stroke_align: StrokeAlign::Center,
             stroke_cap: StrokeCap::Round,
             stroke_join: StrokeJoin::Round,
-            stroke_miter_limit: StrokeMiterLimit::default(),
+            // Make the common miter limit explicit (≈29° cutoff)
+            stroke_miter_limit: StrokeMiterLimit::new(4.0),
             paints: Paints::new([Paint::from(color)]),
             width_profile: None,
             stroke_dash_array: dash_array,
         };

Also applies to: 52-56, 59-60

editor/scaffolds/sidecontrol/controls/stroke-miter-limit.tsx (2)

35-46: Clamp input angle before converting to ratio.

Defensive clamp avoids out-of-range values slipping through programmatically.

-  const handleChange = (angleValue: number) => {
+  const handleChange = (angleValue: number) => {
     if (!onValueChange) return;
-    const ratio = cmath.miter.ratio(angleValue);
+    const a = cmath.clamp(angleValue, 1, 180);
+    const ratio = cmath.miter.ratio(a);
     onValueChange(ratio);
   };

48-57: Let me check how paint.tsx and percentage.tsx handle mixed values with InputPropertyNumber:

Let me check how other similar controls in the codebase handle mixed values with InputPropertyNumber:

Based on my verification, I can now provide the rewritten review comment:

Add degree icon and make step value explicit for UX clarity.

The InputPropertyNumber component already defaults to step={1} and has full type support for grida.mixed values. The displayValue correctly returns grida.mixed unchanged, which the component's TMixed<number | ""> type accepts. The main enhancement is adding a visual degree indicator for better UX.

   return (
     <InputPropertyNumber
       mode="fixed"
       type="number"
       placeholder="29"
       min={1}
       max={180}
+      step={1}
+      icon={<span>°</span>}
       value={displayValue}
       onValueChange={handleChange}
     />
   );

Comment on lines +782 to +799
stroke_style: StrokeStyle {
stroke_align: Self::convert_stroke_align(
component
.stroke_align
.as_ref()
.map(|a| serde_json::to_string(a).unwrap_or_default())
.unwrap_or_else(|| "CENTER".to_string()),
),
stroke_cap: StrokeCap::default(),
stroke_join: StrokeJoin::default(),
stroke_miter_limit: StrokeMiterLimit::default(),
stroke_dash_array: component
.stroke_dashes
.clone()
.map(|v| v.into_iter().map(|x| x as f32).collect()),
},
stroke_width: Self::build_unknown_stroke_width_from_figma(component.stroke_weight)
.into(),
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Preserve Figma stroke cap/join/miter limit

Every stroke_style we build here (and in the other shape converters below) hardcodes StrokeCap::default(), StrokeJoin::default(), and StrokeMiterLimit::default(). That wipes out the real cap/join/miter data coming from Figma, so importing a file with round caps or bevel joins will silently revert to butt/miter/4.0. Please thread the actual stroke_cap, stroke_join, and stroke_miter_limit values from the Figma node into the new StrokeStyle, similar to how we already propagate stroke_dash_array, to avoid regressing fidelity.

🤖 Prompt for AI Agents
In crates/grida-canvas/src/io/io_figma.rs around lines 782 to 799, the
StrokeStyle is currently using StrokeCap::default(), StrokeJoin::default(), and
StrokeMiterLimit::default() which discards Figma's actual cap/join/miter
settings; thread the corresponding values from the Figma `component` into the
StrokeStyle instead (similar to stroke_dash_array): read component.stroke_cap,
component.stroke_join, and component.stroke_miter_limit, convert each to the
internal types (use or add conversion helpers like Self::convert_stroke_cap /
convert_stroke_join / convert_stroke_miter_limit or map the enums
appropriately), and assign those converted values to stroke_cap, stroke_join,
and stroke_miter_limit so the real Figma values are preserved.

Comment on lines +356 to +365
let size = Size {
width: bounds.width,
height: bounds.height,
};
let stroke_path = Self::compute_rectangular_stroke_path(
&n.stroke_width,
&n.corner_radius,
&n.stroke_style,
&size,
&shape,
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Rectangular stroke needs local-size input

size is built from get_world_bounds, but we later apply the node’s world transform again when drawing. For rotated or scaled containers, this double-applies the transform so per-side strokes end up inflated and misaligned. Please derive the dimensions in local space (e.g., from the pre-transform rect/shape or via a local-bounds query) before calling compute_rectangular_stroke_path.

🤖 Prompt for AI Agents
In crates/grida-canvas/src/painter/layer.rs around lines 356 to 365, the code
builds `size` from world bounds and passes it to
`compute_rectangular_stroke_path`, which double-applies the node's world
transform and inflates/misaligns per-side strokes for rotated/scaled containers;
change the code to derive the rectangle dimensions in local space (for example,
use the pre-transform rect/shape or query the node's local bounds, or transform
the world bounds by the inverse world transform) and construct `size` from those
local dimensions, then pass that local `size` (and any local corner radii) into
`compute_rectangular_stroke_path` so the stroke path is computed in node-local
space and not transformed twice.

Comment on lines +92 to +105
strokeWidth: [
"rectangle",
"image",
"video",
"container",
"component",
"instance",
"vector",
"line",
"rectangle",
"ellipse",
"polygon",
"star",
],
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Remove duplicate "rectangle" entry.

The "rectangle" node type appears twice in the strokeWidth array (lines 93 and 101). This duplication is unnecessary and likely a copy-paste error.

Apply this diff to remove the duplicate:

  strokeWidth: [
    "rectangle",
    "image",
    "video",
    "container",
    "component",
    "instance",
    "vector",
    "line",
-    "rectangle",
    "ellipse",
    "polygon",
    "star",
  ],
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
strokeWidth: [
"rectangle",
"image",
"video",
"container",
"component",
"instance",
"vector",
"line",
"rectangle",
"ellipse",
"polygon",
"star",
],
strokeWidth: [
"rectangle",
"image",
"video",
"container",
"component",
"instance",
"vector",
"line",
"ellipse",
"polygon",
"star",
],
🤖 Prompt for AI Agents
In editor/grida-canvas/utils/supports.ts around lines 92 to 105, the strokeWidth
array contains a duplicate "rectangle" entry; remove the redundant "rectangle"
so each node type appears only once in the array (delete the second occurrence)
and verify array formatting remains valid.

Comment on lines +217 to +233
strokeCap: [
"container",
"rectangle",
"image",
"video",
"container",
"vector",
"line",
"rectangle",
"ellipse",
"polygon",
"star",
"text",
"component",
"instance",
"boolean",
],
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

Remove duplicate entries and verify strokeCap expansion.

There are duplicate node type entries in the strokeCap array:

  • "container" appears on lines 218 and 222
  • "rectangle" appears on lines 219 and 225

Additionally, the comment on lines 116-117 states that strokeCap "should be visible to editor only for polyline and line nodes (path-like nodes)," yet this canvas backend mapping includes many additional node types. Please verify this expansion is intentional for the canvas rendering backend.

Apply this diff to remove the duplicates:

  strokeCap: [
    "container",
    "rectangle",
    "image",
    "video",
-    "container",
    "vector",
    "line",
-    "rectangle",
    "ellipse",
    "polygon",
    "star",
    "text",
    "component",
    "instance",
    "boolean",
  ],

🏁 Script executed:

#!/bin/bash
# Fetch the file and display the relevant sections
echo "=== Lines 110-120 (strokeCap comment context) ==="
sed -n '110,120p' editor/grida-canvas/utils/supports.ts

echo ""
echo "=== Lines 115-125 (dom_supports.strokeCap) ==="
sed -n '115,125p' editor/grida-canvas/utils/supports.ts

echo ""
echo "=== Lines 217-233 (canvas_supports.strokeCap) ==="
sed -n '217,233p' editor/grida-canvas/utils/supports.ts

echo ""
echo "=== Full strokeCap arrays with line numbers ==="
sed -n '116,130p' editor/grida-canvas/utils/supports.ts | cat -n
echo "---"
sed -n '217,233p' editor/grida-canvas/utils/supports.ts | cat -n

Length of output: 2077


🏁 Script executed:

#!/bin/bash
# Search for comments or context around strokeCap in the file
echo "=== Search for strokeCap references with context ==="
rg -n -B3 -A3 "strokeCap" editor/grida-canvas/utils/supports.ts

echo ""
echo "=== Search for TODOs or FIXMEs related to stroke ==="
rg -n -i "todo|fixme" editor/grida-canvas/utils/supports.ts | grep -i stroke

echo ""
echo "=== Check if there are any comments about canvas-specific behavior ==="
sed -n '190,240p' editor/grida-canvas/utils/supports.ts | head -60

Length of output: 1983


🏁 Script executed:

#!/bin/bash
# Check git history for strokeCap changes
echo "=== Git log for strokeCap changes ==="
git log -p --follow -S "strokeCap" -- editor/grida-canvas/utils/supports.ts | head -200

echo ""
echo "=== Check for any recent changes to this file ==="
git log --oneline -20 -- editor/grida-canvas/utils/supports.ts

Length of output: 525


Remove duplicate entries in strokeCap array and document the canvas backend expansion.

Duplicates confirmed in editor/grida-canvas/utils/supports.ts lines 217-233:

  • "container" appears twice (lines 218 and 222)
  • "rectangle" appears twice (lines 219 and 225)

Additionally, the documented requirement (lines 116-117) states strokeCap "should be visible to editor only for polyline and line nodes," yet canvas_supports.strokeCap includes 15 node types. While this canvas-specific expansion may be intentional, it lacks documentation explaining why the canvas backend differs from the DOM backend constraint.

Apply the provided diff to remove duplicates and add a comment explaining the canvas backend expansion rationale.

🤖 Prompt for AI Agents
In editor/grida-canvas/utils/supports.ts around lines 217 to 233, remove the
duplicate "container" and "rectangle" entries from the strokeCap array and
collapse it to unique node types; then add a short inline comment above the
array explaining that the canvas backend intentionally expands strokeCap
visibility beyond the DOM/editor constraint (polyline/line) because canvas
rendering requires treating additional node types as strokable for accurate
visual output and hit-testing. Ensure the comment references the DOM/backend
difference and keep the array entries deduplicated and alphabetically or
logically ordered for readability.

Copy link

@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: 0

♻️ Duplicate comments (1)
crates/grida-canvas/src/cg/types.rs (1)

1986-1988: Honor paint activity in Paints::is_visible.

The implementation should use paint.visible() instead of checking only paint.opacity() > 0.0. The Paint::visible() method (lines 1867-1875) already combines both active() and opacity() checks, ensuring inactive paints with non-zero opacity are correctly treated as invisible.

 pub fn is_visible(&self) -> bool {
-    self.paints.iter().any(|paint| paint.opacity() > 0.0)
+    self.paints.iter().any(|paint| paint.visible())
 }
🧹 Nitpick comments (4)
editor/grida-canvas/reducers/node.reducer.ts (2)

482-486: Consider clamping strokeMiterLimit to a minimum of 1.

Per SVG and canvas graphics standards, miter limit should be >= 1. Other numeric properties with meaningful bounds (e.g., fontSize at line 792, pointCount at line 340) are clamped using ranged() or cmath.clamp().

Apply this diff to add minimum value validation:

   strokeMiterLimit: defineNodeProperty<"strokeMiterLimit">({
     apply: (draft, value, prev) => {
-      (draft as UN).strokeMiterLimit = value;
+      (draft as UN).strokeMiterLimit = ranged(1, value);
     },
   }),

487-502: Consider adding defensive validation for strokeDashArray structure.

While TypeScript types provide compile-time safety, adding runtime validation for array structure would be defensive against malformed data from external sources (e.g., imports, API responses).

Apply this diff to add optional runtime validation:

   strokeDashArray: defineNodeProperty<"strokeDashArray">({
     assert: (node) =>
       node.type === "vector" ||
       node.type === "line" ||
       node.type === "rectangle" ||
       node.type === "ellipse" ||
       node.type === "polygon" ||
       node.type === "star" ||
       node.type === "svgpath" ||
       node.type === "image" ||
       node.type === "container" ||
       node.type === "boolean",
     apply: (draft, value, prev) => {
+      // Validate array structure if provided
+      if (value !== null && value !== undefined) {
+        assert(
+          Array.isArray(value) && value.every((v) => typeof v === "number"),
+          "strokeDashArray must be an array of numbers"
+        );
+      }
       (draft as UN).strokeDashArray = value;
     },
   }),
crates/grida-canvas/src/cg/types.rs (2)

544-544: Add Eq and Hash derives for consistency with StrokeCap.

StrokeJoin has the same structure as StrokeCap (simple enum with no floating-point data) but is missing the Eq and Hash derives. This inconsistency limits StrokeJoin from being used in hash-based collections and could confuse API users expecting parallel behavior.

-#[derive(Debug, Clone, Copy, PartialEq, Deserialize)]
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize)]
 pub enum StrokeJoin {

610-642: Improve StrokeMiterLimit encapsulation and safety.

The current implementation has several design concerns:

  1. Public field exposure: The tuple struct field is public (pub f32), allowing direct mutation via .0 which bypasses the value() accessor and any future validation. This breaks encapsulation.

  2. Missing validation: Neither new() nor From<f32> validate inputs. Negative or NaN values could create invalid state.

  3. Missing Copy derive: As a simple f32 wrapper, this type should derive Copy for ergonomics (like StrokeCap and StrokeJoin).

-#[derive(Debug, Clone, Copy, PartialEq, Deserialize)]
-pub struct StrokeMiterLimit(#[serde(default = "StrokeMiterLimit::default_value")] pub f32);
+#[derive(Debug, Clone, Copy, PartialEq, Deserialize)]
+pub struct StrokeMiterLimit(#[serde(default = "StrokeMiterLimit::default_value")] f32);

 impl StrokeMiterLimit {
     pub const DEFAULT_VALUE: f32 = 4.0;

     /// Creates a new miter limit with the specified value.
+    /// 
+    /// # Panics
+    /// Panics if limit is negative or NaN.
     pub const fn new(limit: f32) -> Self {
+        assert!(limit >= 0.0 && !limit.is_nan(), "Miter limit must be non-negative");
         Self(limit)
     }

 impl From<f32> for StrokeMiterLimit {
     fn from(value: f32) -> Self {
-        Self(value)
+        Self::new(value)
     }
 }

Note: The assert! in const fn new requires Rust 1.57+. If targeting older versions, make new non-const and use runtime validation, or accept unchecked construction in const contexts.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 35ee7a3 and 464f513.

📒 Files selected for processing (3)
  • crates/grida-canvas/src/cache/geometry.rs (6 hunks)
  • crates/grida-canvas/src/cg/types.rs (2 hunks)
  • editor/grida-canvas/reducers/node.reducer.ts (2 hunks)
🧰 Additional context used
📓 Path-based instructions (3)
crates/**

📄 CodeRabbit inference engine (AGENTS.md)

Keep Rust crates under /crates; this is the monorepo’s Rust workspace for the Canvas implementation

Files:

  • crates/grida-canvas/src/cache/geometry.rs
  • crates/grida-canvas/src/cg/types.rs
crates/grida-canvas/src/**/*.rs

📄 CodeRabbit inference engine (crates/grida-canvas/AGENTS.md)

crates/grida-canvas/src/**/*.rs: All internal structs (e.g., NodeRecs, SceneGraph, caches) must use NodeId (u64) for identifiers and not serialize them
Public APIs must accept and return UserNodeId (String) for stability and .grida serialization

Files:

  • crates/grida-canvas/src/cache/geometry.rs
  • crates/grida-canvas/src/cg/types.rs
editor/grida-*/**

📄 CodeRabbit inference engine (AGENTS.md)

Use editor/grida-* directories to isolate domain-specific modules pending promotion to /packages

Files:

  • editor/grida-canvas/reducers/node.reducer.ts
🧠 Learnings (11)
📚 Learning: 2025-10-27T06:28:21.888Z
Learnt from: CR
Repo: gridaco/grida PR: 0
File: crates/grida-canvas/AGENTS.md:0-0
Timestamp: 2025-10-27T06:28:21.888Z
Learning: Applies to crates/grida-canvas/src/**/*.rs : All internal structs (e.g., NodeRecs, SceneGraph, caches) must use NodeId (u64) for identifiers and not serialize them

Applied to files:

  • crates/grida-canvas/src/cache/geometry.rs
  • crates/grida-canvas/src/cg/types.rs
📚 Learning: 2025-09-26T09:29:53.155Z
Learnt from: CR
Repo: gridaco/grida PR: 0
File: crates/grida-canvas-wasm/AGENTS.md:0-0
Timestamp: 2025-09-26T09:29:53.155Z
Learning: Applies to crates/grida-canvas-wasm/{crates/grida-canvas-wasm/src/main.rs,**/grida-canvas-wasm.d.ts} : When introducing new public APIs in the WASM entrypoint (main.rs), update the TypeScript declarations in grida-canvas-wasm.d.ts to keep bindings in sync

Applied to files:

  • crates/grida-canvas/src/cache/geometry.rs
  • crates/grida-canvas/src/cg/types.rs
  • editor/grida-canvas/reducers/node.reducer.ts
📚 Learning: 2025-10-27T06:28:21.888Z
Learnt from: CR
Repo: gridaco/grida PR: 0
File: crates/grida-canvas/AGENTS.md:0-0
Timestamp: 2025-10-27T06:28:21.888Z
Learning: Applies to crates/grida-canvas/src/**/*.rs : Public APIs must accept and return UserNodeId (String) for stability and .grida serialization

Applied to files:

  • crates/grida-canvas/src/cache/geometry.rs
  • crates/grida-canvas/src/cg/types.rs
📚 Learning: 2025-09-15T10:31:32.864Z
Learnt from: CR
Repo: gridaco/grida PR: 0
File: crates/grida-canvas-fonts/AGENTS.md:0-0
Timestamp: 2025-09-15T10:31:32.864Z
Learning: Applies to crates/grida-canvas-fonts/tests/ui_parser_test.rs : Place high-level UI API tests (parse_ui) in tests/ui_parser_test.rs

Applied to files:

  • crates/grida-canvas/src/cache/geometry.rs
📚 Learning: 2025-09-15T10:31:32.864Z
Learnt from: CR
Repo: gridaco/grida PR: 0
File: crates/grida-canvas-fonts/AGENTS.md:0-0
Timestamp: 2025-09-15T10:31:32.864Z
Learning: Applies to crates/grida-canvas-fonts/tests/italic_level1.rs : Place core font selection and italic detection tests in tests/italic_level1.rs

Applied to files:

  • crates/grida-canvas/src/cache/geometry.rs
📚 Learning: 2025-09-15T10:31:32.864Z
Learnt from: CR
Repo: gridaco/grida PR: 0
File: crates/grida-canvas-fonts/AGENTS.md:0-0
Timestamp: 2025-09-15T10:31:32.864Z
Learning: Applies to crates/grida-canvas-fonts/tests/scenario_*.rs : Name comprehensive scenario-specific integration tests as tests/scenario_*.rs

Applied to files:

  • crates/grida-canvas/src/cache/geometry.rs
📚 Learning: 2025-09-15T10:31:32.864Z
Learnt from: CR
Repo: gridaco/grida PR: 0
File: crates/grida-canvas-fonts/AGENTS.md:0-0
Timestamp: 2025-09-15T10:31:32.864Z
Learning: Applies to crates/grida-canvas-fonts/tests/italic_name_checking.rs : Put name-based italic detection tests in tests/italic_name_checking.rs

Applied to files:

  • crates/grida-canvas/src/cache/geometry.rs
📚 Learning: 2025-10-14T08:23:46.382Z
Learnt from: CR
Repo: gridaco/grida PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-10-14T08:23:46.382Z
Learning: Applies to docs/canvas/** : Avoid changes to docs/canvas (currently marked inactive) unless reactivated

Applied to files:

  • crates/grida-canvas/src/cg/types.rs
📚 Learning: 2025-10-16T02:50:45.759Z
Learnt from: CR
Repo: gridaco/grida PR: 0
File: editor/app/(tools)/tools/halftone/AGENTS.md:0-0
Timestamp: 2025-10-16T02:50:45.759Z
Learning: Applies to editor/app/(tools)/tools/halftone/app/(tools)/tools/halftone/_page.tsx : When adding a new shape, add a corresponding case in shapeToSVG() for SVG export

Applied to files:

  • editor/grida-canvas/reducers/node.reducer.ts
📚 Learning: 2025-10-16T02:50:45.759Z
Learnt from: CR
Repo: gridaco/grida PR: 0
File: editor/app/(tools)/tools/halftone/AGENTS.md:0-0
Timestamp: 2025-10-16T02:50:45.759Z
Learning: Applies to editor/app/(tools)/tools/halftone/app/(tools)/tools/halftone/_page.tsx : When adding a new shape, add a corresponding case in drawShape()

Applied to files:

  • editor/grida-canvas/reducers/node.reducer.ts
📚 Learning: 2025-10-16T02:50:45.759Z
Learnt from: CR
Repo: gridaco/grida PR: 0
File: editor/app/(tools)/tools/halftone/AGENTS.md:0-0
Timestamp: 2025-10-16T02:50:45.759Z
Learning: Applies to editor/app/(tools)/tools/halftone/app/(tools)/tools/halftone/_page.tsx : When adding a new shape, update the Shape type union

Applied to files:

  • editor/grida-canvas/reducers/node.reducer.ts
🧬 Code graph analysis (2)
crates/grida-canvas/src/cache/geometry.rs (3)
crates/grida-canvas-wasm/lib/index.ts (1)
  • Rectangle (33-38)
packages/grida-canvas-cg/lib.ts (1)
  • StrokeAlign (230-230)
crates/math2/src/rect.rs (1)
  • inflate (729-737)
crates/grida-canvas/src/cg/types.rs (2)
packages/grida-canvas-cg/lib.ts (2)
  • StrokeCap (212-212)
  • StrokeJoin (224-224)
crates/grida-canvas/src/node/schema.rs (5)
  • default (62-69)
  • default (87-95)
  • default (412-414)
  • default (526-536)
  • default (675-677)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
  • GitHub Check: cargo test
  • GitHub Check: test
🔇 Additional comments (9)
editor/grida-canvas/reducers/node.reducer.ts (1)

421-460: Per-side stroke widths are now properly clamped.

All four per-side stroke width properties correctly use ranged(0, value, editor.config.DEFAULT_MAX_STROKE_WIDTH), matching the existing strokeWidth implementation. This prevents negative or excessive width values from causing geometry issues.

crates/grida-canvas/src/cache/geometry.rs (7)

14-14: LGTM: Import path updated to prelude.

The import path change from crate::cg::types::* to crate::cg::prelude::* aligns with the unified stroke system introduced in this PR.


280-285: LGTM: BooleanOperation uses uniform stroke correctly.

The render bounds calculation correctly uses stroke_width.value_or_zero() and stroke_style.stroke_align for uniform stroke handling on boolean operations.


330-344: Previous issue resolved: Rectangular stroke widths now handled correctly.

The Container render bounds computation now correctly branches based on whether rectangular stroke data exists, calling compute_render_bounds_with_rectangular_stroke when per-side widths are present. This addresses the previous review comment about dropped rectangular stroke widths on containers.


633-682: LGTM: Rectangular stroke bounds computation is correct.

The new compute_render_bounds_with_rectangular_stroke function correctly implements per-side stroke bounds expansion for all three alignment modes:

  • Center: Half-width inflation (stroke straddles boundary)
  • Inside: No inflation (stroke contained within bounds)
  • Outside: Full-width inflation (stroke extends outward)

The implementation uses rect::inflate appropriately and applies effects after stroke adjustment, consistent with the uniform stroke path.


686-703: LGTM: Rectangle handles both uniform and per-side strokes.

The Rectangle node correctly branches based on stroke type, using compute_render_bounds_with_rectangular_stroke for per-side widths and falling back to uniform handling otherwise.


704-757: LGTM: Uniform stroke handling is consistent across shape types.

All non-rectangular shape types (Ellipse, Polygon, Image, SVGPath, etc.) correctly use uniform stroke width via render_bounds_stroke_width() or direct field access, paired with stroke_style.stroke_align. These shapes appropriately do not support per-side strokes.


758-775: LGTM: Container render bounds consistent across call sites.

The compute_render_bounds function correctly handles Container nodes with the same branching logic used in build_recursive (lines 330-344), ensuring rectangular stroke widths are properly computed regardless of the code path.

crates/grida-canvas/src/cg/types.rs (1)

361-454: Excellent implementation with comprehensive documentation.

The StrokeCap enum is well-designed with:

  • Clear, detailed documentation including visual examples and cross-platform references
  • Appropriate derives for a public enum type
  • Correct serde mappings that match the TypeScript definitions
  • Standard default (Butt)

The extensive documentation is particularly valuable for a public API, making it easy for users to understand the visual differences between cap styles.

@softmarshmallow softmarshmallow merged commit 5678a1e into main Nov 3, 2025
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant