From 0ab470fc9415ebaccb53606b2b16f869261f0149 Mon Sep 17 00:00:00 2001 From: Universe Date: Mon, 9 Feb 2026 02:01:33 +0900 Subject: [PATCH 01/16] rm prettier --- .oxfmtrc.jsonc | 2 -- .prettierrc | 5 ----- package.json | 1 - pnpm-lock.yaml | 9 +++------ 4 files changed, 3 insertions(+), 14 deletions(-) delete mode 100644 .prettierrc diff --git a/.oxfmtrc.jsonc b/.oxfmtrc.jsonc index 7544734c88..2774e795a2 100644 --- a/.oxfmtrc.jsonc +++ b/.oxfmtrc.jsonc @@ -1,8 +1,6 @@ { "$schema": "./node_modules/oxfmt/configuration_schema.json", "ignorePatterns": [], - // custom below, zero-config above. - // we're migrating from prettier to oxfmt, below will be removed once complete. "printWidth": 80, "trailingComma": "es5", "experimentalSortPackageJson": { diff --git a/.prettierrc b/.prettierrc deleted file mode 100644 index ae1c62c000..0000000000 --- a/.prettierrc +++ /dev/null @@ -1,5 +0,0 @@ -{ - "trailingComma": "es5", - "singleQuote": false, - "semi": true -} diff --git a/package.json b/package.json index 2618cce13f..5020b17800 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,6 @@ "@changesets/cli": "^2.29.4", "@types/node": "^22", "oxfmt": "^0.28.0", - "prettier": "^3.5.3", "tsup": "^8.5.0", "turbo": "^2.8.3", "typescript": "^5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5a6399b444..dab57a0298 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -30,9 +30,6 @@ importers: oxfmt: specifier: ^0.28.0 version: 0.28.0 - prettier: - specifier: ^3.5.3 - version: 3.8.1 tsup: specifier: ^8.5.0 version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(typescript@5.9.3)(yaml@2.7.0) @@ -23582,7 +23579,7 @@ snapshots: eslint: 9.39.2(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 eslint-import-resolver-typescript: 3.8.0(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.8.0)(eslint@9.39.2(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.8.0(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-react: 7.37.4(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-react-hooks: 7.0.1(eslint@9.39.2(jiti@2.6.1)) @@ -23635,7 +23632,7 @@ snapshots: stable-hash: 0.0.4 tinyglobby: 0.2.15 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.8.0)(eslint@9.39.2(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.8.0(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) transitivePeerDependencies: - supports-color @@ -23675,7 +23672,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.8.0)(eslint@9.39.2(jiti@2.6.1)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.8.0(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 From 690097b519068f6715fe32531a6d7410c9f641ac Mon Sep 17 00:00:00 2001 From: Universe Date: Mon, 9 Feb 2026 22:12:21 +0900 Subject: [PATCH 02/16] feat: add Curve Decorations specification for 2D paths - 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. --- docs/wg/feat-2d/curve-decoration.md | 324 ++++++++++++++++++++++++++++ 1 file changed, 324 insertions(+) create mode 100644 docs/wg/feat-2d/curve-decoration.md diff --git a/docs/wg/feat-2d/curve-decoration.md b/docs/wg/feat-2d/curve-decoration.md new file mode 100644 index 0000000000..d0fa54b276 --- /dev/null +++ b/docs/wg/feat-2d/curve-decoration.md @@ -0,0 +1,324 @@ +--- +title: Curve Decorations (2D) +--- + +# 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 `` (`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 | From d60b970a74965d0c404ea449c4e23b222ceed785 Mon Sep 17 00:00:00 2001 From: Universe Date: Mon, 9 Feb 2026 22:32:57 +0900 Subject: [PATCH 03/16] feat: add example for Curve Decoration in 2D paths - 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. --- .../examples/golden_curve_decoration.rs | 364 ++++++++++++++++++ .../grida-canvas/goldens/curve_decoration.png | Bin 0 -> 90676 bytes docs/wg/feat-2d/curve-decoration.md | 22 +- 3 files changed, 375 insertions(+), 11 deletions(-) create mode 100644 crates/grida-canvas/examples/golden_curve_decoration.rs create mode 100644 crates/grida-canvas/goldens/curve_decoration.png diff --git a/crates/grida-canvas/examples/golden_curve_decoration.rs b/crates/grida-canvas/examples/golden_curve_decoration.rs new file mode 100644 index 0000000000..ec33ef4a61 --- /dev/null +++ b/crates/grida-canvas/examples/golden_curve_decoration.rs @@ -0,0 +1,364 @@ +//! # Curve Decoration – Golden Exploration +//! +//! Demonstrates the placement model defined in `docs/wg/feat-2d/curve-decoration.md`. +//! +//! ## Features demonstrated: +//! +//! | Row(s) | Spec concept | What you see | +//! |---------|-----------------------------|---------------------------------------------------| +//! | 1 | **Endpoint (start/end)** | Arrow-filled at both endpoints | +//! | 2 | **auto-start-reverse** | Start marker faces outward, end faces outward | +//! | 3 | **Join markers** | Circle at every interior vertex of a polyline | +//! | 4 | **Repeated (`every`)** | Arrow-filled every N px along a curve | +//! | 5 | **Arbitrary `at` placement**| Diamond at u=0.25, u=0.5, u=0.75 | +//! | 6 | **Orientation: `none`** | Fixed world-up squares (no tangent alignment) | +//! | 7 | **Normal offset** | Circles displaced perpendicular to the path | +//! | 8 | **Tangent offset (cutback)**| Arrow pulled back so tip sits exactly at endpoint | +//! +//! Columns: Straight, Polyline (with joins), Curve +//! +//! Intentionally **not** modularized — raw Skia exploration code. + +use skia_safe::{ + surfaces, Canvas, Color, EncodedImageFormat, Paint, PaintCap, PaintStyle, Path, PathBuilder, + PathMeasure, Point, +}; +use std::f32::consts::PI; + +// --------------------------------------------------------------------------- +// Layout +// --------------------------------------------------------------------------- +const W: i32 = 1200; +const H: i32 = 1200; +const LEFT_MARGIN: f32 = 200.0; +const TOP_MARGIN: f32 = 60.0; +const ROW_H: f32 = 130.0; +const COL_W: f32 = 310.0; +const STROKE_W: f32 = 2.5; +const M: f32 = 12.0; // marker base size + +fn main() { + let mut surface = surfaces::raster_n32_premul((W, H)).expect("surface"); + let canvas = surface.canvas(); + canvas.clear(Color::WHITE); + + let font = skia_safe::Font::new( + cg::fonts::embedded::typeface(cg::fonts::embedded::geistmono::BYTES), + 12.0, + ); + let lp = text_paint(); + + // Column headers + for (col, label) in ["Straight", "Polyline", "Curve"].iter().enumerate() { + let x = LEFT_MARGIN + col as f32 * COL_W + 50.0; + canvas.draw_str(label, Point::new(x, TOP_MARGIN - 20.0), &font, &lp); + } + + let rows: Vec<(&str, fn(&Canvas, &Path, usize))> = vec![ + ("Endpoint start/end", draw_row_endpoints), + ("auto-start-reverse", draw_row_auto_start_reverse), + ("Join markers", draw_row_join_markers), + ("Repeated (every)", draw_row_repeated), + ("At u=.25,.5,.75", draw_row_at_placement), + ("Orient: none", draw_row_orient_none), + ("Normal offset", draw_row_normal_offset), + ("Tangent offset", draw_row_tangent_offset), + ]; + + for (row, (label, draw_fn)) in rows.iter().enumerate() { + let y = TOP_MARGIN + row as f32 * ROW_H + 50.0; + canvas.draw_str(label, Point::new(6.0, y + 4.0), &font, &lp); + + for col in 0..3 { + let x = LEFT_MARGIN + col as f32 * COL_W; + canvas.save(); + canvas.translate((x, y)); + + let path = match col { + 0 => make_straight(), + 1 => make_polyline(), + _ => make_curve(), + }; + + // Draw the base path + let mut paint = stroke_paint(Color::from_rgb(60, 60, 60)); + paint.set_stroke_cap(PaintCap::Butt); + canvas.draw_path(&path, &paint); + + draw_fn(canvas, &path, col); + + canvas.restore(); + } + } + + // Save + let image = surface.image_snapshot(); + let data = image + .encode(None, EncodedImageFormat::PNG, None) + .expect("encode"); + std::fs::write( + concat!(env!("CARGO_MANIFEST_DIR"), "/goldens/curve_decoration.png"), + data.as_bytes(), + ) + .unwrap(); + println!("Saved goldens/curve_decoration.png"); +} + +// =========================================================================== +// Path builders +// =========================================================================== + +fn make_straight() -> Path { + let mut b = PathBuilder::new(); + b.move_to((0.0, 0.0)); + b.line_to((250.0, 0.0)); + b.detach() +} + +fn make_polyline() -> Path { + let mut b = PathBuilder::new(); + b.move_to((0.0, 20.0)); + b.line_to((80.0, -25.0)); + b.line_to((170.0, 25.0)); + b.line_to((250.0, -10.0)); + b.detach() +} + +fn make_curve() -> Path { + let mut b = PathBuilder::new(); + b.move_to((0.0, 0.0)); + b.cubic_to((80.0, -60.0), (170.0, 60.0), (250.0, 0.0)); + b.detach() +} + +// =========================================================================== +// Row drawing functions +// =========================================================================== + +// --- Row 1: Endpoint (start + end) with arrow-filled ----------------------- +fn draw_row_endpoints(canvas: &Canvas, path: &Path, _col: usize) { + let mut m = PathMeasure::new(path, false, None); + let len = m.length(); + if let Some((p, t)) = m.pos_tan(0.0) { + draw_arrow_filled(canvas, p, tangent_angle(&t) + PI, Color::from_rgb(220, 60, 60)); + } + if let Some((p, t)) = m.pos_tan(len) { + draw_arrow_filled(canvas, p, tangent_angle(&t), Color::from_rgb(220, 60, 60)); + } +} + +// --- Row 2: auto-start-reverse (both point outward) ------------------------ +fn draw_row_auto_start_reverse(canvas: &Canvas, path: &Path, _col: usize) { + let mut m = PathMeasure::new(path, false, None); + let len = m.length(); + // start: use -t (reversed tangent), so arrow points away from the path + if let Some((p, t)) = m.pos_tan(0.0) { + draw_arrow_filled(canvas, p, tangent_angle(&t) + PI, Color::from_rgb(60, 60, 220)); + } + // end: use t (normal tangent), arrow points away from the path + if let Some((p, t)) = m.pos_tan(len) { + draw_arrow_filled(canvas, p, tangent_angle(&t), Color::from_rgb(60, 60, 220)); + } +} + +// --- Row 3: Join markers (circle at each interior vertex) ------------------ +fn draw_row_join_markers(canvas: &Canvas, path: &Path, col: usize) { + // For the polyline column, place circle markers at interior vertices. + // For straight / curve, there are no joins — show "no joins" gracefully. + if col == 1 { + // Polyline vertices: (0,20), (80,-25), (170,25), (250,-10) + // Interior joins are indices 1 and 2. + let joins = [(80.0_f32, -25.0_f32), (170.0, 25.0)]; + for (x, y) in joins { + let pt = Point::new(x, y); + draw_circle(canvas, pt, Color::from_rgb(40, 170, 80), true); + } + } + // For straight and curve: also show start/end circles to demonstrate + // that "joins" only exist on piecewise paths. + let mut m = PathMeasure::new(path, false, None); + let len = m.length(); + if let Some((p, _)) = m.pos_tan(0.0) { + draw_circle(canvas, p, Color::from_rgb(180, 180, 180), false); + } + if let Some((p, _)) = m.pos_tan(len) { + draw_circle(canvas, p, Color::from_rgb(180, 180, 180), false); + } +} + +// --- Row 4: Repeated markers (`every` — equal arc-length intervals) -------- +fn draw_row_repeated(canvas: &Canvas, path: &Path, _col: usize) { + let mut m = PathMeasure::new(path, false, None); + let len = m.length(); + let interval = 40.0_f32; // place a marker every 40px of arc-length + let count = (len / interval).floor() as usize; + for i in 0..=count { + let d = (i as f32) * interval; + if let Some((p, t)) = m.pos_tan(d.min(len)) { + draw_arrow_filled(canvas, p, tangent_angle(&t), Color::from_rgb(220, 60, 60)); + } + } +} + +// --- Row 5: Arbitrary `at` placement (u=0.25, 0.5, 0.75) ------------------ +fn draw_row_at_placement(canvas: &Canvas, path: &Path, _col: usize) { + let mut m = PathMeasure::new(path, false, None); + let len = m.length(); + let fractions = [0.25_f32, 0.5, 0.75]; + let colors = [ + Color::from_rgb(140, 80, 200), + Color::from_rgb(200, 60, 140), + Color::from_rgb(80, 140, 200), + ]; + for (u, color) in fractions.iter().zip(colors.iter()) { + let d = u * len; + if let Some((p, t)) = m.pos_tan(d) { + draw_diamond(canvas, p, tangent_angle(&t), *color); + } + } +} + +// --- Row 6: Orientation `none` (fixed world rotation, no tangent align) ---- +fn draw_row_orient_none(canvas: &Canvas, path: &Path, _col: usize) { + let mut m = PathMeasure::new(path, false, None); + let len = m.length(); + let fractions = [0.0_f32, 0.33, 0.66, 1.0]; + for u in fractions { + let d = u * len; + if let Some((p, _t)) = m.pos_tan(d.min(len)) { + // Orientation: none — angle is fixed at 0 (world-space upward square) + draw_square(canvas, p, 0.0, Color::from_rgb(220, 140, 20)); + } + } +} + +// --- Row 7: Normal offset (circles displaced perpendicular to path) -------- +fn draw_row_normal_offset(canvas: &Canvas, path: &Path, _col: usize) { + let mut m = PathMeasure::new(path, false, None); + let len = m.length(); + let n_offset = 16.0_f32; // normal displacement in px + let fractions = [0.0_f32, 0.25, 0.5, 0.75, 1.0]; + for u in fractions { + let d = u * len; + if let Some((p, t)) = m.pos_tan(d.min(len)) { + // Left normal: n = (-ty, tx) + let nx = -t.y; + let ny = t.x; + let offset_p = Point::new(p.x + nx * n_offset, p.y + ny * n_offset); + draw_circle(canvas, offset_p, Color::from_rgb(40, 170, 80), true); + // Also draw a faint line from path to offset marker + let mut lp = stroke_paint(Color::from_rgb(180, 220, 180)); + lp.set_stroke_width(1.0); + canvas.draw_line(p, offset_p, &lp); + } + } +} + +// --- Row 8: Tangent offset (arrow pulled back from endpoint) --------------- +fn draw_row_tangent_offset(canvas: &Canvas, path: &Path, _col: usize) { + let mut m = PathMeasure::new(path, false, None); + let len = m.length(); + let pullback = M * 0.8; // tangent offset in px (arrow pulled back) + + // Start: place arrow at ℓ = pullback instead of ℓ = 0 + if let Some((p, t)) = m.pos_tan(pullback.min(len)) { + draw_arrow_filled(canvas, p, tangent_angle(&t) + PI, Color::from_rgb(180, 60, 180)); + } + // End: place arrow at ℓ = L - pullback instead of ℓ = L + if let Some((p, t)) = m.pos_tan((len - pullback).max(0.0)) { + draw_arrow_filled(canvas, p, tangent_angle(&t), Color::from_rgb(180, 60, 180)); + } + // Draw faint dots at actual endpoints for comparison + if let Some((p, _)) = m.pos_tan(0.0) { + draw_circle(canvas, p, Color::from_rgb(200, 200, 200), false); + } + if let Some((p, _)) = m.pos_tan(len) { + draw_circle(canvas, p, Color::from_rgb(200, 200, 200), false); + } +} + +// =========================================================================== +// Marker primitives +// =========================================================================== + +fn tangent_angle(t: &Point) -> f32 { + t.y.atan2(t.x) +} + +fn draw_arrow_filled(canvas: &Canvas, pos: Point, angle: f32, color: Color) { + let s = M; + canvas.save(); + canvas.translate(pos); + canvas.rotate(angle * 180.0 / PI, None); + let mut b = PathBuilder::new(); + b.move_to((0.0, 0.0)); + b.line_to((-s, -s * 0.45)); + b.line_to((-s, s * 0.45)); + b.close(); + canvas.draw_path(&b.detach(), &fill_paint(color)); + canvas.restore(); +} + +fn draw_diamond(canvas: &Canvas, pos: Point, angle: f32, color: Color) { + let s = M * 0.55; + canvas.save(); + canvas.translate(pos); + canvas.rotate(angle * 180.0 / PI, None); + let mut b = PathBuilder::new(); + b.move_to((0.0, 0.0)); + b.line_to((-s, -s)); + b.line_to((-s * 2.0, 0.0)); + b.line_to((-s, s)); + b.close(); + canvas.draw_path(&b.detach(), &fill_paint(color)); + canvas.restore(); +} + +fn draw_circle(canvas: &Canvas, pos: Point, color: Color, filled: bool) { + let r = M * 0.4; + if filled { + canvas.draw_circle(pos, r, &fill_paint(color)); + } else { + let mut p = stroke_paint(color); + p.set_stroke_width(STROKE_W); + canvas.draw_circle(pos, r, &p); + } +} + +fn draw_square(canvas: &Canvas, pos: Point, angle: f32, color: Color) { + let s = M * 0.4; + canvas.save(); + canvas.translate(pos); + canvas.rotate(angle * 180.0 / PI, None); + let rect = skia_safe::Rect::from_xywh(-s, -s, s * 2.0, s * 2.0); + canvas.draw_rect(rect, &fill_paint(color)); + canvas.restore(); +} + +// =========================================================================== +// Paint helpers +// =========================================================================== + +fn stroke_paint(color: Color) -> Paint { + let mut p = Paint::default(); + p.set_anti_alias(true); + p.set_style(PaintStyle::Stroke); + p.set_stroke_width(STROKE_W); + p.set_color(color); + p +} + +fn fill_paint(color: Color) -> Paint { + let mut p = Paint::default(); + p.set_anti_alias(true); + p.set_style(PaintStyle::Fill); + p.set_color(color); + p +} + +fn text_paint() -> Paint { + let mut p = Paint::default(); + p.set_anti_alias(true); + p.set_color(Color::from_rgb(40, 40, 40)); + p +} diff --git a/crates/grida-canvas/goldens/curve_decoration.png b/crates/grida-canvas/goldens/curve_decoration.png new file mode 100644 index 0000000000000000000000000000000000000000..0462a65527088bf44979d458fea81bea27491f53 GIT binary patch literal 90676 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 Pjn>_<=f|vVj=%mFh}bWF literal 0 HcmV?d00001 diff --git a/docs/wg/feat-2d/curve-decoration.md b/docs/wg/feat-2d/curve-decoration.md index d0fa54b276..4d3d47d529 100644 --- a/docs/wg/feat-2d/curve-decoration.md +++ b/docs/wg/feat-2d/curve-decoration.md @@ -6,9 +6,9 @@ title: 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 | +| feature id | status | description | PRs | +| ------------------ | ------ | ----------------------------------------------------- | ------------------------------------------------- | +| `curve-decoration` | draft | Endpoint/mid-path markers, orientation, scale, offset | [#538](https://github.com/gridaco/grida/pull/538) | --- @@ -110,7 +110,7 @@ This is conceptually similar to SVG `` (`refX`/`refY` as anchor, `orient ## Placement -A **Placement** determines *where* decorations appear. +A **Placement** determines _where_ decorations appear. ### Attachment domains @@ -315,10 +315,10 @@ These can be layered later on top of the same "attach glyphs to a path" primitiv ## 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 | +| 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 | From 3e1d00302c2524a9b279cb7f0740aa8b6e54838f Mon Sep 17 00:00:00 2001 From: Universe Date: Mon, 9 Feb 2026 23:02:16 +0900 Subject: [PATCH 04/16] feat: update AGENTS documentation and add format agent guide - 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. --- AGENTS.md | 24 +++++++------ format/AGENTS.md | 90 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+), 11 deletions(-) create mode 100644 format/AGENTS.md diff --git a/AGENTS.md b/AGENTS.md index bcc804cbe0..7f47b95ce0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,17 +14,19 @@ Currently, we have below features / modules. ## Project Structure -- [docs](./docs) - the docs directory -- [format](./format) - grida file formats & schemas -- [editor](./editor) - the editor directory -- [crates](./crates) - the rust crates directory -- [packages](./packages) - shared packages -- [desktop](./desktop) - the electron desktop app -- [supabase](./supabase) - the supabase project -- [apps](./apps) - micro sites for Grida -- [library](./library) - hosted library workers -- [jobs](./jobs) - hosted jobs -- [.legacy](./legacy) - will be removed (fully ignore this directory) +| directory | README | AGENTS | notes | +| ---------------------- | -------------------------------------------- | -------------------------------------------- | --------------------------------------------- | +| [docs](./docs) | - | [`docs/AGENTS.md`](./docs/AGENTS.md) | the docs directory | +| [format](./format) | [`format/README.md`](./format/README.md) | [`format/AGENTS.md`](./format/AGENTS.md) | grida file formats & schemas | +| [editor](./editor) | - | [`editor/AGENTS.md`](./editor/AGENTS.md) | the editor directory | +| [crates](./crates) | - | - | the rust crates directory | +| [packages](./packages) | - | - | shared packages | +| [desktop](./desktop) | [`desktop/README.md`](./desktop/README.md) | - | the electron desktop app | +| [supabase](./supabase) | [`supabase/README.md`](./supabase/README.md) | [`supabase/AGENTS.md`](./supabase/AGENTS.md) | the supabase project | +| [apps](./apps) | - | - | micro sites for Grida | +| [library](./library) | [`library/README.md`](./library/README.md) | - | hosted library workers | +| [jobs](./jobs) | [`jobs/README.md`](./jobs/README.md) | - | hosted jobs | +| [.legacy](./.legacy) | - | - | will be removed (fully ignore this directory) | ## Languages, Frameworks, Tools, Infrastructures diff --git a/format/AGENTS.md b/format/AGENTS.md new file mode 100644 index 0000000000..5b7254bb84 --- /dev/null +++ b/format/AGENTS.md @@ -0,0 +1,90 @@ +# `format` agent guide + +This file is **for LLM agents** working in `./format`. + +You are editing Grida’s **canonical on-disk contract** (the `.grida` file format). Treat changes here like public API changes: they ripple into Rust + TypeScript codecs, and (eventually) into real user files. + +## What lives here + +- **Schema**: `format/grida.fbs` (FlatBuffers) +- **Tooling notes**: `format/README.md` (how to validate/compile with `flatc`, including pinned `flatc` via `bin/activate-flatc`) + +## Where the schema is consumed (update these together) + +The `grida.fbs` header calls out the main alignment targets: + +- **Rust runtime model**: `crates/grida-canvas/src/node/schema.rs` +- **TS document model**: `packages/grida-canvas-schema/grida.ts` + +If you change the schema shape, **assume you must update both** the Rust and TS sides (and any serializers/deserializers that map between runtime models and FlatBuffers). + +## Pick a strategy: Evolution vs Breaking (default: Evolution) + +FlatBuffers strongly encourages **schema evolution** (additive change). That should be your default. + +### Evolution (preferred) + +Pick **Evolution** when you can ship the new capability by **adding** new fields/tables/variants while keeping old files readable. + +- **Goal**: New writers can emit more data; older readers can ignore what they don’t understand; newer readers can read both old and new files. +- **Typical moves**: add optional table fields, add new union variants, add new enum variants (append-only). + +### Breaking (exceptional, but allowed early on) + +Because the project is still early, there are rare cases where a **breaking cleanup** is worth it (e.g. a clearly wrong early modeling choice that would permanently complicate the ecosystem). + +Pick **Breaking** only when: + +- the user explicitly asks for it, **or** +- you can make a strong case that the cleanup is worth losing compatibility, and you surface that trade-off and proceed intentionally. + +If you do break compatibility, you must also update readers/writers so behavior is explicit: either **migrate**, or **fail fast with a clear error** (do not silently misinterpret old data). + +If the user didn’t request a breaking change, **stick with Evolution by default**. Only choose Breaking after you explicitly raise it as an option and get clear buy-in. + +## FlatBuffers constraints (read before editing) + +Before touching `grida.fbs`, read the block: + +- `CAUTION — FlatBuffers semantics & schema design rules (READ BEFORE EDITING)` + +The short version you must internalize: + +- **Scalars/enums/bools are always readable**: you can’t distinguish “unset” from “default”. If “unset / auto / inherited” matters, model it explicitly (nullable table, union, or explicit mode + payload). +- **Prefer `table` over `struct`**: structs are permanent and cannot evolve. Only use a struct when the all-zeros value is the correct semantic default forever. +- **Choose evolvability over compactness**: explicit intent beats clever sentinel values. + +## Evolution rules of thumb (how to change `grida.fbs` safely) + +- **Add, don’t mutate**: prefer additive changes over changing/removing existing fields. +- **Never change or reuse field ids**: this schema uses explicit `(id: N)`; treat ids as immutable. If you “remove” a field, leave the id unused. +- **Be careful with `required`**: + - Adding a new `required` field is a breaking change for existing files (older writers won’t have it). + - Use `required` only when you truly intend to invalidate older files (or you’re doing a Breaking change). +- **Enums must be append-only**: never reorder/renumber existing variants. Append new variants (or assign explicit values and keep existing values stable). +- **Unions must be forward-tolerant**: + - Add new union members at the end. + - Keep a fallback (`Unknown…`) path and ensure decoders can **skip** unknown variants rather than hard-failing. +- **Do not “fix” old defaults in-place**: changing defaults for existing scalar fields changes how old files decode. If semantics change, add a new field and migrate at the codec layer. + +## Versioning & compatibility hook (`schema_version`) + +`CanvasDocument.schema_version` exists to make compatibility behavior explicit. + +- When writer behavior changes in a meaningful way (especially for Breaking changes), **bump the version your writer emits**. +- Keep it in sync with TS `grida.program.document.SCHEMA_VERSION` (called out in the schema comment). +- Readers should use `schema_version` to decide whether to: + - accept and decode normally, + - accept with best-effort degradation, + - migrate, or + - reject with a clear error. + +## Review checklist (before you consider the work “done”) + +- **Strategy**: you can state whether this is **Evolution** or **Breaking**, and why. +- **Ripple updates**: Rust + TS models/codecs are updated consistently with the schema. +- **Compatibility**: + - Evolution: older files still load, unknown data is ignored safely. + - Breaking: old files either migrate or fail loudly (no silent misreads). +- **Schema validity**: `grida.fbs` compiles with `flatc` (see `format/README.md` for the repo’s preferred workflow). +- **No generated artifacts**: do not commit generated FlatBuffers bindings; this repo intentionally keeps generation ad-hoc/CI-driven for now (see `format/README.md`). From 1983a938d8334fc58c69c79dc4859d7c5089a75f Mon Sep 17 00:00:00 2001 From: Universe Date: Tue, 10 Feb 2026 22:54:30 +0900 Subject: [PATCH 05/16] feat: introduce stroke decoration feature for enhanced line rendering - 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. --- .../examples/golden_stroke_decoration.rs | 289 ++++++++++++++++++ crates/grida-canvas/src/cg/types.rs | 38 +++ crates/grida-canvas/src/io/io_figma.rs | 2 + crates/grida-canvas/src/io/io_grida.rs | 17 ++ crates/grida-canvas/src/layout/engine.rs | 3 + crates/grida-canvas/src/node/factory.rs | 2 + crates/grida-canvas/src/node/schema.rs | 13 + crates/grida-canvas/src/painter/layer.rs | 45 +++ crates/grida-canvas/src/painter/painter.rs | 62 ++++ .../src/painter/painter_debug_node.rs | 18 ++ crates/grida-canvas/src/shape/marker.rs | 167 ++++++++++ crates/grida-canvas/src/shape/mod.rs | 1 + crates/grida-canvas/src/vectornetwork/vn.rs | 10 + editor/grida-canvas-react/provider.tsx | 10 + editor/grida-canvas/editor.i.ts | 8 + editor/grida-canvas/editor.ts | 22 ++ editor/grida-canvas/reducers/node.reducer.ts | 10 + editor/grida-canvas/utils/supports.ts | 22 ++ .../sidecontrol/chunks/section-strokes.tsx | 23 ++ .../controls/stroke-decoration.tsx | 44 +++ format/grida.fbs | 34 +++ packages/grida-canvas-cg/lib.ts | 18 ++ packages/grida-canvas-schema/grida.ts | 23 ++ packages/grida-canvas-vn/vn.ts | 29 +- 24 files changed, 909 insertions(+), 1 deletion(-) create mode 100644 crates/grida-canvas/examples/golden_stroke_decoration.rs create mode 100644 crates/grida-canvas/src/shape/marker.rs create mode 100644 editor/scaffolds/sidecontrol/controls/stroke-decoration.tsx diff --git a/crates/grida-canvas/examples/golden_stroke_decoration.rs b/crates/grida-canvas/examples/golden_stroke_decoration.rs new file mode 100644 index 0000000000..2098f2e03e --- /dev/null +++ b/crates/grida-canvas/examples/golden_stroke_decoration.rs @@ -0,0 +1,289 @@ +//! # Stroke Decoration – Golden Test (full renderer pipeline) +//! +//! Tests the `StrokeDecoration` rendering using the **real Grida renderer** +//! (`Renderer` → layer flattening → painter → marker module). +//! +//! This exercises the same code path as the production editor, ensuring that +//! `LineNodeRec.stroke_decoration_start/end` flows through layer flattening +//! and is drawn by the painter. +//! +//! ## Layout +//! +//! | Row | Decoration variant | What you see (Straight line) | +//! |-----|-----------------------|---------------------------------------------------------| +//! | 1 | ArrowFilled | Filled triangle arrowheads (start reversed, end normal) | +//! | 2 | ArrowOpen | Open chevron arrowheads | +//! | 3 | CircleFilled | Filled circles at endpoints | +//! | 4 | DiamondFilled | Filled diamonds at endpoints | +//! | 5 | TriangleFilled | Filled triangles at endpoints | +//! | 6 | Mixed (start≠end) | ArrowFilled at start, CircleFilled at end | +//! | 7 | None (control) | No decoration, just stroke cap | +//! | 8 | Wide stroke (sw=6) | ArrowFilled on a thick stroke | + +use cg::cg::prelude::*; +use cg::node::factory::NodeFactory; +use cg::node::scene_graph::{Parent, SceneGraph}; +use cg::node::schema::*; +use cg::runtime::camera::Camera2D; +use cg::runtime::scene::{Backend, Renderer}; +use cg::vectornetwork::vn::{VectorNetwork, VectorNetworkSegment}; +use math2::rect::Rectangle; +use math2::transform::AffineTransform; + +fn build_scene() -> Scene { + let nf = NodeFactory::new(); + let mut graph = SceneGraph::new(); + + let rows: Vec<(&str, StrokeDecoration, StrokeDecoration, f32)> = vec![ + ( + "ArrowFilled", + StrokeDecoration::ArrowFilled, + StrokeDecoration::ArrowFilled, + 2.5, + ), + ( + "ArrowOpen", + StrokeDecoration::ArrowOpen, + StrokeDecoration::ArrowOpen, + 2.5, + ), + ( + "CircleFilled", + StrokeDecoration::CircleFilled, + StrokeDecoration::CircleFilled, + 2.5, + ), + ( + "DiamondFilled", + StrokeDecoration::DiamondFilled, + StrokeDecoration::DiamondFilled, + 2.5, + ), + ( + "TriangleFilled", + StrokeDecoration::TriangleFilled, + StrokeDecoration::TriangleFilled, + 2.5, + ), + ( + "Mixed", + StrokeDecoration::ArrowFilled, + StrokeDecoration::CircleFilled, + 2.5, + ), + ( + "None (control)", + StrokeDecoration::None, + StrokeDecoration::None, + 2.5, + ), + ( + "Wide stroke", + StrokeDecoration::ArrowFilled, + StrokeDecoration::ArrowFilled, + 6.0, + ), + ]; + + for (i, (_label, start, end, sw)) in rows.iter().enumerate() { + let y = 50.0 + i as f32 * 80.0; + + let mut line = nf.create_line_node(); + line.transform = AffineTransform::new(80.0, y, 0.0); + line.size = Size { + width: 400.0, + height: 0.0, + }; + line.stroke_width = *sw; + line.strokes = Paints::new([Paint::from(CGColor::from_rgba(60, 60, 60, 255))]); + line.stroke_cap = StrokeCap::Butt; + line.stroke_decoration_start = *start; + line.stroke_decoration_end = *end; + + graph.append_child(Node::Line(line), Parent::Root); + } + + // ----------------------------------------------------------------------- + // VectorNode rows — Straight, Zigzag, Curve through the Vector pipeline + // ----------------------------------------------------------------------- + + // Helper to build a VectorNodeRec + let make_vector = |network: VectorNetwork, + x: f32, + y: f32, + start: StrokeDecoration, + end: StrokeDecoration, + color: CGColor| + -> VectorNodeRec { + VectorNodeRec { + active: true, + opacity: 1.0, + blend_mode: LayerBlendMode::default(), + mask: None, + effects: LayerEffects::default(), + transform: AffineTransform::new(x, y, 0.0), + network, + corner_radius: 0.0, + fills: Paints::default(), + strokes: Paints::new([Paint::from(color)]), + stroke_width: 2.5, + stroke_width_profile: None, + stroke_align: StrokeAlign::Center, + stroke_cap: StrokeCap::Butt, + stroke_join: StrokeJoin::default(), + stroke_miter_limit: StrokeMiterLimit::default(), + stroke_dash_array: None, + stroke_decoration_start: start, + stroke_decoration_end: end, + vertex_overrides: Vec::new(), + layout_child: None, + } + }; + + // --- Networks --- + let straight_net = || VectorNetwork { + vertices: vec![(0.0, 0.0), (400.0, 0.0)], + segments: vec![VectorNetworkSegment::ab(0, 1)], + regions: vec![], + }; + + let zigzag_net = || VectorNetwork { + vertices: vec![ + (0.0, 20.0), + (100.0, -20.0), + (200.0, 20.0), + (300.0, -20.0), + (400.0, 10.0), + ], + segments: vec![ + VectorNetworkSegment::ab(0, 1), + VectorNetworkSegment::ab(1, 2), + VectorNetworkSegment::ab(2, 3), + VectorNetworkSegment::ab(3, 4), + ], + regions: vec![], + }; + + let curve_net = || VectorNetwork { + vertices: vec![(0.0, 0.0), (400.0, 0.0)], + segments: vec![VectorNetworkSegment { + a: 0, + b: 1, + ta: (120.0, -80.0), + tb: (-120.0, -80.0), + }], + regions: vec![], + }; + + let blue = CGColor::from_rgba(60, 60, 220, 255); + let base_y = 50.0 + rows.len() as f32 * 80.0; + + // Row: Vec Straight — ArrowFilled both ends + let y = base_y; + graph.append_child( + Node::Vector(make_vector( + straight_net(), + 80.0, + y, + StrokeDecoration::ArrowFilled, + StrokeDecoration::ArrowFilled, + blue, + )), + Parent::Root, + ); + + // Row: Vec Zigzag — ArrowFilled + CircleFilled + let y = base_y + 80.0; + graph.append_child( + Node::Vector(make_vector( + zigzag_net(), + 80.0, + y, + StrokeDecoration::ArrowFilled, + StrokeDecoration::CircleFilled, + blue, + )), + Parent::Root, + ); + + // Row: Vec Curve — DiamondFilled both ends + let y = base_y + 160.0; + graph.append_child( + Node::Vector(make_vector( + curve_net(), + 80.0, + y, + StrokeDecoration::DiamondFilled, + StrokeDecoration::DiamondFilled, + blue, + )), + Parent::Root, + ); + + // Row: Vec Curve — ArrowFilled + TriangleFilled + let y = base_y + 240.0; + graph.append_child( + Node::Vector(make_vector( + curve_net(), + 80.0, + y, + StrokeDecoration::ArrowFilled, + StrokeDecoration::TriangleFilled, + blue, + )), + Parent::Root, + ); + + // Row: Vec Zigzag — ArrowOpen both ends + let y = base_y + 320.0; + graph.append_child( + Node::Vector(make_vector( + zigzag_net(), + 80.0, + y, + StrokeDecoration::ArrowOpen, + StrokeDecoration::ArrowOpen, + blue, + )), + Parent::Root, + ); + + Scene { + name: "stroke decoration golden".into(), + graph, + background_color: Some(CGColor::WHITE), + } +} + +#[tokio::main] +async fn main() { + let scene = build_scene(); + + let width = 600.0; + let height = 1120.0; + + let mut renderer = Renderer::new( + Backend::new_from_raster(width as i32, height as i32), + None, + Camera2D::new_from_bounds(Rectangle::from_xywh(0.0, 0.0, width, height)), + ); + renderer.load_scene(scene); + + let surface = unsafe { &mut *renderer.backend.get_surface() }; + let canvas = surface.canvas(); + renderer.render_to_canvas(canvas, width, height); + + let image = surface.image_snapshot(); + let data = image + .encode(None, skia_safe::EncodedImageFormat::PNG, None) + .unwrap(); + + let path = concat!( + env!("CARGO_MANIFEST_DIR"), + "/goldens/stroke_decoration.png" + ); + std::fs::write(path, data.as_bytes()).unwrap(); + println!("Saved {}", path); + + renderer.free(); +} diff --git a/crates/grida-canvas/src/cg/types.rs b/crates/grida-canvas/src/cg/types.rs index c07e1543bc..0e771ae749 100644 --- a/crates/grida-canvas/src/cg/types.rs +++ b/crates/grida-canvas/src/cg/types.rs @@ -411,6 +411,44 @@ impl Default for StrokeCap { } } +/// Marker decoration placed at stroke endpoints or vector vertices. +/// +/// Unlike [`StrokeCap`] (which maps to native backend caps like Skia `PaintCap`), +/// `StrokeDecoration` represents explicit marker geometry drawn on top of the +/// stroke path. When a decoration is present at an endpoint, the renderer +/// uses `Butt` cap at that endpoint and draws the marker geometry instead. +/// +/// See: `docs/wg/feat-2d/curve-decoration.md` +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] +pub enum StrokeDecoration { + /// No decoration (endpoint uses the node's stroke_cap as normal). + #[default] + #[serde(rename = "none")] + None, + /// Open arrow (two lines forming a ">" shape). + #[serde(rename = "arrow_open")] + ArrowOpen, + /// Filled triangular arrowhead. + #[serde(rename = "arrow_filled")] + ArrowFilled, + /// Filled diamond shape. + #[serde(rename = "diamond_filled")] + DiamondFilled, + /// Filled equilateral triangle. + #[serde(rename = "triangle_filled")] + TriangleFilled, + /// Filled circle. + #[serde(rename = "circle_filled")] + CircleFilled, +} + +impl StrokeDecoration { + /// Returns `true` if this decoration has visible marker geometry. + pub fn has_marker(&self) -> bool { + !matches!(self, StrokeDecoration::None) + } +} + /// Defines how corners (path segment joins) are rendered when stroked. /// /// `StrokeJoin` determines the appearance of corners where two path segments meet. diff --git a/crates/grida-canvas/src/io/io_figma.rs b/crates/grida-canvas/src/io/io_figma.rs index 2ba87f357f..36cd47356c 100644 --- a/crates/grida-canvas/src/io/io_figma.rs +++ b/crates/grida-canvas/src/io/io_figma.rs @@ -1604,6 +1604,8 @@ impl FigmaConverter { .stroke_dashes .clone() .map(|v| v.into_iter().map(|x| x as f32).collect()), + stroke_decoration_start: StrokeDecoration::default(), + stroke_decoration_end: StrokeDecoration::default(), layout_child: Some(LayoutChildStyle { layout_positioning: origin .layout_positioning diff --git a/crates/grida-canvas/src/io/io_grida.rs b/crates/grida-canvas/src/io/io_grida.rs index ca3656b532..a73eceace9 100644 --- a/crates/grida-canvas/src/io/io_grida.rs +++ b/crates/grida-canvas/src/io/io_grida.rs @@ -831,6 +831,18 @@ pub struct JSONUnknownNodeProperties { alias = "strokeDasharray" )] pub stroke_dash_array: Option>, + #[serde( + rename = "stroke_decoration_start", + alias = "strokeDecorationStart", + default + )] + pub stroke_decoration_start: Option, + #[serde( + rename = "stroke_decoration_end", + alias = "strokeDecorationEnd", + default + )] + pub stroke_decoration_end: Option, #[serde(rename = "stroke")] pub stroke: Option, #[serde(rename = "stroke_paints")] @@ -1813,6 +1825,8 @@ impl From for Node { stroke_miter_limit: node.base.stroke_miter_limit.unwrap_or_default(), _data_stroke_align: node.base.stroke_align.unwrap_or(StrokeAlign::Center), stroke_dash_array: node.base.stroke_dash_array.map(StrokeDashArray::from), + stroke_decoration_start: node.base.stroke_decoration_start.unwrap_or_default(), + stroke_decoration_end: node.base.stroke_decoration_end.unwrap_or_default(), layout_child: Some(LayoutChildStyle { layout_positioning: node .base @@ -1869,6 +1883,9 @@ impl From for Node { stroke_join: node.base.stroke_join.unwrap_or_default(), stroke_miter_limit: node.base.stroke_miter_limit.unwrap_or_default(), stroke_dash_array: node.base.stroke_dash_array.map(StrokeDashArray::from), + stroke_decoration_start: node.base.stroke_decoration_start.unwrap_or_default(), + stroke_decoration_end: node.base.stroke_decoration_end.unwrap_or_default(), + vertex_overrides: Vec::new(), layout_child: Some(LayoutChildStyle { layout_positioning: node .base diff --git a/crates/grida-canvas/src/layout/engine.rs b/crates/grida-canvas/src/layout/engine.rs index ad7ce143c9..a96c70cfc6 100644 --- a/crates/grida-canvas/src/layout/engine.rs +++ b/crates/grida-canvas/src/layout/engine.rs @@ -1440,6 +1440,9 @@ mod tests { stroke_join: StrokeJoin::default(), stroke_miter_limit: StrokeMiterLimit::default(), stroke_dash_array: None, + stroke_decoration_start: StrokeDecoration::default(), + stroke_decoration_end: StrokeDecoration::default(), + vertex_overrides: Vec::new(), layout_child: None, }; diff --git a/crates/grida-canvas/src/node/factory.rs b/crates/grida-canvas/src/node/factory.rs index 32b2d3f8f4..ffdd210830 100644 --- a/crates/grida-canvas/src/node/factory.rs +++ b/crates/grida-canvas/src/node/factory.rs @@ -122,6 +122,8 @@ impl NodeFactory { stroke_miter_limit: StrokeMiterLimit::default(), _data_stroke_align: Self::DEFAULT_STROKE_ALIGN, stroke_dash_array: None, + stroke_decoration_start: StrokeDecoration::default(), + stroke_decoration_end: StrokeDecoration::default(), layout_child: None, } } diff --git a/crates/grida-canvas/src/node/schema.rs b/crates/grida-canvas/src/node/schema.rs index 3a235f0b33..e1c70c0c87 100644 --- a/crates/grida-canvas/src/node/schema.rs +++ b/crates/grida-canvas/src/node/schema.rs @@ -1262,6 +1262,11 @@ pub struct LineNodeRec { pub stroke_dash_array: Option, pub _data_stroke_align: StrokeAlign, + /// Marker decoration at the start endpoint of the line. + pub stroke_decoration_start: StrokeDecoration, + /// Marker decoration at the end endpoint of the line. + pub stroke_decoration_end: StrokeDecoration, + /// Layout style for this node when it is a child of a layout container. pub layout_child: Option, } @@ -1557,6 +1562,14 @@ pub struct VectorNodeRec { pub stroke_miter_limit: StrokeMiterLimit, pub stroke_dash_array: Option, + /// Marker decoration at the start endpoint (first vertex). + pub stroke_decoration_start: StrokeDecoration, + /// Marker decoration at the end endpoint (last vertex). + pub stroke_decoration_end: StrokeDecoration, + + /// Per-vertex stroke decoration overrides. + pub vertex_overrides: Vec, + /// Layout style for this node when it is a child of a layout container. pub layout_child: Option, } diff --git a/crates/grida-canvas/src/painter/layer.rs b/crates/grida-canvas/src/painter/layer.rs index b8d68d51ad..5c94751e07 100644 --- a/crates/grida-canvas/src/painter/layer.rs +++ b/crates/grida-canvas/src/painter/layer.rs @@ -154,6 +154,12 @@ pub struct PainterPictureShapeLayer { pub strokes: Paints, pub fills: Paints, pub stroke_path: Option, + /// Stroke decoration at the start endpoint (line nodes). + pub stroke_decoration_start: StrokeDecoration, + /// Stroke decoration at the end endpoint (line nodes). + pub stroke_decoration_end: StrokeDecoration, + /// Stroke width needed for decoration sizing. + pub stroke_width: f32, } #[derive(Debug, Clone)] @@ -193,6 +199,10 @@ pub struct PainterPictureVectorLayer { pub stroke_width_profile: Option, pub stroke_dash_array: Option, pub corner_radius: f32, + /// Stroke decoration at the start endpoint (first vertex). + pub stroke_decoration_start: StrokeDecoration, + /// Stroke decoration at the end endpoint (last vertex). + pub stroke_decoration_end: StrokeDecoration, } /// A layer with its associated node ID. @@ -412,6 +422,9 @@ impl LayerList { strokes: Self::filter_visible_paints(&n.strokes), fills: Self::filter_visible_paints(&n.fills), stroke_path, + stroke_decoration_start: StrokeDecoration::None, + stroke_decoration_end: StrokeDecoration::None, + stroke_width: 0.0, }); out.push(LayerEntry { id: id.clone(), @@ -472,6 +485,9 @@ impl LayerList { strokes: Self::filter_visible_paints(&n.strokes), fills: Self::filter_visible_paints(&n.fills), stroke_path, + stroke_decoration_start: StrokeDecoration::None, + stroke_decoration_end: StrokeDecoration::None, + stroke_width: 0.0, }); out.push(LayerEntry { id: id.clone(), @@ -522,6 +538,9 @@ impl LayerList { strokes: Self::filter_visible_paints(&n.strokes), fills: Self::filter_visible_paints(&n.fills), stroke_path, + stroke_decoration_start: StrokeDecoration::None, + stroke_decoration_end: StrokeDecoration::None, + stroke_width: 0.0, }); out.push(LayerEntry { id: id.clone(), @@ -566,6 +585,9 @@ impl LayerList { strokes: Self::filter_visible_paints(&n.strokes), fills: Self::filter_visible_paints(&n.fills), stroke_path, + stroke_decoration_start: StrokeDecoration::None, + stroke_decoration_end: StrokeDecoration::None, + stroke_width: 0.0, }); out.push(LayerEntry { id: id.clone(), @@ -610,6 +632,9 @@ impl LayerList { strokes: Self::filter_visible_paints(&n.strokes), fills: Self::filter_visible_paints(&n.fills), stroke_path, + stroke_decoration_start: StrokeDecoration::None, + stroke_decoration_end: StrokeDecoration::None, + stroke_width: 0.0, }); out.push(LayerEntry { id: id.clone(), @@ -654,6 +679,9 @@ impl LayerList { strokes: Self::filter_visible_paints(&n.strokes), fills: Self::filter_visible_paints(&n.fills), stroke_path, + stroke_decoration_start: StrokeDecoration::None, + stroke_decoration_end: StrokeDecoration::None, + stroke_width: 0.0, }); out.push(LayerEntry { id: id.clone(), @@ -698,6 +726,9 @@ impl LayerList { strokes: Self::filter_visible_paints(&n.strokes), fills: Self::filter_visible_paints(&n.fills), stroke_path, + stroke_decoration_start: StrokeDecoration::None, + stroke_decoration_end: StrokeDecoration::None, + stroke_width: 0.0, }); out.push(LayerEntry { id: id.clone(), @@ -741,6 +772,9 @@ impl LayerList { strokes: n.strokes.clone(), fills: Paints::default(), stroke_path, + stroke_decoration_start: n.stroke_decoration_start, + stroke_decoration_end: n.stroke_decoration_end, + stroke_width: n.stroke_width, }); out.push(LayerEntry { id: id.clone(), @@ -849,6 +883,9 @@ impl LayerList { strokes: Self::filter_visible_paints(&n.strokes), fills: Self::filter_visible_paints(&n.fills), stroke_path, + stroke_decoration_start: StrokeDecoration::None, + stroke_decoration_end: StrokeDecoration::None, + stroke_width: 0.0, }); out.push(LayerEntry { id: id.clone(), @@ -887,6 +924,8 @@ impl LayerList { stroke_width_profile: n.stroke_width_profile.clone(), stroke_dash_array: n.stroke_dash_array.clone(), corner_radius: n.corner_radius, + stroke_decoration_start: n.stroke_decoration_start, + stroke_decoration_end: n.stroke_decoration_end, }); out.push(LayerEntry { id: id.clone(), @@ -933,6 +972,9 @@ impl LayerList { n.fill.clone(), )])), stroke_path, + stroke_decoration_start: StrokeDecoration::None, + stroke_decoration_end: StrokeDecoration::None, + stroke_width: 0.0, }); out.push(LayerEntry { id: id.clone(), @@ -963,6 +1005,9 @@ impl LayerList { strokes: Paints::default(), fills: Paints::default(), stroke_path: None, + stroke_decoration_start: StrokeDecoration::None, + stroke_decoration_end: StrokeDecoration::None, + stroke_width: 0.0, }); out.push(LayerEntry { id: id.clone(), diff --git a/crates/grida-canvas/src/painter/painter.rs b/crates/grida-canvas/src/painter/painter.rs index 75028894cd..96b1493f05 100644 --- a/crates/grida-canvas/src/painter/painter.rs +++ b/crates/grida-canvas/src/painter/painter.rs @@ -717,6 +717,34 @@ impl<'a> Painter<'a> { } } + /// Draw stroke decoration markers at the start/end endpoints of a path. + pub fn draw_stroke_decorations( + &self, + shape: &PainterShape, + strokes: &[Paint], + stroke_width: f32, + start: StrokeDecoration, + end: StrokeDecoration, + ) { + if !start.has_marker() && !end.has_marker() { + return; + } + if let Some(sk_paint) = paint::sk_paint_stack( + strokes, + (shape.rect.width(), shape.rect.height()), + self.images, + ) { + crate::shape::marker::draw_endpoint_decorations( + self.canvas, + &shape.to_path(), + start, + end, + stroke_width, + &sk_paint, + ); + } + } + /// Draw a shape applying all layer effects in the correct order. /// /// Effect ordering (as per specification): @@ -940,6 +968,15 @@ impl<'a> Painter<'a> { &shape_layer.strokes, ); } + + // 4. Stroke decorations (markers at endpoints) + self.draw_stroke_decorations( + shape, + &shape_layer.strokes, + shape_layer.stroke_width, + shape_layer.stroke_decoration_start, + shape_layer.stroke_decoration_end, + ); } }); }; @@ -1123,6 +1160,31 @@ impl<'a> Painter<'a> { vector_layer.corner_radius, ); } + + // 4. Stroke decorations (markers at endpoints) + // For VectorNode, use to_paths() to get the raw open path + // (to_union_path()/shape.to_path() collapses open paths) + if vector_layer.stroke_decoration_start.has_marker() + || vector_layer.stroke_decoration_end.has_marker() + { + let paths = vector_layer.vector.to_paths(); + if let Some(vn_path) = paths.first() { + if let Some(sk_paint) = paint::sk_paint_stack( + &vector_layer.strokes, + (shape.rect.width(), shape.rect.height()), + self.images, + ) { + crate::shape::marker::draw_endpoint_decorations( + self.canvas, + vn_path, + vector_layer.stroke_decoration_start, + vector_layer.stroke_decoration_end, + vector_layer.stroke_width, + &sk_paint, + ); + } + } + } } }); }; diff --git a/crates/grida-canvas/src/painter/painter_debug_node.rs b/crates/grida-canvas/src/painter/painter_debug_node.rs index 27d98f27cd..0a3a38622f 100644 --- a/crates/grida-canvas/src/painter/painter_debug_node.rs +++ b/crates/grida-canvas/src/painter/painter_debug_node.rs @@ -163,6 +163,15 @@ impl<'a> NodePainter<'a> { node.stroke_miter_limit, node.stroke_dash_array.as_ref(), ); + + // Draw stroke decorations (markers at endpoints) + self.painter.draw_stroke_decorations( + &shape, + &node.strokes, + node.stroke_width, + node.stroke_decoration_start, + node.stroke_decoration_end, + ); }, ); }); @@ -198,6 +207,15 @@ impl<'a> NodePainter<'a> { node.stroke_miter_limit, node.stroke_dash_array.as_ref(), ); + + // Draw stroke decorations (markers at endpoints) + self.painter.draw_stroke_decorations( + &shape, + &node.strokes, + node.stroke_width, + node.stroke_decoration_start, + node.stroke_decoration_end, + ); }, ); }); diff --git a/crates/grida-canvas/src/shape/marker.rs b/crates/grida-canvas/src/shape/marker.rs new file mode 100644 index 0000000000..c4c9f55392 --- /dev/null +++ b/crates/grida-canvas/src/shape/marker.rs @@ -0,0 +1,167 @@ +//! Marker geometry builders for stroke decorations. +//! +//! Provides reusable marker shapes (arrow, diamond, triangle, circle) that are +//! drawn at stroke endpoints or vector vertices. Each builder produces geometry +//! in local "marker space" where the tip/center is at the origin and the +//! forward direction is +X. The caller is responsible for translating and +//! rotating the marker to the correct position/orientation on the path. +//! +//! See: `docs/wg/feat-2d/curve-decoration.md` + +use crate::cg::StrokeDecoration; +use skia_safe::{Canvas, Paint, PaintStyle, Path, PathBuilder, PathMeasure}; +use std::f32::consts::PI; + +/// Default scale factor for marker size relative to stroke width. +const MARKER_SCALE: f32 = 3.0; + +/// Returns marker geometry in local marker space, if the decoration has visible geometry. +/// +/// - Tip/center is at the origin. +/// - Forward direction is +X axis. +/// - `size` is the base dimension (typically `stroke_width * MARKER_SCALE`). +pub fn marker_path(decoration: StrokeDecoration, size: f32) -> Option { + match decoration { + StrokeDecoration::None => None, + StrokeDecoration::ArrowOpen => Some(build_arrow_open(size)), + StrokeDecoration::ArrowFilled => Some(build_arrow_filled(size)), + StrokeDecoration::DiamondFilled => Some(build_diamond(size)), + StrokeDecoration::TriangleFilled => Some(build_triangle(size)), + StrokeDecoration::CircleFilled => Some(build_circle(size)), + } +} + +/// Compute the marker size from stroke width. +pub fn marker_size(stroke_width: f32) -> f32 { + stroke_width * MARKER_SCALE +} + +/// Draw a decoration marker at a given arc-length distance on a path. +/// +/// Uses `PathMeasure` to get position + tangent at `distance`, then orients the +/// marker along the tangent. +/// +/// - `reverse`: if `true`, flip the tangent 180 degrees (for start-of-path markers +/// that should point outward, matching "auto-start-reverse" semantics). +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) { + 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 }; + canvas.save(); + canvas.translate(pos); + canvas.rotate((angle + flip) * 180.0 / PI, None); + canvas.draw_path(&marker, paint); + canvas.restore(); + } + } +} + +/// Draw start and end decorations on a path in one call. +/// +/// When a decoration is `None`, the corresponding endpoint is skipped. +/// The `stroke_paint` should be a fill-style paint derived from the stroke color. +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(); + + // Derive a fill paint from the stroke paint + let mut fill = stroke_paint.clone(); + fill.set_style(PaintStyle::Fill); + + if start.has_marker() { + draw_decoration_at(canvas, &mut measure, 0.0, start, stroke_width, &fill, true); + } + if end.has_marker() { + draw_decoration_at( + canvas, + &mut measure, + length, + end, + stroke_width, + &fill, + false, + ); + } +} + +// --------------------------------------------------------------------------- +// Private shape builders +// --------------------------------------------------------------------------- + +/// Filled triangular arrowhead pointing in the +X direction. +/// Tip at origin, base at -size along X. +fn build_arrow_filled(size: f32) -> Path { + let s = size; + let mut b = PathBuilder::new(); + b.move_to((0.0, 0.0)); + b.line_to((-s, -s * 0.45)); + b.line_to((-s, s * 0.45)); + b.close(); + b.detach() +} + +/// Open arrow (chevron ">") pointing in the +X direction. +/// Tip at origin, arms extend to -size along X. +fn build_arrow_open(size: f32) -> Path { + let s = size; + let mut b = PathBuilder::new(); + b.move_to((-s, -s * 0.45)); + b.line_to((0.0, 0.0)); + b.line_to((-s, s * 0.45)); + // Not closed — open stroke shape + b.detach() +} + +/// Filled diamond shape centered at origin. +fn build_diamond(size: f32) -> Path { + let s = size * 0.55; + let mut b = PathBuilder::new(); + b.move_to((s, 0.0)); + b.line_to((0.0, -s)); + b.line_to((-s, 0.0)); + b.line_to((0.0, s)); + b.close(); + b.detach() +} + +/// Filled equilateral triangle pointing in the +X direction. +/// Centered at origin. +fn build_triangle(size: f32) -> Path { + let s = size * 0.5; + let mut b = PathBuilder::new(); + b.move_to((s, 0.0)); + b.line_to((-s * 0.5, -s * 0.866)); + b.line_to((-s * 0.5, s * 0.866)); + b.close(); + b.detach() +} + +/// Filled circle centered at origin. +fn build_circle(size: f32) -> Path { + let r = size * 0.35; + let rect = skia_safe::Rect::from_xywh(-r, -r, r * 2.0, r * 2.0); + let mut b = PathBuilder::new(); + b.add_oval(rect, None, None); + b.detach() +} diff --git a/crates/grida-canvas/src/shape/mod.rs b/crates/grida-canvas/src/shape/mod.rs index 2975b9ab23..3251fedc88 100644 --- a/crates/grida-canvas/src/shape/mod.rs +++ b/crates/grida-canvas/src/shape/mod.rs @@ -3,6 +3,7 @@ pub mod ellipse; pub mod ellipse_ring; pub mod ellipse_ring_sector; pub mod ellipse_sector; +pub mod marker; pub mod polygon; pub mod rect; pub mod regular_polygon; diff --git a/crates/grida-canvas/src/vectornetwork/vn.rs b/crates/grida-canvas/src/vectornetwork/vn.rs index b595b960cf..7f11d78f84 100644 --- a/crates/grida-canvas/src/vectornetwork/vn.rs +++ b/crates/grida-canvas/src/vectornetwork/vn.rs @@ -2,6 +2,16 @@ use crate::cg::prelude::*; use math2::Rectangle; use skia_safe; +/// Sparse per-vertex property override for vector networks. +/// +/// Allows individual vertices to have custom stroke decorations. +/// Indexed by `vertex_index` into `VectorNetwork.vertices`. +#[derive(Debug, Clone)] +pub struct VectorVertexOverride { + pub vertex_index: usize, + pub stroke_decoration: StrokeDecoration, +} + #[derive(Debug, Clone, PartialEq)] pub struct VectorNetworkSegment { pub a: usize, diff --git a/editor/grida-canvas-react/provider.tsx b/editor/grida-canvas-react/provider.tsx index f1c3190fc9..72ffcdcabd 100644 --- a/editor/grida-canvas-react/provider.tsx +++ b/editor/grida-canvas-react/provider.tsx @@ -163,6 +163,16 @@ export function useNodeActions(node_id: string | undefined) { instance.commands.changeNodePropertyStrokeAlign(node_id, value), strokeCap: (value: cg.StrokeCap) => instance.commands.changeNodePropertyStrokeCap(node_id, value), + strokeDecorationStart: (value: cg.StrokeDecoration) => + instance.commands.changeNodePropertyStrokeDecorationStart( + node_id, + value + ), + strokeDecorationEnd: (value: cg.StrokeDecoration) => + instance.commands.changeNodePropertyStrokeDecorationEnd( + node_id, + value + ), strokeJoin: (value: cg.StrokeJoin) => instance.commands.changeNodePropertyStrokeJoin(node_id, value), strokeMiterLimit: (value: number) => diff --git a/editor/grida-canvas/editor.i.ts b/editor/grida-canvas/editor.i.ts index a5d8a4aa70..dec6e1207d 100644 --- a/editor/grida-canvas/editor.i.ts +++ b/editor/grida-canvas/editor.i.ts @@ -3623,6 +3623,14 @@ export namespace editor.api { strokeDashArray: number[] | undefined ): void; changeNodePropertyStrokeCap(node_id: NodeID, strokeCap: cg.StrokeCap): void; + changeNodePropertyStrokeDecorationStart( + node_id: NodeID, + decoration: cg.StrokeDecoration + ): void; + changeNodePropertyStrokeDecorationEnd( + node_id: NodeID, + decoration: cg.StrokeDecoration + ): void; changeNodePropertyStrokeJoin( node_id: NodeID, strokeJoin: cg.StrokeJoin diff --git a/editor/grida-canvas/editor.ts b/editor/grida-canvas/editor.ts index f60864c1f3..d5a1640bfb 100644 --- a/editor/grida-canvas/editor.ts +++ b/editor/grida-canvas/editor.ts @@ -1957,6 +1957,28 @@ class EditorDocumentStore }); } + changeNodePropertyStrokeDecorationStart( + node_id: string, + decoration: cg.StrokeDecoration + ) { + this.dispatch({ + type: "node/change/*", + node_id: node_id, + stroke_decoration_start: decoration, + }); + } + + changeNodePropertyStrokeDecorationEnd( + node_id: string, + decoration: cg.StrokeDecoration + ) { + this.dispatch({ + type: "node/change/*", + node_id: node_id, + stroke_decoration_end: decoration, + }); + } + changeNodePropertyStrokeJoin(node_id: string, strokeJoin: cg.StrokeJoin) { this.dispatch({ type: "node/change/*", diff --git a/editor/grida-canvas/reducers/node.reducer.ts b/editor/grida-canvas/reducers/node.reducer.ts index 42e0086a4f..59751aacdf 100644 --- a/editor/grida-canvas/reducers/node.reducer.ts +++ b/editor/grida-canvas/reducers/node.reducer.ts @@ -488,6 +488,16 @@ const safe_properties: Partial< (draft as UN).stroke_cap = value; }, }), + stroke_decoration_start: defineNodeProperty<"stroke_decoration_start">({ + apply: (draft, value) => { + (draft as UN).stroke_decoration_start = value; + }, + }), + stroke_decoration_end: defineNodeProperty<"stroke_decoration_end">({ + apply: (draft, value) => { + (draft as UN).stroke_decoration_end = value; + }, + }), stroke_join: defineNodeProperty<"stroke_join">({ apply: (draft, value, prev) => { (draft as UN).stroke_join = value; diff --git a/editor/grida-canvas/utils/supports.ts b/editor/grida-canvas/utils/supports.ts index ba9de87754..6547e08851 100644 --- a/editor/grida-canvas/utils/supports.ts +++ b/editor/grida-canvas/utils/supports.ts @@ -20,6 +20,8 @@ type NodeFeatureProperty = | "stroke_paints" | "feDropShadow" | "strokeCap" + | "strokeDecorationStart" + | "strokeDecorationEnd" | "strokeWidth" | "strokeWidth4" | "pointCount" @@ -115,6 +117,8 @@ const dom_supports: Record> = { * strokeCap value itself is supported by all istroke nodes, yet it should be visible to editor only for polyline and line nodes. (path-like nodes) */ strokeCap: ["vector", "line"], + strokeDecorationStart: ["line", "vector"], + strokeDecorationEnd: ["line", "vector"], pointCount: ["polygon", "star"], boolean: [], } as const; @@ -246,6 +250,8 @@ const canvas_supports: Record> = { "component", "boolean", ], + strokeDecorationStart: ["line", "vector"], + strokeDecorationEnd: ["line", "vector"], pointCount: ["polygon", "star"], boolean: ["boolean", "rectangle", "polygon", "star"], } as const; @@ -351,6 +357,22 @@ export namespace supports { return canvas_supports.strokeCap.includes(type); } }; + export const strokeDecorationStart = (type: NodeType, context: Context) => { + switch (context.backend) { + case "dom": + return dom_supports.strokeDecorationStart.includes(type); + case "canvas": + return canvas_supports.strokeDecorationStart.includes(type); + } + }; + export const strokeDecorationEnd = (type: NodeType, context: Context) => { + switch (context.backend) { + case "dom": + return dom_supports.strokeDecorationEnd.includes(type); + case "canvas": + return canvas_supports.strokeDecorationEnd.includes(type); + } + }; export const feDropShadow = (type: NodeType, context: Context) => { switch (context.backend) { case "dom": diff --git a/editor/scaffolds/sidecontrol/chunks/section-strokes.tsx b/editor/scaffolds/sidecontrol/chunks/section-strokes.tsx index cce2c3ae08..354000d0da 100644 --- a/editor/scaffolds/sidecontrol/chunks/section-strokes.tsx +++ b/editor/scaffolds/sidecontrol/chunks/section-strokes.tsx @@ -14,6 +14,7 @@ import { } from "../controls/stroke-width"; import { StrokeAlignControl } from "../controls/stroke-align"; import { StrokeCapControl } from "../controls/stroke-cap"; +import { StrokeDecorationControl } from "../controls/stroke-decoration"; import { StrokeJoinControl } from "../controls/stroke-join"; import { StrokeMiterLimitControl } from "../controls/stroke-miter-limit"; import { StrokeClassControl, StrokeClass } from "../controls/stroke-class"; @@ -76,6 +77,8 @@ export function SectionStrokes({ rectangular_stroke_width_left, stroke_align, stroke_cap, + stroke_decoration_start, + stroke_decoration_end, stroke_join, stroke_miter_limit, stroke_dash_array, @@ -90,6 +93,8 @@ export function SectionStrokes({ rectangular_stroke_width_left: node.rectangular_stroke_width_left, stroke_align: node.stroke_align, stroke_cap: node.stroke_cap, + stroke_decoration_start: node.stroke_decoration_start, + stroke_decoration_end: node.stroke_decoration_end, stroke_join: node.stroke_join, stroke_miter_limit: node.stroke_miter_limit, stroke_dash_array: node.stroke_dash_array, @@ -295,6 +300,24 @@ export function SectionStrokes({ onValueChange={actions.strokeCap} /> + {(type === "line" || type === "vector") && ( + <> + + Start + + + + End + + + + )}