diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f65cb3..15b9699 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,7 +21,7 @@ - Live agent proposal workflow with ghost preview, partial acceptance, and explicit-apply Direct mode. - Offline Automerge sync between trusted peers through a transport-neutral - envelope and bounded per-peer checkpoints. + envelope and per-peer checkpoints. - Bundled agent skill with worked examples covering file edits, proposal review, and stale-head recovery. - Excalidraw and Obsidian Canvas (JSON Canvas) import and export. @@ -37,7 +37,10 @@ #### SVG Interop - Native path shape representation with normalized move, line, quadratic, and - cubic subpaths, closed-path flags, compound fill rules, & generated bindings + cubic subpaths, closed-path flags, compound fill rules, and generated bindings. +- Native path bounds with Bézier extrema, Canvas rendering, fill and stroke hit + testing, parent-relative transforms, deterministic SVG output, and shared + valid/invalid geometry fixtures. ### Changed diff --git a/ROADMAP.md b/ROADMAP.md index 5d43ed9..c244ba0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -40,8 +40,8 @@ SVG interoperability must use the same transaction, persistence, undo/redo, CRDT, desktop, and CLI paths as content created directly in Inkfinite. The [native path geometry guide](apps/web/src/content/docs/internals/native-path-geometry.md) -documents the durable representation, fill rules, validation, and the boundaries -that later geometry and editing work builds on. +documents the native representation, fill rules, validation, exact bounds, +rendering, hit testing, and fixture coverage used by later import and editing work. ### Vector editing diff --git a/TODO.md b/TODO.md index 17552a9..2c34327 100644 --- a/TODO.md +++ b/TODO.md @@ -23,15 +23,15 @@ Completed work is in [CHANGELOG.md](CHANGELOG.md). #### Geometry, rendering, and fixtures -- [ ] Implement path bounds -- [ ] Include quadratic and cubic extrema in bounds -- [ ] Implement Canvas path rendering -- [ ] Implement path fill hit testing -- [ ] Implement path stroke hit testing -- [ ] Implement deterministic SVG path rendering -- [ ] Support parent-relative path transforms -- [ ] Add shared Rust/TypeScript path fixtures -- [ ] Add invalid-path fixtures +- [x] Implement path bounds +- [x] Include quadratic and cubic extrema in bounds +- [x] Implement Canvas path rendering +- [x] Implement path fill hit testing +- [x] Implement path stroke hit testing +- [x] Implement deterministic SVG path rendering +- [x] Support parent-relative path transforms +- [x] Add shared Rust/TypeScript path fixtures +- [x] Add invalid-path fixtures ### SVG import diff --git a/apps/web/src/content/docs/internals/native-path-geometry.md b/apps/web/src/content/docs/internals/native-path-geometry.md index de11842..fff79e4 100644 --- a/apps/web/src/content/docs/internals/native-path-geometry.md +++ b/apps/web/src/content/docs/internals/native-path-geometry.md @@ -71,5 +71,18 @@ The binding generator exports `PathFillRule`, `PathSegment`, `PathSubpath`, and `@inkfinite/bindings`. Its registry also exposes `validatePathGeometry` and applies the same structural checks to serialized values. -Path bounds, Bézier extrema, Canvas and SVG rendering, hit testing, SVG parsing, and direct path -editing will consume this representation. +## Geometry and rendering + +Path bounds use segment endpoints and the interior extrema of quadratic and cubic Bézier +curves. A closed subpath contributes its implicit closing line. Shape transforms compose +from the path to its parent and through the containing hierarchy. + +The Canvas renderer draws move, line, quadratic, and cubic segments and applies the stored +compound fill rule. Fill hit testing uses the same rule. Stroke hit testing follows the +flattened curve segments with the stored stroke width and selection tolerance. Headless SVG +output serializes normalized commands with fixed numeric formatting, fill rules, painting +properties, and composed transforms. + +Rust and TypeScript consume shared valid and invalid path fixtures. The fixtures cover curve +bounds, compound geometry, nested transforms, and validation errors for malformed paths. SVG +parsing and direct path editing build on these services. diff --git a/crates/inkfinite-cli/src/bin/generate-bindings.rs b/crates/inkfinite-cli/src/bin/generate-bindings.rs index b68453a..5e15f48 100644 --- a/crates/inkfinite-cli/src/bin/generate-bindings.rs +++ b/crates/inkfinite-cli/src/bin/generate-bindings.rs @@ -5,20 +5,8 @@ use std::error::Error; use std::fs; use std::path::{Path, PathBuf}; -use inkfinite_core::proto::{ - AffectedRegion, AgentAccessMode, AssetPatch, Bounds, CameraState, CommitResult, DocumentPatch, DocumentPath, - InverseMetadata, LayerContentsDisposition, LayerPatch, LayoutAxis, Operation, Proposal, ProposalId, - ProposalOperationPreview, ProtocolError, Query, QueryRecord, QueryResult, RecordId, Request, Response, SaveResult, - SessionId, ShapeAlignment, ShapePatch, TransactionDraft, TransactionId, Warning, -}; -use inkfinite_core::{ - ActorId, AssetId, AssetRecord, AssetSource, BindingAnchor, BindingId, BindingKind, BindingRecord, ChangeHash, - ContainerLayout, Document, DocumentId, DocumentSnapshot, FormatId, GEOMETRY_BOUNDS, GEOMETRY_COORDINATE_SYSTEM, - GEOMETRY_ROTATION, Insets, LayerId, LayerRecord, LayoutAlignment, Opacity, Origin, PageId, PageRecord, - PathFillRule, PathGeometry, PathSegment, PathSubpath, Provenance, RecordVersion, SemanticMetadata, ShapeId, - ShapeKind, ShapeParent, ShapeRecord, ShapeStyle, SiblingAnchor, StackDirection, Timestamp, Transform, Vec2, - builtin_shape_kinds, validate_shape_properties, -}; +use inkfinite_core::proto::*; +use inkfinite_core::*; use schemars::JsonSchema; use serde_json::{Value, json}; use ts_rs::{Config, TS}; @@ -231,7 +219,7 @@ fn registry_bindings() -> String { .collect::>() .join(", "); format!( - "{GENERATED_TS_HEADER}import type {{ JsonValue, PathGeometry, Transform }} from \"./model.js\";\nimport type {{ Bounds }} from \"./transaction.js\";\n\nexport const BUILTIN_SHAPE_KINDS = [{kinds}] as const;\nexport type BuiltinShapeKind = typeof BUILTIN_SHAPE_KINDS[number];\n\nexport const GEOMETRY_CONVENTION = {{\n coordinateSystem: \"{GEOMETRY_COORDINATE_SYSTEM}\",\n rotation: \"{GEOMETRY_ROTATION}\",\n bounds: \"{GEOMETRY_BOUNDS}\",\n}} as const;\n\nexport const SHAPE_REGISTRY = BUILTIN_SHAPE_KINDS.map((kind) => ({{\n kind,\n dimensions: [\"width\", \"height\"] as const,\n allowsChildren: kind === \"container\",\n}}));\n\nfunction isRecord(value: unknown): value is Record {{\n return typeof value === \"object\" && value !== null;\n}}\n\nfunction isFinitePoint(value: unknown): boolean {{\n if (!isRecord(value)) return false;\n return typeof value.x === \"number\" && Number.isFinite(value.x)\n && typeof value.y === \"number\" && Number.isFinite(value.y);\n}}\n\nfunction isPathSegment(value: unknown): boolean {{\n if (!isRecord(value) || typeof value.type !== \"string\") return false;\n switch (value.type) {{\n case \"move\":\n case \"line\":\n return isFinitePoint(value.to);\n case \"quadratic\":\n return isFinitePoint(value.control) && isFinitePoint(value.to);\n case \"cubic\":\n return isFinitePoint(value.control_1) && isFinitePoint(value.control_2) && isFinitePoint(value.to);\n default:\n return false;\n }}\n}}\n\nexport function validatePathGeometry(value: unknown): value is PathGeometry {{\n if (!isRecord(value) || !Array.isArray(value.subpaths) || value.subpaths.length === 0) return false;\n if (value.fill_rule !== \"nonzero\" && value.fill_rule !== \"evenodd\") return false;\n return value.subpaths.every((subpath) => {{\n if (!isRecord(subpath) || typeof subpath.closed !== \"boolean\" || !Array.isArray(subpath.segments) || subpath.segments.length === 0) return false;\n const first = subpath.segments[0];\n if (!isRecord(first) || first.type !== \"move\") return false;\n return subpath.segments.every((segment, index) => (index === 0 || (isRecord(segment) && segment.type !== \"move\")) && isPathSegment(segment));\n }});\n}}\n\nfunction numericProperty(properties: Record, name: string): number {{\n const value = properties[name];\n return typeof value === \"number\" && Number.isFinite(value) ? value : 0;\n}}\n\nexport function validateShapeProperties(kind: string, properties: Record): boolean {{\n if (!(BUILTIN_SHAPE_KINDS as readonly string[]).includes(kind)) return false;\n if (kind === \"path\" && !validatePathGeometry({{ subpaths: properties.subpaths, fill_rule: properties.fill_rule }})) return false;\n return [\"width\", \"height\"].every((name) => {{\n const value = properties[name];\n return value === undefined || (typeof value === \"number\" && Number.isFinite(value) && value >= 0);\n }});\n}}\n\nexport type RegistryShape = {{\n kind: string;\n properties: Record;\n transform: Transform;\n}};\n\nexport function boundsForShape(shape: RegistryShape): Bounds {{\n const width = Math.abs(numericProperty(shape.properties, \"width\"));\n const height = Math.abs(numericProperty(shape.properties, \"height\"));\n const {{ translation, rotation, scale_x: scaleX, scale_y: scaleY }} = shape.transform;\n const cos = Math.cos(rotation);\n const sin = Math.sin(rotation);\n const points = [[0, 0], [width, 0], [0, height], [width, height]].map(([x, y]) => [\n translation.x + x * scaleX * cos - y * scaleY * sin,\n translation.y + x * scaleX * sin + y * scaleY * cos,\n ]);\n const xs = points.map(([x]) => x);\n const ys = points.map(([, y]) => y);\n const minX = Math.min(...xs);\n const maxX = Math.max(...xs);\n const minY = Math.min(...ys);\n const maxY = Math.max(...ys);\n return {{ x: minX, y: minY, width: maxX - minX, height: maxY - minY }};\n}}\n" + "{GENERATED_TS_HEADER}import type {{ JsonValue, PathGeometry, Transform }} from \"./model.js\";\nimport type {{ Bounds }} from \"./transaction.js\";\n\nexport const BUILTIN_SHAPE_KINDS = [{kinds}] as const;\nexport type BuiltinShapeKind = typeof BUILTIN_SHAPE_KINDS[number];\n\nexport const GEOMETRY_CONVENTION = {{\n coordinateSystem: \"{GEOMETRY_COORDINATE_SYSTEM}\",\n rotation: \"{GEOMETRY_ROTATION}\",\n bounds: \"{GEOMETRY_BOUNDS}\",\n}} as const;\n\nexport const SHAPE_REGISTRY = BUILTIN_SHAPE_KINDS.map((kind) => ({{\n kind,\n dimensions: [\"width\", \"height\"] as const,\n allowsChildren: kind === \"container\",\n}}));\n\nfunction isRecord(value: unknown): value is Record {{\n return typeof value === \"object\" && value !== null;\n}}\n\nfunction isFinitePoint(value: unknown): boolean {{\n if (!isRecord(value)) return false;\n return typeof value.x === \"number\" && Number.isFinite(value.x)\n && typeof value.y === \"number\" && Number.isFinite(value.y);\n}}\n\nfunction isPathSegment(value: unknown): boolean {{\n if (!isRecord(value) || typeof value.type !== \"string\") return false;\n switch (value.type) {{\n case \"move\":\n case \"line\":\n return isFinitePoint(value.to);\n case \"quadratic\":\n return isFinitePoint(value.control) && isFinitePoint(value.to);\n case \"cubic\":\n return isFinitePoint(value.control_1) && isFinitePoint(value.control_2) && isFinitePoint(value.to);\n default:\n return false;\n }}\n}}\n\nexport function validatePathGeometry(value: unknown): value is PathGeometry {{\n if (!isRecord(value) || !Array.isArray(value.subpaths) || value.subpaths.length === 0) return false;\n if (value.fill_rule !== \"nonzero\" && value.fill_rule !== \"evenodd\") return false;\n return value.subpaths.every((subpath) => {{\n if (!isRecord(subpath) || typeof subpath.closed !== \"boolean\" || !Array.isArray(subpath.segments) || subpath.segments.length === 0) return false;\n const first = subpath.segments[0];\n if (!isRecord(first) || first.type !== \"move\") return false;\n return subpath.segments.every((segment, index) => (index === 0 || (isRecord(segment) && segment.type !== \"move\")) && isPathSegment(segment));\n }});\n}}\n\nfunction numericProperty(properties: Record, name: string): number {{\n const value = properties[name];\n return typeof value === \"number\" && Number.isFinite(value) ? value : 0;\n}}\n\ntype PathPoint = {{ x: number; y: number }};\n\nfunction quadraticPoint(start: PathPoint, control: PathPoint, end: PathPoint, t: number): PathPoint {{\n const inverse = 1 - t;\n return {{\n x: inverse * inverse * start.x + 2 * inverse * t * control.x + t * t * end.x,\n y: inverse * inverse * start.y + 2 * inverse * t * control.y + t * t * end.y,\n }};\n}}\n\nfunction cubicPoint(start: PathPoint, control1: PathPoint, control2: PathPoint, end: PathPoint, t: number): PathPoint {{\n const inverse = 1 - t;\n return {{\n x: inverse ** 3 * start.x + 3 * inverse ** 2 * t * control1.x + 3 * inverse * t ** 2 * control2.x + t ** 3 * end.x,\n y: inverse ** 3 * start.y + 3 * inverse ** 2 * t * control1.y + 3 * inverse * t ** 2 * control2.y + t ** 3 * end.y,\n }};\n}}\n\nfunction quadraticRoots(a: number, b: number, c: number): number[] {{\n if (Math.abs(a) <= Number.EPSILON) return Math.abs(b) > Number.EPSILON ? [-c / b] : [];\n const discriminant = b * b - 4 * a * c;\n if (discriminant < 0) return [];\n const root = Math.sqrt(discriminant);\n return [(-b - root) / (2 * a), (-b + root) / (2 * a)];\n}}\n\n/** Returns exact local bounds, including Bézier derivative extrema. */\nexport function pathBounds(geometry: PathGeometry): Bounds {{\n const points: PathPoint[] = [];\n for (const subpath of geometry.subpaths) {{\n const first = subpath.segments[0];\n if (!first || first.type !== 'move') continue;\n const start = first.to;\n let current = start;\n points.push(current);\n for (const segment of subpath.segments.slice(1)) {{\n if (segment.type === 'move') {{\n current = segment.to;\n points.push(current);\n }} else if (segment.type === 'line') {{\n points.push(current, segment.to);\n current = segment.to;\n }} else if (segment.type === 'quadratic') {{\n points.push(current, segment.to);\n for (const value of [\n (current.x - segment.control.x) / (current.x - 2 * segment.control.x + segment.to.x),\n (current.y - segment.control.y) / (current.y - 2 * segment.control.y + segment.to.y),\n ]) {{\n if (Number.isFinite(value) && value > 0 && value < 1) points.push(quadraticPoint(current, segment.control, segment.to, value));\n }}\n current = segment.to;\n }} else {{\n points.push(current, segment.to);\n for (const [startValue, control1, control2, endValue] of [\n [current.x, segment.control_1.x, segment.control_2.x, segment.to.x],\n [current.y, segment.control_1.y, segment.control_2.y, segment.to.y],\n ]) {{\n const a = -startValue + 3 * control1 - 3 * control2 + endValue;\n const b = 2 * (startValue - 2 * control1 + control2);\n const c = control1 - startValue;\n for (const value of quadraticRoots(a, b, c)) {{\n if (value > 0 && value < 1) points.push(cubicPoint(current, segment.control_1, segment.control_2, segment.to, value));\n }}\n }}\n current = segment.to;\n }}\n }}\n if (subpath.closed) points.push(current, start);\n }}\n if (points.length === 0) return {{ x: 0, y: 0, width: 0, height: 0 }};\n const xs = points.map((point) => point.x);\n const ys = points.map((point) => point.y);\n const minX = Math.min(...xs);\n const maxX = Math.max(...xs);\n const minY = Math.min(...ys);\n const maxY = Math.max(...ys);\n return {{ x: minX, y: minY, width: maxX - minX, height: maxY - minY }};\n}}\n\nexport function validateShapeProperties(kind: string, properties: Record): boolean {{\n if (!(BUILTIN_SHAPE_KINDS as readonly string[]).includes(kind)) return false;\n if (kind === \"path\" && !validatePathGeometry({{ subpaths: properties.subpaths, fill_rule: properties.fill_rule }})) return false;\n return [\"width\", \"height\"].every((name) => {{\n const value = properties[name];\n return value === undefined || (typeof value === \"number\" && Number.isFinite(value) && value >= 0);\n }});\n}}\n\nexport type RegistryShape = {{\n kind: string;\n properties: Record;\n transform: Transform;\n}};\n\nexport function boundsForShape(shape: RegistryShape): Bounds {{\n const pathValue: PathGeometry = {{\n subpaths: shape.properties.subpaths as PathGeometry['subpaths'],\n fill_rule: shape.properties.fill_rule as PathGeometry['fill_rule'],\n }};\n const local = shape.kind === 'path' && validatePathGeometry(pathValue)\n ? pathBounds(pathValue)\n : {{ x: 0, y: 0, width: Math.abs(numericProperty(shape.properties, \"width\")), height: Math.abs(numericProperty(shape.properties, \"height\")) }};\n const {{ translation, rotation, scale_x: scaleX, scale_y: scaleY }} = shape.transform;\n const cos = Math.cos(rotation);\n const sin = Math.sin(rotation);\n const points = [[local.x, local.y], [local.x + local.width, local.y], [local.x, local.y + local.height], [local.x + local.width, local.y + local.height]].map(([x, y]) => [\n translation.x + x * scaleX * cos - y * scaleY * sin,\n translation.y + x * scaleX * sin + y * scaleY * cos,\n ]);\n const xs = points.map(([x]) => x);\n const ys = points.map(([, y]) => y);\n const minX = Math.min(...xs);\n const maxX = Math.max(...xs);\n const minY = Math.min(...ys);\n const maxY = Math.max(...ys);\n return {{ x: minX, y: minY, width: maxX - minX, height: maxY - minY }};\n}}\n" ) } @@ -288,12 +276,50 @@ fn fixture_json() -> Result> { fill_rule: PathFillRule::EvenOdd, }; let path_properties = json!({ - "subpaths": path_geometry.subpaths, + "subpaths": &path_geometry.subpaths, "fill_rule": path_geometry.fill_rule, }); + let path_transform = Transform { translation: Vec2 { x: -5.0, y: 7.0 }, rotation: 0.3, scale_x: 1.2, scale_y: 0.8 }; + let path_local_bounds = inkfinite_core::engine::geometry::path_bounds(&path_geometry); + let path_expected_bounds = + inkfinite_core::engine::geometry::Affine::from_transform(path_transform).transform_bounds(path_local_bounds); + let invalid_path_cases = [ + ("empty", json!({ "subpaths": [], "fill_rule": "nonzero" })), + ( + "empty_subpath", + json!({ "subpaths": [{ "segments": [], "closed": false }], "fill_rule": "nonzero" }), + ), + ( + "missing_move", + json!({ + "subpaths": [{ "segments": [{ "type": "line", "to": { "x": 1.0, "y": 2.0 } }], "closed": false }], + "fill_rule": "nonzero" + }), + ), + ( + "move_not_first", + json!({ + "subpaths": [{ "segments": [ + { "type": "move", "to": { "x": 0.0, "y": 0.0 } }, + { "type": "move", "to": { "x": 1.0, "y": 1.0 } } + ], "closed": false }], + "fill_rule": "nonzero" + }), + ), + ( + "nonfinite_coordinate", + json!({ + "subpaths": [{ "segments": [{ "type": "move", "to": { "x": null, "y": 0.0 } }], "closed": false }], + "fill_rule": "nonzero" + }), + ), + ] + .into_iter() + .map(|(name, geometry)| json!({ "name": name, "geometry": geometry, "valid": false })) + .collect::>(); let property_cases = [ (RECTANGLE_KIND, json!({"width": 40.0, "height": 20.0})), - (inkfinite_core::PATH_KIND, path_properties), + (inkfinite_core::PATH_KIND, path_properties.clone()), (RECTANGLE_KIND, json!({"width": -1.0, "height": 20.0})), (RECTANGLE_KIND, json!({"width": "40", "height": 20.0})), ("unknown", json!({})), @@ -320,14 +346,23 @@ fn fixture_json() -> Result> { "rotation": GEOMETRY_ROTATION, "bounds": GEOMETRY_BOUNDS, }, - "path_geometry": serde_json::to_value(path_geometry)?, + "path_geometry": serde_json::to_value(&path_geometry)?, + "invalid_path_cases": invalid_path_cases, "property_cases": property_cases, - "geometry_cases": [{ - "kind": shape.kind, - "properties": shape.properties, - "transform": shape.transform, - "expected_bounds": expected_bounds, - }], + "geometry_cases": [ + { + "kind": shape.kind, + "properties": shape.properties, + "transform": shape.transform, + "expected_bounds": expected_bounds, + }, + { + "kind": inkfinite_core::PATH_KIND, + "properties": path_properties, + "transform": path_transform, + "expected_bounds": path_expected_bounds, + } + ], "serialization": { "shape": serde_json::to_value(shape)?, "transaction": serde_json::to_value(transaction)?, diff --git a/crates/inkfinite-core/src/engine/geometry.rs b/crates/inkfinite-core/src/engine/geometry.rs index 2088169..d7553b5 100644 --- a/crates/inkfinite-core/src/engine/geometry.rs +++ b/crates/inkfinite-core/src/engine/geometry.rs @@ -1,5 +1,5 @@ use super::{Bounds, Document, EngineError, ShapeId, ShapeParent, ShapeRecord}; -use crate::{Transform, Vec2}; +use crate::{PathGeometry, PathSegment, Transform, Vec2}; /// A two-dimensional affine transform shared by document geometry consumers. #[derive(Clone, Copy, Debug, PartialEq)] @@ -88,9 +88,17 @@ impl Affine { /// Returns a shape's axis-aligned bounds in its parent's coordinate space. #[must_use] pub fn local_shape_bounds(shape: &ShapeRecord) -> Bounds { - let width = numeric_property(shape, "width").unwrap_or(0.0).abs(); - let height = numeric_property(shape, "height").unwrap_or(0.0).abs(); - Affine::from_transform(shape.transform).transform_bounds(Bounds { x: 0.0, y: 0.0, width, height }) + let local = if shape.kind.as_str() == crate::PATH_KIND { + crate::path_geometry_from_properties(&shape.properties) + .map_or(Bounds { x: 0.0, y: 0.0, width: 0.0, height: 0.0 }, |geometry| { + path_bounds(&geometry) + }) + } else { + let width = numeric_property(shape, "width").unwrap_or(0.0).abs(); + let height = numeric_property(shape, "height").unwrap_or(0.0).abs(); + Bounds { x: 0.0, y: 0.0, width, height } + }; + Affine::from_transform(shape.transform).transform_bounds(local) } /// Returns a shape's axis-aligned bounds in document coordinates. @@ -99,9 +107,62 @@ pub fn world_shape_bounds(document: &Document, shape_id: &ShapeId) -> Bounds { let Some(shape) = document.shapes.get(shape_id) else { return Bounds { x: 0.0, y: 0.0, width: 0.0, height: 0.0 }; }; - let width = numeric_property(shape, "width").unwrap_or(0.0).abs(); - let height = numeric_property(shape, "height").unwrap_or(0.0).abs(); - world_transform(document, shape).transform_bounds(Bounds { x: 0.0, y: 0.0, width, height }) + let parent_transform = match &shape.parent { + ShapeParent::Layer(_) => Affine::IDENTITY, + ShapeParent::Shape(parent_id) => document + .shapes + .get(parent_id) + .map(|parent| world_transform(document, parent)) + .unwrap_or(Affine::IDENTITY), + }; + parent_transform.transform_bounds(local_shape_bounds(shape)) +} + +/// Returns the exact axis-aligned bounds of normalized path geometry. +/// +/// Quadratic and cubic Bézier derivative roots are included, so the bounds do +/// not depend on a sampling step. A closed subpath contributes its implicit +/// closing line; an open subpath contributes only its stored segments. +#[must_use] +pub fn path_bounds(geometry: &PathGeometry) -> Bounds { + let mut points = Vec::new(); + for subpath in &geometry.subpaths { + let Some(PathSegment::Move { to: start }) = subpath.segments.first() else { + continue; + }; + let mut current = *start; + points.push(current); + for segment in subpath.segments.iter().skip(1) { + match segment { + PathSegment::Move { to } => { + current = *to; + points.push(current); + } + PathSegment::Line { to } => { + points.extend([current, *to]); + current = *to; + } + PathSegment::Quadratic { control, to } => { + points.extend([current, *to]); + add_quadratic_extremum(&mut points, current, *control, *to, |t| { + quadratic_point(current, *control, *to, t) + }); + current = *to; + } + PathSegment::Cubic { control_1, control_2, to } => { + points.extend([current, *to]); + add_cubic_extrema(&mut points, current, *control_1, *control_2, *to, |t| { + cubic_point(current, *control_1, *control_2, *to, t) + }); + current = *to; + } + } + } + if subpath.closed { + points.extend([current, *start]); + } + } + bounds_from_points(&points) } /// Returns the complete local-to-world transform for a shape hierarchy. @@ -201,6 +262,87 @@ pub fn union(left: Bounds, right_bounds: Bounds) -> Bounds { } } +fn add_quadratic_extremum(points: &mut Vec, start: Vec2, control: Vec2, end: Vec2, evaluate: F) +where + F: Fn(f64) -> Vec2, +{ + for t in [ + quadratic_extremum(start.x, control.x, end.x), + quadratic_extremum(start.y, control.y, end.y), + ] + .into_iter() + .flatten() + .filter(|t| *t > 0.0 && *t < 1.0) + { + points.push(evaluate(t)); + } +} + +fn quadratic_extremum(start: f64, control: f64, end: f64) -> Option { + let denominator = start - 2.0 * control + end; + if denominator.abs() <= f64::EPSILON { None } else { Some((start - control) / denominator) } +} + +fn add_cubic_extrema(points: &mut Vec, start: Vec2, control_1: Vec2, control_2: Vec2, end: Vec2, evaluate: F) +where + F: Fn(f64) -> Vec2, +{ + for (a, b, c) in [ + cubic_derivative_coefficients(start.x, control_1.x, control_2.x, end.x), + cubic_derivative_coefficients(start.y, control_1.y, control_2.y, end.y), + ] { + for t in quadratic_roots(a, b, c) + .into_iter() + .flatten() + .filter(|t| *t > 0.0 && *t < 1.0) + { + points.push(evaluate(t)); + } + } +} + +fn cubic_derivative_coefficients(start: f64, control_1: f64, control_2: f64, end: f64) -> (f64, f64, f64) { + ( + -start + 3.0 * control_1 - 3.0 * control_2 + end, + 2.0 * (start - 2.0 * control_1 + control_2), + control_1 - start, + ) +} + +fn quadratic_roots(a: f64, b: f64, c: f64) -> [Option; 2] { + if a.abs() <= f64::EPSILON { + return [if b.abs() > f64::EPSILON { Some(-c / b) } else { None }, None]; + } + let discriminant = b * b - 4.0 * a * c; + if discriminant < 0.0 { + return [None, None]; + } + let root = discriminant.sqrt(); + [Some((-b - root) / (2.0 * a)), Some((-b + root) / (2.0 * a))] +} + +fn quadratic_point(start: Vec2, control: Vec2, end: Vec2, t: f64) -> Vec2 { + let inverse = 1.0 - t; + Vec2 { + x: inverse * inverse * start.x + 2.0 * inverse * t * control.x + t * t * end.x, + y: inverse * inverse * start.y + 2.0 * inverse * t * control.y + t * t * end.y, + } +} + +fn cubic_point(start: Vec2, control_1: Vec2, control_2: Vec2, end: Vec2, t: f64) -> Vec2 { + let inverse = 1.0 - t; + Vec2 { + x: inverse.powi(3) * start.x + + 3.0 * inverse.powi(2) * t * control_1.x + + 3.0 * inverse * t.powi(2) * control_2.x + + t.powi(3) * end.x, + y: inverse.powi(3) * start.y + + 3.0 * inverse.powi(2) * t * control_1.y + + 3.0 * inverse * t.powi(2) * control_2.y + + t.powi(3) * end.y, + } +} + #[cfg(test)] mod tests { use super::*; @@ -212,6 +354,50 @@ mod tests { assert_eq!(union(left, right), Bounds { x: 0.0, y: 0.0, width: 8.0, height: 5.0 }); } + #[test] + fn path_bounds_include_quadratic_and_cubic_extrema() { + let geometry = PathGeometry { + subpaths: vec![crate::PathSubpath { + segments: vec![ + PathSegment::Move { to: Vec2 { x: 0.0, y: 0.0 } }, + PathSegment::Quadratic { control: Vec2 { x: 10.0, y: 20.0 }, to: Vec2 { x: 20.0, y: 0.0 } }, + PathSegment::Cubic { + control_1: Vec2 { x: 30.0, y: -20.0 }, + control_2: Vec2 { x: 40.0, y: 20.0 }, + to: Vec2 { x: 50.0, y: 0.0 }, + }, + ], + closed: false, + }], + fill_rule: crate::PathFillRule::NonZero, + }; + let bounds = path_bounds(&geometry); + + assert!((bounds.x - 0.0).abs() < 1e-12); + assert!((bounds.y + 5.773502691896258).abs() < 1e-12); + assert!((bounds.width - 50.0).abs() < 1e-12); + assert!((bounds.height - 15.773502691896258).abs() < 1e-12); + } + + #[test] + fn closed_path_bounds_include_the_implicit_closing_line() { + let geometry = PathGeometry { + subpaths: vec![crate::PathSubpath { + segments: vec![ + PathSegment::Move { to: Vec2 { x: 10.0, y: 20.0 } }, + PathSegment::Line { to: Vec2 { x: 30.0, y: 20.0 } }, + ], + closed: true, + }], + fill_rule: crate::PathFillRule::EvenOdd, + }; + + assert_eq!( + path_bounds(&geometry), + Bounds { x: 10.0, y: 20.0, width: 20.0, height: 0.0 } + ); + } + #[test] fn affine_composition_and_inverse_round_trip_a_child_point() { let parent = Affine::from_transform(crate::Transform { diff --git a/crates/inkfinite-core/src/render/mod.rs b/crates/inkfinite-core/src/render/mod.rs index 4fc15d2..d1b7a09 100644 --- a/crates/inkfinite-core/src/render/mod.rs +++ b/crates/inkfinite-core/src/render/mod.rs @@ -11,8 +11,8 @@ use thiserror::Error; use crate::engine::geometry::{Affine, bounds_from_points, intersects, union, world_transform}; use crate::proto::Bounds; use crate::{ - AssetId, AssetSource, BindingAnchor, BuiltinShapeKind, Document, DocumentSnapshot, LayerId, PageId, ShapeId, - ShapeRecord, Vec2, + AssetId, AssetSource, BindingAnchor, BuiltinShapeKind, Document, DocumentSnapshot, LayerId, PageId, PathFillRule, + PathGeometry, PathSegment, PathSubpath, ShapeId, ShapeRecord, Vec2, }; const DEFAULT_PADDING: f64 = 20.0; @@ -116,126 +116,6 @@ pub enum SvgRenderError { }, } -/// Renders a materialized snapshot as deterministic SVG. -/// -/// Layer and shape order come exclusively from the page, layer, and container -/// child lists. Hidden layers are omitted; locked layers remain visible, as in -/// the interactive renderer. The function performs no filesystem or font-system -/// access, so equal snapshots and options produce byte-for-byte equal output. -/// -/// # Errors -/// -/// Returns [`SvgRenderError`] for an unknown page, invalid region, or malformed -/// built-in shape properties. -pub fn render_svg(snapshot: &DocumentSnapshot, options: &SvgRenderOptions) -> Result { - validate_region(options.region)?; - let Some(page_id) = options.page_id.as_ref().or_else(|| snapshot.document.page_ids.first()) else { - return Ok(empty_svg()); - }; - let page = snapshot - .document - .pages - .get(page_id) - .ok_or_else(|| SvgRenderError::PageNotFound { page_id: page_id.clone() })?; - - let mut renderer = Renderer { - document: &snapshot.document, - options, - warnings: BTreeSet::new(), - font_faces: BTreeMap::new(), - rendered_bounds: None, - body: String::new(), - }; - - for layer_id in &page.layer_ids { - if !options.layer_ids.is_empty() && !options.layer_ids.contains(layer_id) { - continue; - } - let Some(layer) = snapshot.document.layers.get(layer_id) else { - continue; - }; - if !layer.visible { - continue; - } - let mut layer_body = String::new(); - for shape_id in &layer.shape_ids { - renderer.render_shape(shape_id, Affine::IDENTITY, false, &mut layer_body)?; - } - if !layer_body.is_empty() { - writeln!( - renderer.body, - " ", - escape_xml(layer.id.as_str()), - number(f64::from(layer.opacity.get())) - ) - .expect("writing to a String cannot fail"); - renderer.body.push_str(&layer_body); - renderer.body.push_str(" \n"); - } - } - - let view_box = options.region.or(renderer.rendered_bounds).unwrap_or(Bounds { - x: 0.0, - y: 0.0, - width: EMPTY_SIZE, - height: EMPTY_SIZE, - }); - let view_box = if options.region.is_some() { view_box } else { padded(view_box, DEFAULT_PADDING) }; - let width = view_box.width.max(1.0); - let height = view_box.height.max(1.0); - let clip = options.region.map(|region| { - format!( - " \n", - number(region.x), number(region.y), number(region.width), number(region.height) - ) - }); - - let mut svg = format!( - "\n", - number(view_box.x), - number(view_box.y), - number(width), - number(height), - number(width), - number(height) - ); - if !renderer.font_faces.is_empty() { - svg.push_str(" \n"); - } - if let Some(clip) = clip { - svg.push_str(&clip); - svg.push_str(" \n"); - svg.push_str(&indent(&renderer.body, 2)); - svg.push_str(" \n"); - } else { - svg.push_str(&renderer.body); - } - svg.push_str("\n"); - - Ok(SvgRenderOutput { svg, warnings: renderer.warnings.into_iter().collect() }) -} - -fn empty_svg() -> SvgRenderOutput { - SvgRenderOutput { - svg: - "\n\n" - .into(), - warnings: Vec::new(), - } -} - struct Renderer<'a> { document: &'a Document, options: &'a SvgRenderOptions, @@ -378,7 +258,25 @@ impl Renderer<'_> { ).expect("writing to a String cannot fail"); } } - Some(BuiltinShapeKind::Path) => {} + Some(BuiltinShapeKind::Path) => { + let props: PathProps = properties(shape)?; + let geometry = PathGeometry { subpaths: props.subpaths, fill_rule: props.fill_rule }; + crate::validate_path_geometry(&geometry).map_err(|error| SvgRenderError::InvalidShapeProperties { + shape_id: shape.id.clone(), + kind: shape.kind.to_string(), + message: error.to_string(), + })?; + writeln!( + output, + " ", + path_data(&geometry), + paint(props.fill.as_deref()), + path_fill_rule(props.fill_rule), + paint(props.stroke.as_deref()), + number(props.stroke_width.unwrap_or(2.0).max(0.0)), + ) + .expect("writing to a String cannot fail"); + } None => {} } Ok(output) @@ -663,17 +561,31 @@ struct MarkdownProps { border: Option, } +#[derive(Deserialize)] +struct PathProps { + subpaths: Vec, + fill_rule: PathFillRule, + #[serde(default)] + fill: Option, + #[serde(default)] + stroke: Option, + #[serde(default, alias = "strokeWidth")] + stroke_width: Option, +} + #[derive(Deserialize)] struct StrokeProps { points: Vec>, style: StrokeStyle, brush: Brush, } + #[derive(Deserialize)] struct StrokeStyle { color: String, opacity: f64, } + #[derive(Deserialize)] struct Brush { size: f64, @@ -684,6 +596,133 @@ struct Brush { simulate_pressure: bool, } +struct MarkdownLine { + text: String, + font_size: f64, + bold: bool, + code: bool, +} + +/// Renders a materialized snapshot as deterministic SVG. +/// +/// Layer and shape order come exclusively from the page, layer, and container +/// child lists. Hidden layers are omitted; locked layers remain visible, as in +/// the interactive renderer. The function performs no filesystem or font-system +/// access, so equal snapshots and options produce byte-for-byte equal output. +/// +/// # Errors +/// +/// Returns [`SvgRenderError`] for an unknown page, invalid region, or malformed +/// built-in shape properties. +pub fn render_svg(snapshot: &DocumentSnapshot, options: &SvgRenderOptions) -> Result { + validate_region(options.region)?; + let Some(page_id) = options.page_id.as_ref().or_else(|| snapshot.document.page_ids.first()) else { + return Ok(empty_svg()); + }; + let page = snapshot + .document + .pages + .get(page_id) + .ok_or_else(|| SvgRenderError::PageNotFound { page_id: page_id.clone() })?; + + let mut renderer = Renderer { + document: &snapshot.document, + options, + warnings: BTreeSet::new(), + font_faces: BTreeMap::new(), + rendered_bounds: None, + body: String::new(), + }; + + for layer_id in &page.layer_ids { + if !options.layer_ids.is_empty() && !options.layer_ids.contains(layer_id) { + continue; + } + let Some(layer) = snapshot.document.layers.get(layer_id) else { + continue; + }; + if !layer.visible { + continue; + } + let mut layer_body = String::new(); + for shape_id in &layer.shape_ids { + renderer.render_shape(shape_id, Affine::IDENTITY, false, &mut layer_body)?; + } + if !layer_body.is_empty() { + writeln!( + renderer.body, + " ", + escape_xml(layer.id.as_str()), + number(f64::from(layer.opacity.get())) + ) + .expect("writing to a String cannot fail"); + renderer.body.push_str(&layer_body); + renderer.body.push_str(" \n"); + } + } + + let view_box = options.region.or(renderer.rendered_bounds).unwrap_or(Bounds { + x: 0.0, + y: 0.0, + width: EMPTY_SIZE, + height: EMPTY_SIZE, + }); + let view_box = if options.region.is_some() { view_box } else { padded(view_box, DEFAULT_PADDING) }; + let width = view_box.width.max(1.0); + let height = view_box.height.max(1.0); + let clip = options.region.map(|region| { + format!( + " \n", + number(region.x), number(region.y), number(region.width), number(region.height) + ) + }); + + let mut svg = format!( + "\n", + number(view_box.x), + number(view_box.y), + number(width), + number(height), + number(width), + number(height) + ); + if !renderer.font_faces.is_empty() { + svg.push_str(" \n"); + } + if let Some(clip) = clip { + svg.push_str(&clip); + svg.push_str(" \n"); + svg.push_str(&indent(&renderer.body, 2)); + svg.push_str(" \n"); + } else { + svg.push_str(&renderer.body); + } + svg.push_str("\n"); + + Ok(SvgRenderOutput { svg, warnings: renderer.warnings.into_iter().collect() }) +} + +fn empty_svg() -> SvgRenderOutput { + SvgRenderOutput { + svg: + "\n\n" + .into(), + warnings: Vec::new(), + } +} + fn properties Deserialize<'de>>(shape: &ShapeRecord) -> Result { serde_json::from_value(Value::Object(shape.properties.clone().into_iter().collect())).map_err(|error| { SvgRenderError::InvalidShapeProperties { @@ -740,13 +779,55 @@ fn shape_local_bounds(shape: &ShapeRecord) -> Result { kind: shape.kind.to_string(), message: error.to_string(), })?; - Bounds { x: 0.0, y: 0.0, width: 0.0, height: 0.0 } + crate::engine::geometry::path_bounds(&geometry) } None => Bounds { x: 0.0, y: 0.0, width: 0.0, height: 0.0 }, }; Ok(bounds) } +fn path_data(geometry: &PathGeometry) -> String { + let mut output = String::new(); + for subpath in &geometry.subpaths { + for segment in &subpath.segments { + match segment { + PathSegment::Move { to } => write!(output, "M {} {} ", number(to.x), number(to.y)), + PathSegment::Line { to } => write!(output, "L {} {} ", number(to.x), number(to.y)), + PathSegment::Quadratic { control, to } => write!( + output, + "Q {} {} {} {} ", + number(control.x), + number(control.y), + number(to.x), + number(to.y) + ), + PathSegment::Cubic { control_1, control_2, to } => write!( + output, + "C {} {} {} {} {} {} ", + number(control_1.x), + number(control_1.y), + number(control_2.x), + number(control_2.y), + number(to.x), + number(to.y) + ), + } + .expect("writing to a String cannot fail"); + } + if subpath.closed { + output.push_str("Z "); + } + } + output.trim_end().to_owned() +} + +fn path_fill_rule(rule: PathFillRule) -> &'static str { + match rule { + PathFillRule::NonZero => "nonzero", + PathFillRule::EvenOdd => "evenodd", + } +} + fn stroke_outline(props: &StrokeProps) -> Vec { if props.points.len() < 2 { return Vec::new(); @@ -844,13 +925,6 @@ fn point_at_distance(points: &[Vec2], target: f64) -> Vec2 { points.last().copied().unwrap_or(Vec2 { x: 0.0, y: 0.0 }) } -struct MarkdownLine { - text: String, - font_size: f64, - bold: bool, - code: bool, -} - fn markdown_lines(source: &str, base_size: f64) -> Vec { let mut result = Vec::new(); let mut code = false; diff --git a/crates/inkfinite-core/src/render/tests.rs b/crates/inkfinite-core/src/render/tests.rs index 9c52ac5..4238efc 100644 --- a/crates/inkfinite-core/src/render/tests.rs +++ b/crates/inkfinite-core/src/render/tests.rs @@ -566,6 +566,79 @@ fn render_is_deterministic_and_covers_every_visual_builtin() { ); } +#[test] +fn renders_native_path_geometry_with_curves_and_fill_rule() { + let mut snapshot = fixture_snapshot(); + let layer_id = LayerId::from("layer:page:fixtures:default"); + let path_id = ShapeId::from("shape:path"); + add_shape( + &mut snapshot.document.shapes, + shape( + "shape:path", + "path", + ShapeParent::Shape(ShapeId::from("group:path")), + 10.0, + 20.0, + 0.0, + props([ + ( + "subpaths", + serde_json::json!([{ + "segments": [ + { "type": "move", "to": { "x": 0, "y": 0 } }, + { "type": "line", "to": { "x": 40, "y": 0 } }, + { "type": "quadratic", "control": { "x": 50, "y": 10 }, "to": { "x": 40, "y": 20 } }, + { "type": "cubic", "control_1": { "x": 40, "y": 30 }, "control_2": { "x": 0, "y": 30 }, "to": { "x": 0, "y": 20 } } + ], + "closed": true + }]), + ), + ("fill_rule", serde_json::json!("evenodd")), + ("fill", serde_json::json!("#fef08a")), + ("stroke", serde_json::json!("#854d0e")), + ("stroke_width", serde_json::json!(3)), + ]), + Vec::new(), + ), + ); + add_shape( + &mut snapshot.document.shapes, + shape( + "group:path", + "container", + ShapeParent::Layer(layer_id.clone()), + 5.0, + 6.0, + 0.0, + props([("w", serde_json::json!(100)), ("h", serde_json::json!(100))]), + vec![path_id], + ), + ); + snapshot + .document + .layers + .get_mut(&layer_id) + .expect("fixture layer") + .shape_ids + .push(ShapeId::from("group:path")); + + let output = render_svg( + &snapshot, + &SvgRenderOptions { + page_id: Some(PageId::from("page:fixtures")), + selection: BTreeSet::from([ShapeId::from("shape:path")]), + region: Some(Bounds { x: 0.0, y: 0.0, width: 100.0, height: 100.0 }), + ..SvgRenderOptions::default() + }, + ) + .expect("path renders"); + + assert_eq!( + output.svg, + include_str!("../../../../fixtures/native/rendering/path.svg") + ); +} + #[test] fn filters_page_layer_selection_and_region_without_changing_order() { let mut snapshot = fixture_snapshot(); diff --git a/crates/inkfinite-core/tests/bindings.rs b/crates/inkfinite-core/tests/bindings.rs index b2b569a..200a110 100644 --- a/crates/inkfinite-core/tests/bindings.rs +++ b/crates/inkfinite-core/tests/bindings.rs @@ -28,6 +28,15 @@ fn shared_shape_fixture_matches_the_rust_registry_and_bindings() { serde_json::to_value(&path_geometry).expect("path geometry should reserialize"), fixture["path_geometry"] ); + for case in fixture["invalid_path_cases"] + .as_array() + .expect("invalid path cases should be an array") + { + let geometry = serde_json::from_value::(case["geometry"].clone()); + assert!( + geometry.is_err() || validate_path_geometry(&geometry.expect("invalid geometry should decode")).is_err() + ); + } for case in fixture["property_cases"] .as_array() @@ -56,23 +65,28 @@ fn shared_shape_fixture_matches_the_rust_registry_and_bindings() { fixture["serialization"]["transaction"] ); - let geometry_case = &fixture["geometry_cases"][0]; - let expected: Bounds = serde_json::from_value(geometry_case["expected_bounds"].clone()) - .expect("expected bounds should use the protocol binding"); - let actual = transformed_bounds( - shape - .properties - .get("width") - .and_then(Value::as_f64) - .expect("fixture width"), - shape - .properties - .get("height") - .and_then(Value::as_f64) - .expect("fixture height"), - shape.transform, - ); - assert_bounds_close(actual, expected); + for geometry_case in fixture["geometry_cases"] + .as_array() + .expect("geometry cases should be an array") + { + let expected: Bounds = serde_json::from_value(geometry_case["expected_bounds"].clone()) + .expect("expected bounds should use the protocol binding"); + let transform: inkfinite_core::Transform = + serde_json::from_value(geometry_case["transform"].clone()).expect("fixture transform"); + let actual = if geometry_case["kind"] == inkfinite_core::PATH_KIND { + let geometry: PathGeometry = + serde_json::from_value(geometry_case["properties"].clone()).expect("path properties should decode"); + inkfinite_core::engine::geometry::Affine::from_transform(transform) + .transform_bounds(inkfinite_core::engine::geometry::path_bounds(&geometry)) + } else { + transformed_bounds( + geometry_case["properties"]["width"].as_f64().expect("fixture width"), + geometry_case["properties"]["height"].as_f64().expect("fixture height"), + transform, + ) + }; + assert_bounds_close(actual, expected); + } } fn transformed_bounds(width: f64, height: f64, transform: inkfinite_core::Transform) -> Bounds { diff --git a/fixtures/native/rendering/path.svg b/fixtures/native/rendering/path.svg new file mode 100644 index 0000000..ce2ffa6 --- /dev/null +++ b/fixtures/native/rendering/path.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/fixtures/native/shape-registry.json b/fixtures/native/shape-registry.json index a884d2a..764e764 100644 --- a/fixtures/native/shape-registry.json +++ b/fixtures/native/shape-registry.json @@ -29,6 +29,167 @@ "y": 20.0 } } + }, + { + "expected_bounds": { + "height": 36.97549392047567, + "width": 58.08961495933219, + "x": -11.50144454654947, + "y": 7.0 + }, + "kind": "path", + "properties": { + "fill_rule": "evenodd", + "subpaths": [ + { + "closed": true, + "segments": [ + { + "to": { + "x": 0.0, + "y": 0.0 + }, + "type": "move" + }, + { + "to": { + "x": 40.0, + "y": 0.0 + }, + "type": "line" + }, + { + "control": { + "x": 50.0, + "y": 10.0 + }, + "to": { + "x": 40.0, + "y": 20.0 + }, + "type": "quadratic" + }, + { + "control_1": { + "x": 40.0, + "y": 30.0 + }, + "control_2": { + "x": 0.0, + "y": 30.0 + }, + "to": { + "x": 0.0, + "y": 20.0 + }, + "type": "cubic" + } + ] + } + ] + }, + "transform": { + "rotation": 0.3, + "scale_x": 1.2, + "scale_y": 0.8, + "translation": { + "x": -5.0, + "y": 7.0 + } + } + } + ], + "invalid_path_cases": [ + { + "geometry": { + "fill_rule": "nonzero", + "subpaths": [] + }, + "name": "empty", + "valid": false + }, + { + "geometry": { + "fill_rule": "nonzero", + "subpaths": [ + { + "closed": false, + "segments": [] + } + ] + }, + "name": "empty_subpath", + "valid": false + }, + { + "geometry": { + "fill_rule": "nonzero", + "subpaths": [ + { + "closed": false, + "segments": [ + { + "to": { + "x": 1.0, + "y": 2.0 + }, + "type": "line" + } + ] + } + ] + }, + "name": "missing_move", + "valid": false + }, + { + "geometry": { + "fill_rule": "nonzero", + "subpaths": [ + { + "closed": false, + "segments": [ + { + "to": { + "x": 0.0, + "y": 0.0 + }, + "type": "move" + }, + { + "to": { + "x": 1.0, + "y": 1.0 + }, + "type": "move" + } + ] + } + ] + }, + "name": "move_not_first", + "valid": false + }, + { + "geometry": { + "fill_rule": "nonzero", + "subpaths": [ + { + "closed": false, + "segments": [ + { + "to": { + "x": null, + "y": 0.0 + }, + "type": "move" + } + ] + } + ] + }, + "name": "nonfinite_coordinate", + "valid": false } ], "kind_names": [ diff --git a/packages/bindings/src/model.ts b/packages/bindings/src/model.ts index 88cd679..96468f2 100644 --- a/packages/bindings/src/model.ts +++ b/packages/bindings/src/model.ts @@ -66,7 +66,7 @@ export type BindingKind = string; export type Timestamp = number; /** - * Monotonic version of a durable record within the document history. + * Monotonic version of a record within the document history. */ export type RecordVersion = number; @@ -78,506 +78,553 @@ export type Opacity = number; /** * Anchor used to place an item in an ordered child list without numeric indexes. */ -export type SiblingAnchor = { "position": "first" } | { "position": "last" } | { "position": "before", "sibling_id": Id } | { "position": "after", "sibling_id": Id }; +export type SiblingAnchor = + | { position: 'first' } + | { position: 'last' } + | { position: 'before'; sibling_id: Id } + | { position: 'after'; sibling_id: Id }; /** * Two-dimensional point or vector in document coordinates. */ -export type Vec2 = { -/** - * Horizontal component. - */ -x: number, -/** - * Vertical component. - */ -y: number, }; +export type Vec2 = { + /** + * Horizontal component. + */ + x: number; + /** + * Vertical component. + */ + y: number; +}; /** * Fill rule used to determine the interior of a compound path. */ -export type PathFillRule = "nonzero" | "evenodd"; +export type PathFillRule = 'nonzero' | 'evenodd'; /** * One normalized drawing command in a path subpath. */ -export type PathSegment = { "type": "move", -/** - * Destination point. - */ -to: Vec2, } | { "type": "line", -/** - * Destination point. - */ -to: Vec2, } | { "type": "quadratic", -/** - * Quadratic control point. - */ -control: Vec2, -/** - * Destination point. - */ -to: Vec2, } | { "type": "cubic", -/** - * First cubic control point. - */ -control_1: Vec2, -/** - * Second cubic control point. - */ -control_2: Vec2, -/** - * Destination point. - */ -to: Vec2, }; +export type PathSegment = + | { + type: 'move'; + /** + * Destination point. + */ + to: Vec2; + } + | { + type: 'line'; + /** + * Destination point. + */ + to: Vec2; + } + | { + type: 'quadratic'; + /** + * Quadratic control point. + */ + control: Vec2; + /** + * Destination point. + */ + to: Vec2; + } + | { + type: 'cubic'; + /** + * First cubic control point. + */ + control_1: Vec2; + /** + * Second cubic control point. + */ + control_2: Vec2; + /** + * Destination point. + */ + to: Vec2; + }; /** * One normalized subpath. Its first segment must be a move command; later * segments continue from the previous segment's destination. */ export type PathSubpath = { -/** - * Ordered move, line, and Bézier segments. - */ -segments: Array, -/** - * Whether the final point connects back to the subpath's move point. - */ -closed: boolean, }; + /** + * Ordered move, line, and Bézier segments. + */ + segments: Array; + /** + * Whether the final point connects back to the subpath's move point. + */ + closed: boolean; +}; /** * Normalized geometry for a native path shape. */ export type PathGeometry = { -/** - * Independent subpaths in document-local coordinates. - */ -subpaths: Array, -/** - * Rule used when filling the compound path. - */ -fill_rule: PathFillRule, }; + /** + * Independent subpaths in document-local coordinates. + */ + subpaths: Array; + /** + * Rule used when filling the compound path. + */ + fill_rule: PathFillRule; +}; /** * Transform relative to a shape's parent container or layer. */ -export type Transform = { -/** - * Translation in parent coordinates. - */ -translation: Vec2, -/** - * Clockwise rotation in radians. - */ -rotation: number, -/** - * Horizontal scale. - */ -scale_x: number, -/** - * Vertical scale. - */ -scale_y: number, }; - -/** - * Origin of a durable record or transaction. - */ -export type Origin = "human" | "agent" | "sync" | "system"; - -/** - * Attribution retained with durable content. - */ -export type Provenance = { -/** - * Actor responsible for the record's current form. - */ -actor_id: ActorId, -/** - * Path by which the record entered the document. - */ -origin: Origin, -/** - * Time at which this provenance entry was recorded. - */ -timestamp: Timestamp, -/** - * Optional source identifier, such as an external reference or proposal ID. - */ -source: string | null, }; +export type Transform = { + /** + * Translation in parent coordinates. + */ + translation: Vec2; + /** + * Clockwise rotation in radians. + */ + rotation: number; + /** + * Horizontal scale. + */ + scale_x: number; + /** + * Vertical scale. + */ + scale_y: number; +}; + +/** + * Origin of a record or transaction. + */ +export type Origin = 'human' | 'agent' | 'sync' | 'system'; + +/** + * Attribution retained with content. + */ +export type Provenance = { + /** + * Actor responsible for the record's current form. + */ + actor_id: ActorId; + /** + * Path by which the record entered the document. + */ + origin: Origin; + /** + * Time at which this provenance entry was recorded. + */ + timestamp: Timestamp; + /** + * Optional source identifier, such as an external reference or proposal ID. + */ + source: string | null; +}; /** * Human- and agent-readable meaning attached to a shape. */ -export type SemanticMetadata = { -/** - * Optional display name. - */ -name: string | null, -/** - * Optional semantic selector such as `architecture.service`. - */ -role: string | null, -/** - * Optional longer description. - */ -description: string | null, -/** - * Searchable, user-defined tags. - */ -tags: Array, -/** - * Whether direct edits to this shape are prohibited. - */ -locked: boolean, -/** - * Whether an agent may propose or apply edits to this shape. - */ -agent_editable: boolean, -/** - * Attribution for the record. - */ -provenance: Provenance, }; +export type SemanticMetadata = { + /** + * Optional display name. + */ + name: string | null; + /** + * Optional semantic selector such as `architecture.service`. + */ + role: string | null; + /** + * Optional longer description. + */ + description: string | null; + /** + * Searchable, user-defined tags. + */ + tags: Array; + /** + * Whether direct edits to this shape are prohibited. + */ + locked: boolean; + /** + * Whether an agent may propose or apply edits to this shape. + */ + agent_editable: boolean; + /** + * Attribution for the record. + */ + provenance: Provenance; +}; /** * Common visual style shared by all shape kinds. */ -export type ShapeStyle = { -/** - * Opacity applied to the complete shape. - */ -opacity: Opacity, -/** - * Optional opacity override for fills. - */ -fill_opacity: Opacity | null, -/** - * Optional opacity override for strokes. - */ -stroke_opacity: Opacity | null, }; +export type ShapeStyle = { + /** + * Opacity applied to the complete shape. + */ + opacity: Opacity; + /** + * Optional opacity override for fills. + */ + fill_opacity: Opacity | null; + /** + * Optional opacity override for strokes. + */ + stroke_opacity: Opacity | null; +}; /** * Parent that owns a shape's sole draw-order entry. */ -export type ShapeParent = { "kind": "layer", "id": LayerId } | { "kind": "shape", "id": ShapeId }; +export type ShapeParent = { kind: 'layer'; id: LayerId } | { kind: 'shape'; id: ShapeId }; /** * Stack direction for container layout. */ -export type StackDirection = "horizontal" | "vertical"; +export type StackDirection = 'horizontal' | 'vertical'; /** * Cross-axis alignment for laid-out children. */ -export type LayoutAlignment = "start" | "center" | "end" | "stretch"; +export type LayoutAlignment = 'start' | 'center' | 'end' | 'stretch'; /** * Padding inside a layout container. */ -export type Insets = { -/** - * Top inset. - */ -top: number, -/** - * Right inset. - */ -right: number, -/** - * Bottom inset. - */ -bottom: number, -/** - * Left inset. - */ -left: number, }; +export type Insets = { + /** + * Top inset. + */ + top: number; + /** + * Right inset. + */ + right: number; + /** + * Bottom inset. + */ + bottom: number; + /** + * Left inset. + */ + left: number; +}; /** * Optional automatic layout applied by a container shape. */ -export type ContainerLayout = { "kind": "free" } | { "kind": "stack", -/** - * Flow direction. - */ -direction: StackDirection, -/** - * Space between adjacent children. - */ -gap: number, -/** - * Space between children and container edges. - */ -padding: Insets, -/** - * Alignment on the cross axis. - */ -alignment: LayoutAlignment, } | { "kind": "grid", -/** - * Positive number of grid columns. - */ -columns: number, -/** - * Horizontal gap between cells. - */ -column_gap: number, -/** - * Vertical gap between cells. - */ -row_gap: number, -/** - * Space between children and container edges. - */ -padding: Insets, -/** - * Alignment within cells. - */ -alignment: LayoutAlignment, }; - -/** - * Durable shape record shared by all built-in shape definitions. - */ -export type ShapeRecord = { -/** - * Stable record identifier. - */ -id: ShapeId, -/** - * Registry key. Built-in values are exposed as `*_KIND` constants. - */ -kind: ShapeKind, -/** - * Parent relation; ordering comes only from the parent's child list. - */ -parent: ShapeParent, -/** - * Transform relative to `parent`. - */ -transform: Transform, -/** - * Ordered children when this shape is a container. - */ -child_ids: Array, -/** - * Optional automatic layout for container shapes. - */ -layout: ContainerLayout | null, -/** - * Kind-specific serialized properties validated by the registry. - */ -properties: ShapeProperties, -/** - * Human- and agent-readable semantics and permissions. - */ -metadata: SemanticMetadata, -/** - * Visual properties common to all kinds. - */ -style: ShapeStyle, -/** - * Version used by optimistic operation preconditions. - */ -version: RecordVersion, }; - -/** - * Durable page record and its ordered layer list. - */ -export type PageRecord = { -/** - * Stable record identifier. - */ -id: PageId, -/** - * User-visible page name. - */ -name: string, -/** - * Layer IDs in back-to-front draw order. - */ -layer_ids: Array, -/** - * Version used by optimistic operation preconditions. - */ -version: RecordVersion, }; - -/** - * Durable layer record and its ordered root-shape list. - */ -export type LayerRecord = { -/** - * Stable record identifier. - */ -id: LayerId, -/** - * Page that owns this layer. - */ -page_id: PageId, -/** - * User-visible layer name. - */ -name: string, -/** - * Root shape IDs in back-to-front draw order. - */ -shape_ids: Array, -/** - * Whether descendants participate in rendering and hit testing. - */ -visible: boolean, -/** - * Whether descendants can be selected or changed. - */ -locked: boolean, -/** - * Opacity inherited by descendants. - */ -opacity: Opacity, -/** - * Version used by optimistic operation preconditions. - */ -version: RecordVersion, }; +export type ContainerLayout = + | { kind: 'free' } + | { + kind: 'stack'; + /** + * Flow direction. + */ + direction: StackDirection; + /** + * Space between adjacent children. + */ + gap: number; + /** + * Space between children and container edges. + */ + padding: Insets; + /** + * Alignment on the cross axis. + */ + alignment: LayoutAlignment; + } + | { + kind: 'grid'; + /** + * Positive number of grid columns. + */ + columns: number; + /** + * Horizontal gap between cells. + */ + column_gap: number; + /** + * Vertical gap between cells. + */ + row_gap: number; + /** + * Space between children and container edges. + */ + padding: Insets; + /** + * Alignment within cells. + */ + alignment: LayoutAlignment; + }; + +/** + * A shape record shared by all built-in shape definitions. + */ +export type ShapeRecord = { + /** + * Stable record identifier. + */ + id: ShapeId; + /** + * Registry key. Built-in values are exposed as `*_KIND` constants. + */ + kind: ShapeKind; + /** + * Parent relation; ordering comes only from the parent's child list. + */ + parent: ShapeParent; + /** + * Transform relative to `parent`. + */ + transform: Transform; + /** + * Ordered children when this shape is a container. + */ + child_ids: Array; + /** + * Optional automatic layout for container shapes. + */ + layout: ContainerLayout | null; + /** + * Kind-specific serialized properties validated by the registry. + */ + properties: ShapeProperties; + /** + * Human- and agent-readable semantics and permissions. + */ + metadata: SemanticMetadata; + /** + * Visual properties common to all kinds. + */ + style: ShapeStyle; + /** + * Version used by optimistic operation preconditions. + */ + version: RecordVersion; +}; + +/** + * A page record and its ordered layer list. + */ +export type PageRecord = { + /** + * Stable record identifier. + */ + id: PageId; + /** + * User-visible page name. + */ + name: string; + /** + * Layer IDs in back-to-front draw order. + */ + layer_ids: Array; + /** + * Version used by optimistic operation preconditions. + */ + version: RecordVersion; +}; + +/** + * A layer record and its ordered root-shape list. + */ +export type LayerRecord = { + /** + * Stable record identifier. + */ + id: LayerId; + /** + * Page that owns this layer. + */ + page_id: PageId; + /** + * User-visible layer name. + */ + name: string; + /** + * Root shape IDs in back-to-front draw order. + */ + shape_ids: Array; + /** + * Whether descendants participate in rendering and hit testing. + */ + visible: boolean; + /** + * Whether descendants can be selected or changed. + */ + locked: boolean; + /** + * Opacity inherited by descendants. + */ + opacity: Opacity; + /** + * Version used by optimistic operation preconditions. + */ + version: RecordVersion; +}; /** * Attachment point on a bound shape. */ -export type BindingAnchor = { "kind": "center" } | { "kind": "edge", -/** - * Normalized horizontal coordinate. - */ -x: number, -/** - * Normalized vertical coordinate. - */ -y: number, }; - -/** - * Durable relationship between two shapes. - */ -export type BindingRecord = { -/** - * Stable record identifier. - */ -id: BindingId, -/** - * Registry key describing binding behavior. - */ -kind: BindingKind, -/** - * Shape that owns the binding, such as an arrow. - */ -source_shape_id: ShapeId, -/** - * Shape to which the source is bound. - */ -target_shape_id: ShapeId, -/** - * Named source handle, such as `start` or `end`. - */ -source_handle: string, -/** - * Attachment point on the target. - */ -anchor: BindingAnchor, -/** - * Version used by optimistic operation preconditions. - */ -version: RecordVersion, }; +export type BindingAnchor = + | { kind: 'center' } + | { + kind: 'edge'; + /** + * Normalized horizontal coordinate. + */ + x: number; + /** + * Normalized vertical coordinate. + */ + y: number; + }; + +/** + * Relationship between two shapes. + */ +export type BindingRecord = { + /** + * Stable record identifier. + */ + id: BindingId; + /** + * Registry key describing binding behavior. + */ + kind: BindingKind; + /** + * Shape that owns the binding, such as an arrow. + */ + source_shape_id: ShapeId; + /** + * Shape to which the source is bound. + */ + target_shape_id: ShapeId; + /** + * Named source handle, such as `start` or `end`. + */ + source_handle: string; + /** + * Attachment point on the target. + */ + anchor: BindingAnchor; + /** + * Version used by optimistic operation preconditions. + */ + version: RecordVersion; +}; /** * Storage form for asset contents. */ -export type AssetSource = { "kind": "embedded", -/** - * Raw asset bytes. - */ -bytes: Array, } | { "kind": "external", -/** - * URI used to resolve the content. - */ -uri: string, }; - -/** - * Durable image, font, or other binary asset. - */ -export type AssetRecord = { -/** - * Stable record identifier. - */ -id: AssetId, -/** - * User-visible asset name. - */ -name: string, -/** - * IANA media type. - */ -media_type: string, -/** - * Content digest including its algorithm prefix. - */ -digest: string, -/** - * Stored or linked content. - */ -source: AssetSource, -/** - * Attribution for the asset. - */ -provenance: Provenance, -/** - * Version used by optimistic operation preconditions. - */ -version: RecordVersion, }; +export type AssetSource = + | { + kind: 'embedded'; + /** + * Raw asset bytes. + */ + bytes: Array; + } + | { + kind: 'external'; + /** + * URI used to resolve the content. + */ + uri: string; + }; + +/** + * Image, font, or other binary asset. + */ +export type AssetRecord = { + /** + * Stable record identifier. + */ + id: AssetId; + /** + * User-visible asset name. + */ + name: string; + /** + * IANA media type. + */ + media_type: string; + /** + * Content digest including its algorithm prefix. + */ + digest: string; + /** + * Stored or linked content. + */ + source: AssetSource; + /** + * Attribution for the asset. + */ + provenance: Provenance; + /** + * Version used by optimistic operation preconditions. + */ + version: RecordVersion; +}; /** * Normalized, materialized Inkfinite document. */ -export type Document = { -/** - * Pages indexed by their stable IDs. - */ -pages: { [key in PageId]: PageRecord }, -/** - * Pages in user-visible order. - */ -page_ids: Array, -/** - * Layers indexed by their stable IDs. - */ -layers: { [key in LayerId]: LayerRecord }, -/** - * Shapes indexed by their stable IDs. - */ -shapes: { [key in ShapeId]: ShapeRecord }, -/** - * Bindings indexed by their stable IDs. - */ -bindings: { [key in BindingId]: BindingRecord }, -/** - * Assets indexed by their stable IDs. - */ -assets: { [key in AssetId]: AssetRecord }, }; +export type Document = { + /** + * Pages indexed by their stable IDs. + */ + pages: { [key in PageId]: PageRecord }; + /** + * Pages in user-visible order. + */ + page_ids: Array; + /** + * Layers indexed by their stable IDs. + */ + layers: { [key in LayerId]: LayerRecord }; + /** + * Shapes indexed by their stable IDs. + */ + shapes: { [key in ShapeId]: ShapeRecord }; + /** + * Bindings indexed by their stable IDs. + */ + bindings: { [key in BindingId]: BindingRecord }; + /** + * Assets indexed by their stable IDs. + */ + assets: { [key in AssetId]: AssetRecord }; +}; /** * Materialized document plus its format and causal identity. */ -export type DocumentSnapshot = { -/** - * Stable format identifier. - */ -format: FormatId, -/** - * Version of the document contract. - */ -format_version: number, -/** - * Stable document identifier. - */ -document_id: DocumentId, -/** - * Causal CRDT heads represented by this snapshot. - */ -heads: Array, -/** - * Normalized records. - */ -document: Document, }; - +export type DocumentSnapshot = { + /** + * Stable format identifier. + */ + format: FormatId; + /** + * Version of the document contract. + */ + format_version: number; + /** + * Stable document identifier. + */ + document_id: DocumentId; + /** + * Causal CRDT heads represented by this snapshot. + */ + heads: Array; + /** + * Normalized records. + */ + document: Document; +}; diff --git a/packages/bindings/src/registry.ts b/packages/bindings/src/registry.ts index d47cba1..8c9fc2f 100644 --- a/packages/bindings/src/registry.ts +++ b/packages/bindings/src/registry.ts @@ -60,6 +60,85 @@ function numericProperty(properties: Record, name: string): n return typeof value === "number" && Number.isFinite(value) ? value : 0; } +type PathPoint = { x: number; y: number }; + +function quadraticPoint(start: PathPoint, control: PathPoint, end: PathPoint, t: number): PathPoint { + const inverse = 1 - t; + return { + x: inverse * inverse * start.x + 2 * inverse * t * control.x + t * t * end.x, + y: inverse * inverse * start.y + 2 * inverse * t * control.y + t * t * end.y, + }; +} + +function cubicPoint(start: PathPoint, control1: PathPoint, control2: PathPoint, end: PathPoint, t: number): PathPoint { + const inverse = 1 - t; + return { + x: inverse ** 3 * start.x + 3 * inverse ** 2 * t * control1.x + 3 * inverse * t ** 2 * control2.x + t ** 3 * end.x, + y: inverse ** 3 * start.y + 3 * inverse ** 2 * t * control1.y + 3 * inverse * t ** 2 * control2.y + t ** 3 * end.y, + }; +} + +function quadraticRoots(a: number, b: number, c: number): number[] { + if (Math.abs(a) <= Number.EPSILON) return Math.abs(b) > Number.EPSILON ? [-c / b] : []; + const discriminant = b * b - 4 * a * c; + if (discriminant < 0) return []; + const root = Math.sqrt(discriminant); + return [(-b - root) / (2 * a), (-b + root) / (2 * a)]; +} + +/** Returns exact local bounds, including Bézier derivative extrema. */ +export function pathBounds(geometry: PathGeometry): Bounds { + const points: PathPoint[] = []; + for (const subpath of geometry.subpaths) { + const first = subpath.segments[0]; + if (!first || first.type !== 'move') continue; + const start = first.to; + let current = start; + points.push(current); + for (const segment of subpath.segments.slice(1)) { + if (segment.type === 'move') { + current = segment.to; + points.push(current); + } else if (segment.type === 'line') { + points.push(current, segment.to); + current = segment.to; + } else if (segment.type === 'quadratic') { + points.push(current, segment.to); + for (const value of [ + (current.x - segment.control.x) / (current.x - 2 * segment.control.x + segment.to.x), + (current.y - segment.control.y) / (current.y - 2 * segment.control.y + segment.to.y), + ]) { + if (Number.isFinite(value) && value > 0 && value < 1) points.push(quadraticPoint(current, segment.control, segment.to, value)); + } + current = segment.to; + } else { + points.push(current, segment.to); + for (const [startValue, control1, control2, endValue] of [ + [current.x, segment.control_1.x, segment.control_2.x, segment.to.x], + [current.y, segment.control_1.y, segment.control_2.y, segment.to.y], + ]) { + const a = -startValue + 3 * control1 - 3 * control2 + endValue; + const b = 2 * (startValue - 2 * control1 + control2); + const c = control1 - startValue; + for (const value of quadraticRoots(a, b, c)) { + if (value > 0 && value < 1) points.push(cubicPoint(current, segment.control_1, segment.control_2, segment.to, value)); + } + } + current = segment.to; + } + } + if (subpath.closed) points.push(current, start); + } + if (points.length === 0) return { x: 0, y: 0, width: 0, height: 0 }; + const xs = points.map((point) => point.x); + const ys = points.map((point) => point.y); + const minX = Math.min(...xs); + const maxX = Math.max(...xs); + const minY = Math.min(...ys); + const maxY = Math.max(...ys); + return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }; +} + export function validateShapeProperties(kind: string, properties: Record): boolean { if (!(BUILTIN_SHAPE_KINDS as readonly string[]).includes(kind)) return false; if (kind === "path" && !validatePathGeometry({ subpaths: properties.subpaths, fill_rule: properties.fill_rule })) return false; @@ -76,12 +155,17 @@ export type RegistryShape = { }; export function boundsForShape(shape: RegistryShape): Bounds { - const width = Math.abs(numericProperty(shape.properties, "width")); - const height = Math.abs(numericProperty(shape.properties, "height")); + const pathValue: PathGeometry = { + subpaths: shape.properties.subpaths as PathGeometry['subpaths'], + fill_rule: shape.properties.fill_rule as PathGeometry['fill_rule'], + }; + const local = shape.kind === 'path' && validatePathGeometry(pathValue) + ? pathBounds(pathValue) + : { x: 0, y: 0, width: Math.abs(numericProperty(shape.properties, "width")), height: Math.abs(numericProperty(shape.properties, "height")) }; const { translation, rotation, scale_x: scaleX, scale_y: scaleY } = shape.transform; const cos = Math.cos(rotation); const sin = Math.sin(rotation); - const points = [[0, 0], [width, 0], [0, height], [width, height]].map(([x, y]) => [ + const points = [[local.x, local.y], [local.x + local.width, local.y], [local.x, local.y + local.height], [local.x + local.width, local.y + local.height]].map(([x, y]) => [ translation.x + x * scaleX * cos - y * scaleY * sin, translation.y + x * scaleX * sin + y * scaleY * cos, ]); diff --git a/packages/core/src/export.ts b/packages/core/src/export.ts index 0f42705..cc0dd4c 100644 --- a/packages/core/src/export.ts +++ b/packages/core/src/export.ts @@ -1,7 +1,7 @@ import { shapeBounds } from "./geom"; import type { Box2 } from "./math"; import { Box2 as Box2Ops } from "./math"; -import type { ArrowShape, EllipseShape, LineShape, MarkdownShape, RectShape, ShapeRecord, TextShape } from "./model"; +import type { ArrowShape, EllipseShape, LineShape, MarkdownShape, PathShape, RectShape, ShapeRecord, TextShape } from "./model"; import type { EditorState } from "./reactivity"; import { getSelectedShapes, getShapesOnCurrentPage } from "./reactivity"; @@ -168,6 +168,9 @@ function shapeToSVG(shape: ShapeRecord, state: EditorState): string | null { case "text": { return textToSVG(shape, transform); } + case "path": { + return pathToSVG(shape, transform); + } case "markdown": { return markdownToSVG(shape, transform); } @@ -250,6 +253,29 @@ function textToSVG(shape: TextShape, transform: string): string { }">${escapeXML(text)}`; } +function pathToSVG(shape: PathShape, transform: string): string { + const commands = shape.props.subpaths.map((subpath) => { + const segments = subpath.segments.map((segment) => { + switch (segment.type) { + case "move": return `M ${svgNumber(segment.to.x)} ${svgNumber(segment.to.y)}`; + case "line": return `L ${svgNumber(segment.to.x)} ${svgNumber(segment.to.y)}`; + case "quadratic": return `Q ${svgNumber(segment.control.x)} ${svgNumber(segment.control.y)} ${svgNumber(segment.to.x)} ${svgNumber(segment.to.y)}`; + case "cubic": return `C ${svgNumber(segment.control_1.x)} ${svgNumber(segment.control_1.y)} ${svgNumber(segment.control_2.x)} ${svgNumber(segment.control_2.y)} ${svgNumber(segment.to.x)} ${svgNumber(segment.to.y)}`; + } + }); + if (subpath.closed) segments.push("Z"); + return segments.join(" "); + }).join(" "); + const fill = shape.props.fill ? escapeXML(shape.props.fill) : "none"; + const stroke = shape.props.stroke ? ` stroke="${escapeXML(shape.props.stroke)}" stroke-width="${svgNumber(shape.props.stroke_width ?? 2)}"` : ""; + return ``; +} + +function svgNumber(value: number): string { + if (Object.is(value, -0) || value === 0) return "0"; + return value.toFixed(6).replace(/0+$/, "").replace(/\.$/, ""); +} + /** * Export markdown shape as SVG foreignObject * diff --git a/packages/core/src/geom.ts b/packages/core/src/geom.ts index 783ca24..31daa24 100644 --- a/packages/core/src/geom.ts +++ b/packages/core/src/geom.ts @@ -7,6 +7,8 @@ import type { EllipseShape, LineShape, MarkdownShape, + PathGeometry, + PathShape, RectShape, ShapeRecord, StrokePoint, @@ -47,6 +49,9 @@ export function shapeBounds(shape: ShapeRecord): Box2 { case 'stroke': { return strokeBounds(shape); } + case 'path': { + return pathBounds(shape); + } case 'markdown': { return markdownBounds(shape); } @@ -160,6 +165,111 @@ function textBounds(shape: TextShape): Box2 { return Box2Ops.fromPoints(translatedCorners); } +/** Get bounds for a native path shape. */ +function pathBounds(shape: PathShape): Box2 { + const local = pathGeometryBounds(shape.props); + const { x, y, rot } = shape; + if (rot === 0) { + return Box2Ops.create(x + local.min.x, y + local.min.y, x + local.max.x, y + local.max.y); + } + const corners = [ + { x: local.min.x, y: local.min.y }, + { x: local.max.x, y: local.min.y }, + { x: local.max.x, y: local.max.y }, + { x: local.min.x, y: local.max.y } + ]; + return Box2Ops.fromPoints( + corners.map((corner) => { + const rotated = Vec2Ops.rotate(corner, rot); + return { x: rotated.x + x, y: rotated.y + y }; + }) + ); +} + +/** Return exact local bounds for path endpoints and Bézier extrema. */ +export function pathGeometryBounds(geometry: PathGeometry): Box2 { + const points: Vec2[] = []; + for (const subpath of geometry.subpaths) { + const first = subpath.segments[0]; + if (!first || first.type !== 'move') continue; + const start = first.to; + let current = start; + points.push(current); + for (const segment of subpath.segments.slice(1)) { + if (segment.type === 'move') { + current = segment.to; + points.push(current); + } else if (segment.type === 'line') { + points.push(current, segment.to); + current = segment.to; + } else if (segment.type === 'quadratic') { + points.push(current, segment.to); + const tx = quadraticExtremum(current.x, segment.control.x, segment.to.x); + const ty = quadraticExtremum(current.y, segment.control.y, segment.to.y); + if (tx !== null && tx > 0 && tx < 1) + points.push(quadraticPoint(current, segment.control, segment.to, tx)); + if (ty !== null && ty > 0 && ty < 1) + points.push(quadraticPoint(current, segment.control, segment.to, ty)); + current = segment.to; + } else { + points.push(current, segment.to); + for (const [startValue, control1, control2, endValue] of [ + [current.x, segment.control_1.x, segment.control_2.x, segment.to.x], + [current.y, segment.control_1.y, segment.control_2.y, segment.to.y] + ]) { + const a = -startValue + 3 * control1 - 3 * control2 + endValue; + const b = 2 * (startValue - 2 * control1 + control2); + const c = control1 - startValue; + for (const t of quadraticRoots(a, b, c)) { + if (t > 0 && t < 1) + points.push(cubicPoint(current, segment.control_1, segment.control_2, segment.to, t)); + } + } + current = segment.to; + } + } + if (subpath.closed) points.push(current, start); + } + return points.length === 0 ? Box2Ops.create(0, 0, 0, 0) : Box2Ops.fromPoints(points); +} + +function quadraticExtremum(start: number, control: number, end: number): number | null { + const denominator = start - 2 * control + end; + return Math.abs(denominator) <= Number.EPSILON ? null : (start - control) / denominator; +} + +function quadraticRoots(a: number, b: number, c: number): number[] { + if (Math.abs(a) <= Number.EPSILON) return Math.abs(b) > Number.EPSILON ? [-c / b] : []; + const discriminant = b * b - 4 * a * c; + if (discriminant < 0) return []; + const root = Math.sqrt(discriminant); + return [(-b - root) / (2 * a), (-b + root) / (2 * a)]; +} + +function quadraticPoint(start: Vec2, control: Vec2, end: Vec2, t: number): Vec2 { + const inverse = 1 - t; + return { + x: inverse * inverse * start.x + 2 * inverse * t * control.x + t * t * end.x, + y: inverse * inverse * start.y + 2 * inverse * t * control.y + t * t * end.y + }; +} + +function cubicPoint(start: Vec2, control1: Vec2, control2: Vec2, end: Vec2, t: number): Vec2 { + const inverse = 1 - t; + return { + x: + inverse ** 3 * start.x + + 3 * inverse ** 2 * t * control1.x + + 3 * inverse * t ** 2 * control2.x + + t ** 3 * end.x, + y: + inverse ** 3 * start.y + + 3 * inverse ** 2 * t * control1.y + + 3 * inverse * t ** 2 * control2.y + + t ** 3 * end.y + }; +} + /** * Get bounds for a markdown block shape */ @@ -420,6 +530,78 @@ function pointInPolygon(p: Vec2, polygon: Vec2[]): boolean { return inside; } +function pathPolylines(geometry: PathGeometry, closeOpenSubpaths: boolean): Vec2[][] { + const result: Vec2[][] = []; + for (const subpath of geometry.subpaths) { + const first = subpath.segments[0]; + if (!first || first.type !== 'move') continue; + const points: Vec2[] = [first.to]; + let current = first.to; + for (const segment of subpath.segments.slice(1)) { + if (segment.type === 'move') { + current = segment.to; + points.push(current); + } else if (segment.type === 'line') { + points.push(segment.to); + current = segment.to; + } else if (segment.type === 'quadratic') { + for (let step = 1; step <= 24; step += 1) { + points.push(quadraticPoint(current, segment.control, segment.to, step / 24)); + } + current = segment.to; + } else { + for (let step = 1; step <= 32; step += 1) { + points.push(cubicPoint(current, segment.control_1, segment.control_2, segment.to, step / 32)); + } + current = segment.to; + } + } + if ((closeOpenSubpaths || subpath.closed) && points.length > 1) points.push(first.to); + result.push(points); + } + return result; +} + +/** Test a local point against a native path's compound fill. */ +export function pointInPath(point: Vec2, geometry: PathGeometry): boolean { + let crossings = 0; + let winding = 0; + for (const polyline of pathPolylines(geometry, true)) { + for (let index = 1; index < polyline.length; index += 1) { + const from = polyline[index - 1]; + const to = polyline[index]; + if (from.y > point.y === to.y > point.y) continue; + const x = from.x + ((point.y - from.y) * (to.x - from.x)) / (to.y - from.y); + if (x <= point.x) continue; + crossings += 1; + if (to.y > from.y) winding += 1; + else winding -= 1; + } + } + return geometry.fill_rule === 'evenodd' ? crossings % 2 === 1 : winding !== 0; +} + +/** Test a world point against a native path's stroked segments. */ +export function pointNearPath(point: Vec2, shape: PathShape, tolerance = 5): boolean { + const local = worldToLocal(point, shape.x, shape.y, shape.rot); + const radius = Math.max(0, shape.props.stroke_width ?? 2) / 2 + tolerance; + for (const polyline of pathPolylines(shape.props, false)) { + for (let index = 1; index < polyline.length; index += 1) { + if (pointNearSegment(local, polyline[index - 1], polyline[index], radius)) return true; + } + } + return false; +} + +/** Test a world point against either the fill or stroke of a native path. */ +export function hitTestPath(point: Vec2, shape: PathShape, tolerance = 5): boolean { + const local = worldToLocal(point, shape.x, shape.y, shape.rot); + return ( + (Boolean(shape.props.fill) && pointInPath(local, shape.props)) || + (Boolean(shape.props.stroke) && pointNearPath(point, shape, tolerance)) + ); +} + /** * Check if a point is inside a stroke shape * @@ -520,6 +702,12 @@ export function hitTestPoint(state: EditorState, worldPoint: Vec2, tolerance = 5 } break; } + case 'path': { + if (hitTestPath(worldPoint, shape, tolerance)) { + return shape.id; + } + break; + } } } diff --git a/packages/core/src/interchange/excalidraw.ts b/packages/core/src/interchange/excalidraw.ts index f8c539e..956f405 100644 --- a/packages/core/src/interchange/excalidraw.ts +++ b/packages/core/src/interchange/excalidraw.ts @@ -472,6 +472,9 @@ function excalidrawElement( }; warnings.add('excalidraw-markdown', 'Markdown blocks were exported as literal text.'); break; + case 'path': + warnings.add('excalidraw-path', 'Native paths are omitted from Excalidraw export.'); + return null; case 'stroke': { const points = shape.props.points.map(([x, y]) => ({ x, y })); const normalized = normalizePoints(points, shape.x, shape.y, shape.rot); diff --git a/packages/core/src/model.ts b/packages/core/src/model.ts index e31ffa6..f973c46 100644 --- a/packages/core/src/model.ts +++ b/packages/core/src/model.ts @@ -67,6 +67,25 @@ export type RectProps = { w: number; h: number; fill: string; stroke: string; ra export type EllipseProps = { w: number; h: number; fill: string; stroke: string }; export type LineProps = { a: Vec2; b: Vec2; stroke: string; width: number }; +/** Fill rule for compound native paths. */ +export type PathFillRule = 'nonzero' | 'evenodd'; + +/** A normalized native path segment. */ +export type PathSegment = + | { type: 'move'; to: Vec2 } + | { type: 'line'; to: Vec2 } + | { type: 'quadratic'; control: Vec2; to: Vec2 } + | { type: 'cubic'; control_1: Vec2; control_2: Vec2; to: Vec2 }; + +/** One native path subpath. */ +export type PathSubpath = { segments: PathSegment[]; closed: boolean }; + +/** Native path geometry and its compound fill rule. */ +export type PathGeometry = { subpaths: PathSubpath[]; fill_rule: PathFillRule }; + +/** Native path painting properties stored alongside its geometry. */ +export type PathProps = PathGeometry & { fill?: string; stroke?: string; stroke_width?: number }; + /** * Arrow endpoint binding metadata */ @@ -150,7 +169,7 @@ export type StrokeStyle = { color: string; opacity: number }; */ export type StrokeProps = { points: StrokePoint[]; style: StrokeStyle; brush: BrushConfig }; -export type ShapeType = 'rect' | 'ellipse' | 'line' | 'arrow' | 'text' | 'stroke' | 'markdown'; +export type ShapeType = 'rect' | 'ellipse' | 'line' | 'arrow' | 'text' | 'stroke' | 'path' | 'markdown'; export type BaseShape = { id: string; type: ShapeType; @@ -176,9 +195,18 @@ export type LineShape = BaseShape & { type: 'line'; props: LineProps }; export type ArrowShape = BaseShape & { type: 'arrow'; props: ArrowProps }; export type TextShape = BaseShape & { type: 'text'; props: TextProps }; export type StrokeShape = BaseShape & { type: 'stroke'; props: StrokeProps }; +export type PathShape = BaseShape & { type: 'path'; props: PathProps }; export type MarkdownShape = BaseShape & { type: 'markdown'; props: MarkdownProps }; -export type ShapeRecord = RectShape | EllipseShape | LineShape | ArrowShape | TextShape | StrokeShape | MarkdownShape; +export type ShapeRecord = + | RectShape + | EllipseShape + | LineShape + | ArrowShape + | TextShape + | StrokeShape + | PathShape + | MarkdownShape; export const ShapeRecord = { /** @@ -223,6 +251,11 @@ export const ShapeRecord = { return { id: id ?? createId('shape'), type: 'stroke', pageId, x, y, rot: 0, props: properties }; }, + /** Create a native path shape. */ + createPath(pageId: string, x: number, y: number, properties: PathProps, id?: string): PathShape { + return { id: id ?? createId('shape'), type: 'path', pageId, x, y, rot: 0, props: properties }; + }, + /** * Create a markdown block shape */ @@ -267,6 +300,25 @@ export const ShapeRecord = { if (shape.type === 'markdown') { return { ...shape, props: { ...shape.props } }; } + if (shape.type === 'path') { + return { + ...shape, + props: { + ...shape.props, + subpaths: shape.props.subpaths.map((subpath) => ({ + ...subpath, + segments: subpath.segments.map((segment) => ({ + ...segment, + to: { ...segment.to }, + ...('control' in segment ? { control: { ...segment.control } } : {}), + ...('control_1' in segment + ? { control_1: { ...segment.control_1 }, control_2: { ...segment.control_2 } } + : {}) + })) + })) + } + } as PathShape; + } return { ...shape, props: { ...shape.props } } as ShapeRecord; } }; @@ -528,6 +580,26 @@ export function validateDoc(document: Document): ValidationResult { break; } + case 'path': { + if (shape.props.subpaths.length === 0) { + errors.push(`Path shape '${shapeId}' has no subpaths`); + } + if (shape.props.fill_rule !== 'nonzero' && shape.props.fill_rule !== 'evenodd') { + errors.push(`Path shape '${shapeId}' has an invalid fill rule`); + } + for (const [subpathIndex, subpath] of shape.props.subpaths.entries()) { + if (subpath.segments.length === 0 || subpath.segments[0]?.type !== 'move') { + errors.push(`Path shape '${shapeId}' subpath ${subpathIndex} must begin with a move`); + } + if (subpath.segments.slice(1).some((segment) => segment.type === 'move')) { + errors.push(`Path shape '${shapeId}' subpath ${subpathIndex} has a later move`); + } + } + if (shape.props.stroke_width !== undefined && shape.props.stroke_width < 0) { + errors.push(`Path shape '${shapeId}' has negative stroke width`); + } + break; + } case 'markdown': { if (shape.props.fontSize <= 0) { errors.push(`Markdown shape '${shapeId}' has invalid fontSize`); diff --git a/packages/core/tests/export.test.ts b/packages/core/tests/export.test.ts index 3f09776..38d26ee 100644 --- a/packages/core/tests/export.test.ts +++ b/packages/core/tests/export.test.ts @@ -118,6 +118,34 @@ describe("exportToSVG", () => { expect(svg).toContain(">Hello World"); }); + it("should export native path commands and fill rules", () => { + const { state, pageId } = createTestState(); + const path = ShapeRecord.createPath(pageId, 10, 20, { + subpaths: [{ + segments: [ + { type: "move", to: { x: 0, y: 0 } }, + { type: "line", to: { x: 40, y: 0 } }, + { type: "quadratic", control: { x: 50, y: 10 }, to: { x: 40, y: 20 } }, + { type: "cubic", control_1: { x: 40, y: 30 }, control_2: { x: 0, y: 30 }, to: { x: 0, y: 20 } } + ], + closed: true + }], + fill_rule: "evenodd", + fill: "#fff", + stroke: "#000", + stroke_width: 3 + }, "path:1"); + state.doc.shapes[path.id] = path; + state.doc.pages[pageId].shapeIds.push(path.id); + + const svg = exportToSVG(state); + expect(svg).toContain(" { const { state, pageId } = createTestState(); diff --git a/packages/core/tests/path.test.ts b/packages/core/tests/path.test.ts new file mode 100644 index 0000000..8eec1d0 --- /dev/null +++ b/packages/core/tests/path.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest'; +import { hitTestPath, pathGeometryBounds, pointInPath, pointNearPath, shapeBounds } from '../src/geom'; +import { ShapeRecord, type PathGeometry } from '../src/model'; + +describe('native path geometry', () => { + const geometry: PathGeometry = { + subpaths: [ + { + segments: [ + { type: 'move', to: { x: 0, y: 0 } }, + { type: 'line', to: { x: 40, y: 0 } }, + { type: 'quadratic', control: { x: 50, y: 10 }, to: { x: 40, y: 20 } }, + { type: 'cubic', control_1: { x: 40, y: 30 }, control_2: { x: 0, y: 30 }, to: { x: 0, y: 20 } } + ], + closed: true + } + ], + fill_rule: 'evenodd' + }; + + it('includes quadratic and cubic extrema in bounds', () => { + const bounds = pathGeometryBounds(geometry); + expect(bounds.min.x).toBe(0); + expect(bounds.max.x).toBe(45); + expect(bounds.min.y).toBe(0); + expect(bounds.max.y).toBeGreaterThan(20); + }); + + it('applies the path shape transform to bounds and hits', () => { + const shape = ShapeRecord.createPath('page', 10, 20, { ...geometry, fill: '#fff', stroke: '#000' }, 'path'); + const bounds = shapeBounds(shape); + expect(bounds.min.x).toBe(10); + expect(bounds.min.y).toBe(20); + expect(pointInPath({ x: 20, y: 25 }, geometry)).toBe(true); + expect(hitTestPath({ x: 20, y: 25 }, shape)).toBe(true); + expect(hitTestPath({ x: 100, y: 100 }, shape)).toBe(false); + }); + + it('uses even-odd for compound path holes', () => { + const compound: PathGeometry = { + subpaths: [ + { + segments: [ + { type: 'move', to: { x: 0, y: 0 } }, + { type: 'line', to: { x: 100, y: 0 } }, + { type: 'line', to: { x: 100, y: 100 } }, + { type: 'line', to: { x: 0, y: 100 } } + ], + closed: true + }, + { + segments: [ + { type: 'move', to: { x: 25, y: 25 } }, + { type: 'line', to: { x: 75, y: 25 } }, + { type: 'line', to: { x: 75, y: 75 } }, + { type: 'line', to: { x: 25, y: 75 } } + ], + closed: true + } + ], + fill_rule: 'evenodd' + }; + expect(pointInPath({ x: 10, y: 10 }, compound)).toBe(true); + expect(pointInPath({ x: 50, y: 50 }, compound)).toBe(false); + }); + + it('hits open path strokes with width and selection tolerance', () => { + const shape = ShapeRecord.createPath( + 'page', + 0, + 0, + { + subpaths: [ + { + segments: [ + { type: 'move', to: { x: 0, y: 0 } }, + { type: 'line', to: { x: 100, y: 0 } } + ], + closed: false + } + ], + fill_rule: 'nonzero', + stroke: '#000', + stroke_width: 4 + }, + 'line' + ); + expect(pointNearPath({ x: 50, y: 4 }, shape, 1)).toBe(false); + expect(pointNearPath({ x: 50, y: 4 }, shape, 3)).toBe(true); + }); +}); diff --git a/packages/renderer/src/index.ts b/packages/renderer/src/index.ts index e802ef0..888e2e0 100644 --- a/packages/renderer/src/index.ts +++ b/packages/renderer/src/index.ts @@ -6,6 +6,7 @@ import type { EllipseShape, LineShape, MarkdownShape, + PathShape, RectShape, ShapeRecord, Store, @@ -491,6 +492,10 @@ function drawShape( drawStroke(context, shape); break; } + case 'path': { + drawPath(context, shape); + break; + } } context.restore(); @@ -994,6 +999,53 @@ function parseInlineStyles(text: string): Array<{ text: string; bold: boolean; i return segments; } +/** Draw a native path with Canvas' compound fill rule and stroke. */ +function drawPath(context: CanvasRenderingContext2D, shape: PathShape) { + const { subpaths, fill_rule: fillRule, fill, stroke, stroke_width: strokeWidth } = shape.props; + const shapeAlpha = context.globalAlpha; + context.beginPath(); + for (const subpath of subpaths) { + const first = subpath.segments[0]; + if (!first || first.type !== 'move') continue; + context.moveTo(first.to.x, first.to.y); + for (const segment of subpath.segments.slice(1)) { + switch (segment.type) { + case 'move': + context.moveTo(segment.to.x, segment.to.y); + break; + case 'line': + context.lineTo(segment.to.x, segment.to.y); + break; + case 'quadratic': + context.quadraticCurveTo(segment.control.x, segment.control.y, segment.to.x, segment.to.y); + break; + case 'cubic': + context.bezierCurveTo( + segment.control_1.x, + segment.control_1.y, + segment.control_2.x, + segment.control_2.y, + segment.to.x, + segment.to.y + ); + break; + } + } + if (subpath.closed) context.closePath(); + } + if (fill) { + context.globalAlpha = shapeAlpha * (shape.fillOpacity ?? 1); + context.fillStyle = fill; + context.fill(fillRule); + } + if (stroke) { + context.globalAlpha = shapeAlpha * (shape.strokeOpacity ?? 1); + context.strokeStyle = stroke; + context.lineWidth = Math.max(0, strokeWidth ?? 2); + context.stroke(); + } +} + /** * Draw a stroke shape (freehand drawing) */ @@ -1117,6 +1169,17 @@ function drawSelection( ); break; } + case 'path': { + const bounds = shapeBounds(shape); + const padding = 5; + context.strokeRect( + bounds.min.x - shape.x - padding, + bounds.min.y - shape.y - padding, + bounds.max.x - bounds.min.x + padding * 2, + bounds.max.y - bounds.min.y + padding * 2 + ); + break; + } case 'text': { const { fontSize, fontFamily, text, w } = shape.props; context.font = `${fontSize}px ${fontFamily}`; diff --git a/packages/renderer/tests/index.test.ts b/packages/renderer/tests/index.test.ts index 9fece4e..3a36bf2 100644 --- a/packages/renderer/tests/index.test.ts +++ b/packages/renderer/tests/index.test.ts @@ -28,6 +28,8 @@ describe('Renderer', () => { beginPath: vi.fn(), moveTo: vi.fn(), lineTo: vi.fn(), + quadraticCurveTo: vi.fn(), + bezierCurveTo: vi.fn(), arc: vi.fn(), arcTo: vi.fn(), ellipse: vi.fn(), @@ -402,6 +404,62 @@ describe('Renderer', () => { renderer.dispose(); }); + it('renders native path segments with the stored fill rule', () => { + const scheduledFrames: FrameRequestCallback[] = []; + globalThis.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => { + scheduledFrames.push(callback); + return scheduledFrames.length; + }); + const page = PageRecord.create('Page 1', 'page:1'); + const path = ShapeRecord.createPath( + 'page:1', + 0, + 0, + { + subpaths: [ + { + segments: [ + { type: 'move', to: { x: 0, y: 0 } }, + { type: 'line', to: { x: 40, y: 0 } }, + { type: 'quadratic', control: { x: 50, y: 10 }, to: { x: 40, y: 20 } }, + { + type: 'cubic', + control_1: { x: 40, y: 30 }, + control_2: { x: 0, y: 30 }, + to: { x: 0, y: 20 } + } + ], + closed: true + } + ], + fill_rule: 'evenodd', + fill: '#fff', + stroke: '#000', + stroke_width: 3 + }, + 'path:1' + ); + const store = new Store(); + store.setState((state) => ({ + ...state, + doc: { + pages: { [page.id]: { ...page, shapeIds: [path.id] } }, + shapes: { [path.id]: path }, + bindings: {} + }, + ui: { ...state.ui, currentPageId: page.id } + })); + + const renderer = createRenderer(canvas, store); + scheduledFrames.shift()?.(0); + + expect(context.quadraticCurveTo).toHaveBeenCalledWith(50, 10, 40, 20); + expect(context.bezierCurveTo).toHaveBeenCalledWith(40, 30, 0, 30, 0, 20); + expect(context.fill).toHaveBeenCalledWith('evenodd'); + expect(context.lineWidth).toBe(3); + renderer.dispose(); + }); + it('should render scene with line shape', async () => { const store = new Store(); diff --git a/schemas/document-snapshot.schema.json b/schemas/document-snapshot.schema.json index d5b9703..c38f08f 100644 --- a/schemas/document-snapshot.schema.json +++ b/schemas/document-snapshot.schema.json @@ -10,7 +10,7 @@ "type": "string" }, "AssetRecord": { - "description": "Durable image, font, or other binary asset.", + "description": "Image, font, or other binary asset.", "properties": { "digest": { "description": "Content digest including its algorithm prefix.", @@ -151,7 +151,7 @@ "type": "string" }, "BindingRecord": { - "description": "Durable relationship between two shapes.", + "description": "Relationship between two shapes.", "properties": { "anchor": { "$ref": "#/$defs/BindingAnchor", @@ -392,7 +392,7 @@ "type": "string" }, "LayerRecord": { - "description": "Durable layer record and its ordered root-shape list.", + "description": "A layer record and its ordered root-shape list.", "properties": { "id": { "$ref": "#/$defs/LayerId", @@ -475,7 +475,7 @@ "type": "number" }, "Origin": { - "description": "Origin of a durable record or transaction.", + "description": "Origin of a record or transaction.", "oneOf": [ { "const": "human", @@ -504,7 +504,7 @@ "type": "string" }, "PageRecord": { - "description": "Durable page record and its ordered layer list.", + "description": "A page record and its ordered layer list.", "properties": { "id": { "$ref": "#/$defs/PageId", @@ -535,7 +535,7 @@ "type": "object" }, "Provenance": { - "description": "Attribution retained with durable content.", + "description": "Attribution retained with content.", "properties": { "actor_id": { "$ref": "#/$defs/ActorId", @@ -565,7 +565,7 @@ "type": "object" }, "RecordVersion": { - "description": "Monotonic version of a durable record within the document history.", + "description": "Monotonic version of a record within the document history.", "format": "uint64", "minimum": 0, "type": "integer" @@ -670,7 +670,7 @@ ] }, "ShapeRecord": { - "description": "Durable shape record shared by all built-in shape definitions.", + "description": "A shape record shared by all built-in shape definitions.", "properties": { "child_ids": { "description": "Ordered children when this shape is a container.", diff --git a/schemas/protocol-request.schema.json b/schemas/protocol-request.schema.json index 09adf92..f3b38cd 100644 --- a/schemas/protocol-request.schema.json +++ b/schemas/protocol-request.schema.json @@ -30,7 +30,7 @@ "type": "object" }, "AssetRecord": { - "description": "Durable image, font, or other binary asset.", + "description": "Image, font, or other binary asset.", "properties": { "digest": { "description": "Content digest including its algorithm prefix.", @@ -171,7 +171,7 @@ "type": "string" }, "BindingRecord": { - "description": "Durable relationship between two shapes.", + "description": "Relationship between two shapes.", "properties": { "anchor": { "$ref": "#/$defs/BindingAnchor", @@ -461,7 +461,7 @@ "type": "object" }, "LayerRecord": { - "description": "Durable layer record and its ordered root-shape list.", + "description": "A layer record and its ordered root-shape list.", "properties": { "id": { "$ref": "#/$defs/LayerId", @@ -1096,7 +1096,7 @@ ] }, "Origin": { - "description": "Origin of a durable record or transaction.", + "description": "Origin of a record or transaction.", "oneOf": [ { "const": "human", @@ -1125,7 +1125,7 @@ "type": "string" }, "PageRecord": { - "description": "Durable page record and its ordered layer list.", + "description": "A page record and its ordered layer list.", "properties": { "id": { "$ref": "#/$defs/PageId", @@ -1160,7 +1160,7 @@ "type": "string" }, "Provenance": { - "description": "Attribution retained with durable content.", + "description": "Attribution retained with content.", "properties": { "actor_id": { "$ref": "#/$defs/ActorId", @@ -1286,7 +1286,7 @@ "type": "object" }, "RecordVersion": { - "description": "Monotonic version of a durable record within the document history.", + "description": "Monotonic version of a record within the document history.", "format": "uint64", "minimum": 0, "type": "integer" @@ -1488,7 +1488,7 @@ "type": "object" }, "ShapeRecord": { - "description": "Durable shape record shared by all built-in shape definitions.", + "description": "A shape record shared by all built-in shape definitions.", "properties": { "child_ids": { "description": "Ordered children when this shape is a container.", diff --git a/schemas/protocol-response.schema.json b/schemas/protocol-response.schema.json index e350245..7f2ebe6 100644 --- a/schemas/protocol-response.schema.json +++ b/schemas/protocol-response.schema.json @@ -48,7 +48,7 @@ "type": "object" }, "AssetRecord": { - "description": "Durable image, font, or other binary asset.", + "description": "Image, font, or other binary asset.", "properties": { "digest": { "description": "Content digest including its algorithm prefix.", @@ -189,7 +189,7 @@ "type": "string" }, "BindingRecord": { - "description": "Durable relationship between two shapes.", + "description": "Relationship between two shapes.", "properties": { "anchor": { "$ref": "#/$defs/BindingAnchor", @@ -685,7 +685,7 @@ "type": "object" }, "LayerRecord": { - "description": "Durable layer record and its ordered root-shape list.", + "description": "A layer record and its ordered root-shape list.", "properties": { "id": { "$ref": "#/$defs/LayerId", @@ -1320,7 +1320,7 @@ ] }, "Origin": { - "description": "Origin of a durable record or transaction.", + "description": "Origin of a record or transaction.", "oneOf": [ { "const": "human", @@ -1349,7 +1349,7 @@ "type": "string" }, "PageRecord": { - "description": "Durable page record and its ordered layer list.", + "description": "A page record and its ordered layer list.", "properties": { "id": { "$ref": "#/$defs/PageId", @@ -1472,7 +1472,7 @@ "type": "object" }, "Provenance": { - "description": "Attribution retained with durable content.", + "description": "Attribution retained with content.", "properties": { "actor_id": { "$ref": "#/$defs/ActorId", @@ -1734,7 +1734,7 @@ ] }, "RecordVersion": { - "description": "Monotonic version of a durable record within the document history.", + "description": "Monotonic version of a record within the document history.", "format": "uint64", "minimum": 0, "type": "integer" @@ -1957,7 +1957,7 @@ "type": "object" }, "ShapeRecord": { - "description": "Durable shape record shared by all built-in shape definitions.", + "description": "A shape record shared by all built-in shape definitions.", "properties": { "child_ids": { "description": "Ordered children when this shape is a container.", diff --git a/schemas/transaction-draft.schema.json b/schemas/transaction-draft.schema.json index b536ed9..9c06084 100644 --- a/schemas/transaction-draft.schema.json +++ b/schemas/transaction-draft.schema.json @@ -30,7 +30,7 @@ "type": "object" }, "AssetRecord": { - "description": "Durable image, font, or other binary asset.", + "description": "Image, font, or other binary asset.", "properties": { "digest": { "description": "Content digest including its algorithm prefix.", @@ -171,7 +171,7 @@ "type": "string" }, "BindingRecord": { - "description": "Durable relationship between two shapes.", + "description": "Relationship between two shapes.", "properties": { "anchor": { "$ref": "#/$defs/BindingAnchor", @@ -421,7 +421,7 @@ "type": "object" }, "LayerRecord": { - "description": "Durable layer record and its ordered root-shape list.", + "description": "A layer record and its ordered root-shape list.", "properties": { "id": { "$ref": "#/$defs/LayerId", @@ -1056,7 +1056,7 @@ ] }, "Origin": { - "description": "Origin of a durable record or transaction.", + "description": "Origin of a record or transaction.", "oneOf": [ { "const": "human", @@ -1085,7 +1085,7 @@ "type": "string" }, "PageRecord": { - "description": "Durable page record and its ordered layer list.", + "description": "A page record and its ordered layer list.", "properties": { "id": { "$ref": "#/$defs/PageId", @@ -1116,7 +1116,7 @@ "type": "object" }, "Provenance": { - "description": "Attribution retained with durable content.", + "description": "Attribution retained with content.", "properties": { "actor_id": { "$ref": "#/$defs/ActorId", @@ -1146,7 +1146,7 @@ "type": "object" }, "RecordVersion": { - "description": "Monotonic version of a durable record within the document history.", + "description": "Monotonic version of a record within the document history.", "format": "uint64", "minimum": 0, "type": "integer" @@ -1344,7 +1344,7 @@ "type": "object" }, "ShapeRecord": { - "description": "Durable shape record shared by all built-in shape definitions.", + "description": "A shape record shared by all built-in shape definitions.", "properties": { "child_ids": { "description": "Ordered children when this shape is a container.", diff --git a/scripts/check-bindings.mjs b/scripts/check-bindings.mjs index b5304c1..0655230 100644 --- a/scripts/check-bindings.mjs +++ b/scripts/check-bindings.mjs @@ -5,6 +5,7 @@ import { boundsForShape, BUILTIN_SHAPE_KINDS, GEOMETRY_CONVENTION, + pathBounds, validatePathGeometry, validateShapeProperties, } from "../packages/bindings/dist/index.js"; @@ -23,6 +24,9 @@ for (const testCase of fixture.property_cases) { } assert.ok(validatePathGeometry(fixture.path_geometry)); +for (const testCase of fixture.invalid_path_cases) { + assert.equal(validatePathGeometry(testCase.geometry), testCase.valid, testCase.name); +} for (const testCase of fixture.geometry_cases) { const actual = boundsForShape({ @@ -30,6 +34,10 @@ for (const testCase of fixture.geometry_cases) { properties: testCase.properties, transform: testCase.transform, }); + if (testCase.kind === "path") { + const local = pathBounds(testCase.properties); + assert.ok(local.width > 0 && local.height > 0); + } for (const key of ["x", "y", "width", "height"]) { assert.ok(Math.abs(actual[key] - testCase.expected_bounds[key]) < 1e-9, `${key} differs`); }