diff --git a/CHANGELOG.md b/CHANGELOG.md index d0b2a82..2fd34f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,8 @@ ancestor transforms, semantic editor patches, and minimal parent-relative native transactions. - Stateful browser WASM document sessions that open, validate, mutate, undo, redo, and save canonical Automerge bytes through one worker. +- Canonical geometry commits that validate and normalize native paths and freehand strokes in Rust, + compute committed stroke bounds, and exercise shared Rust/TypeScript geometry fixtures. #### SVG Interop diff --git a/README.md b/README.md index 3cde834..90ba9dc 100644 --- a/README.md +++ b/README.md @@ -239,6 +239,20 @@ Run the shared UI workshop when changing components or the editor: pnpm dev:ui ``` +## Credits + +I was inspired by fantastic apps in the space like +[Excalidraw](https://github.com/excalidraw/excalidraw) and more recently, tldraw. +[tldraw offline](https://offline.tldraw.com/) in particular was so cool that it +kicked off [a lot](https://thndrs.stormlightlabs.org/) [of other](https://sbuf.stormlightlabs.org/) +[agent-capable](https://mire.stormlightlabs.org/) work for me. + +The choice to use [perfect-freehand](https://www.npmjs.com/package/perfect-freehand) came +from playing around with [this](https://reactflow.dev/examples/whiteboard/freehand-draw) +react flow demo. + +The SVG capabilities were inspired by this [post](https://aturi.to/explore/did:plc:p572wxnsuoogcrhlfrlizlrb/app.bsky.feed.post/3mth4fkaok2nl). + ## License Inkfinite is licensed under [Apache-2.0](LICENSE). diff --git a/TODO.md b/TODO.md index 7716c1b..ed551e0 100644 --- a/TODO.md +++ b/TODO.md @@ -90,10 +90,10 @@ Inkfinite now imports SVGs through one validated Rust pipeline across desktop, w ### Canonical geometry -- [ ] Commit path geometry through Rust validation -- [ ] Commit freehand strokes through Rust normalization -- [ ] Keep gesture previews and hit testing in TypeScript -- [ ] Add committed-geometry parity fixtures +- [x] Commit path geometry through Rust validation +- [x] Commit freehand strokes through Rust normalization +- [x] Keep gesture previews and hit testing in TypeScript +- [x] Add committed-geometry parity fixtures ## Vector Editing 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 fff79e4..233cc96 100644 --- a/apps/web/src/content/docs/internals/native-path-geometry.md +++ b/apps/web/src/content/docs/internals/native-path-geometry.md @@ -53,8 +53,8 @@ the subpath its starting point. Later segments continue from the previous destin - `cubic` stores two control points and a destination. A `closed` subpath connects its final destination back to its initial move point. Closure is a -property of the subpath, not a separate segment. Separate subpaths represent compound geometry; -they do not use additional move segments inside one subpath. +property of the subpath, not a separate segment. Separate subpaths represent compound geometry +but do not use additional move segments inside one subpath. ## Compound fills @@ -65,7 +65,14 @@ to all subpaths in the path when a renderer determines which regions are inside Rust validates path properties before they enter a shape record. It rejects empty paths, empty subpaths, subpaths without an initial move, later move segments, and non-finite coordinates. -`validate_shape_properties` applies this check whenever the shape kind is `path`. +`validate_shape_properties` applies this check whenever the shape kind is `path`. Create and patch +operations reserialize valid path geometry through the Rust representation before committing it. + +Freehand shapes keep their input points and brush settings as properties. Rust validates those +properties at the same transaction boundary, writes their canonical field representation, and +computes the committed outline for bounds and invalidated regions. TypeScript can use +`perfect-freehand` for pointer previews and hit testing. It doesn't decide whether a stroke enters +the document. The binding generator exports `PathFillRule`, `PathSegment`, `PathSubpath`, and `PathGeometry` to `@inkfinite/bindings`. Its registry also exposes `validatePathGeometry` and applies the same diff --git a/crates/inkfinite-cli/src/bin/generate-bindings.rs b/crates/inkfinite-cli/src/bin/generate-bindings.rs index aacc9eb..a892b12 100644 --- a/crates/inkfinite-cli/src/bin/generate-bindings.rs +++ b/crates/inkfinite-cli/src/bin/generate-bindings.rs @@ -275,7 +275,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\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" + "{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 validateStrokeProperties(properties: Record): boolean {{\n const points = properties.points;\n if (!Array.isArray(points) || points.length < 2) return false;\n if (!points.every((point) => Array.isArray(point)\n && (point.length === 2 || point.length === 3)\n && point.every((value, index) => typeof value === \"number\" && Number.isFinite(value)\n && (index < 2 || (value >= 0 && value <= 1))))) return false;\n const style = properties.style;\n if (!isRecord(style) || typeof style.color !== \"string\"\n || typeof style.opacity !== \"number\" || !Number.isFinite(style.opacity)\n || style.opacity < 0 || style.opacity > 1) return false;\n const brush = properties.brush;\n if (!isRecord(brush) || typeof brush.size !== \"number\" || !Number.isFinite(brush.size)\n || brush.size <= 0 || typeof brush.thinning !== \"number\" || !Number.isFinite(brush.thinning)\n || typeof brush.smoothing !== \"number\" || !Number.isFinite(brush.smoothing)\n || typeof brush.streamline !== \"number\" || !Number.isFinite(brush.streamline)\n || typeof brush.simulatePressure !== \"boolean\") return false;\n return true;\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 if (kind === \"stroke\" && !validateStrokeProperties(properties)) 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" ) } @@ -410,9 +410,35 @@ fn fixture_json() -> Result> { .into_iter() .map(|(name, geometry)| json!({ "name": name, "geometry": geometry, "valid": false })) .collect::>(); + let stroke_properties = json!({ + "points": [[0.0, 0.0], [20.0, 10.0]], + "style": { "color": "#000000", "opacity": 1.0 }, + "brush": { + "size": 8.0, + "thinning": 0.5, + "smoothing": 0.5, + "streamline": 0.5, + "simulatePressure": true + } + }); let property_cases = [ (RECTANGLE_KIND, json!({"width": 40.0, "height": 20.0})), (inkfinite_core::PATH_KIND, path_properties.clone()), + (inkfinite_core::STROKE_KIND, stroke_properties), + ( + inkfinite_core::STROKE_KIND, + json!({ + "points": [[0.0, 0.0]], + "style": { "color": "#000000", "opacity": 1.0 }, + "brush": { + "size": 8.0, + "thinning": 0.5, + "smoothing": 0.5, + "streamline": 0.5, + "simulatePressure": true + } + }), + ), (RECTANGLE_KIND, json!({"width": -1.0, "height": 20.0})), (RECTANGLE_KIND, json!({"width": "40", "height": 20.0})), ("unknown", json!({})), diff --git a/crates/inkfinite-core/src/engine/geometry.rs b/crates/inkfinite-core/src/engine/geometry.rs index d7553b5..f6aa10f 100644 --- a/crates/inkfinite-core/src/engine/geometry.rs +++ b/crates/inkfinite-core/src/engine/geometry.rs @@ -1,5 +1,8 @@ +use perfect_freehand::{InputPoint, StrokeOptions, get_stroke}; +use serde_json::Value; + use super::{Bounds, Document, EngineError, ShapeId, ShapeParent, ShapeRecord}; -use crate::{PathGeometry, PathSegment, Transform, Vec2}; +use crate::{PathGeometry, PathSegment, ShapeProperties, StrokeProperties, Transform, Vec2}; /// A two-dimensional affine transform shared by document geometry consumers. #[derive(Clone, Copy, Debug, PartialEq)] @@ -93,6 +96,8 @@ pub fn local_shape_bounds(shape: &ShapeRecord) -> Bounds { .map_or(Bounds { x: 0.0, y: 0.0, width: 0.0, height: 0.0 }, |geometry| { path_bounds(&geometry) }) + } else if shape.kind.as_str() == crate::STROKE_KIND { + stroke_bounds(&shape.properties).unwrap_or(Bounds { x: 0.0, y: 0.0, width: 0.0, height: 0.0 }) } else { let width = numeric_property(shape, "width").unwrap_or(0.0).abs(); let height = numeric_property(shape, "height").unwrap_or(0.0).abs(); @@ -101,6 +106,49 @@ pub fn local_shape_bounds(shape: &ShapeRecord) -> Bounds { Affine::from_transform(shape.transform).transform_bounds(local) } +/// Computes the committed freehand outline using the Rust canonical renderer. +/// +/// The editor may calculate the same outline for a responsive preview, but +/// transaction validation and affected-region calculation use this function. +/// +/// # Errors +/// +/// Returns the decoded stroke-property error when the properties are malformed. +pub fn stroke_outline(properties: &ShapeProperties) -> Result, String> { + crate::validate_shape_properties(crate::STROKE_KIND, properties).map_err(|error| error.to_string())?; + let stroke: StrokeProperties = serde_json::from_value(Value::Object(properties.clone().into_iter().collect())) + .map_err(|error| format!("stroke properties could not be decoded: {error}"))?; + if stroke.points.len() < 2 { + return Ok(Vec::new()); + } + let points = stroke + .points + .iter() + .map(|point| InputPoint::Array([point[0], point[1]], point.get(2).copied())) + .collect::>(); + let options = StrokeOptions { + size: Some(stroke.brush.size), + thinning: Some(stroke.brush.thinning), + smoothing: Some(stroke.brush.smoothing), + streamline: Some(stroke.brush.streamline), + simulate_pressure: Some(stroke.brush.simulate_pressure), + ..StrokeOptions::default() + }; + Ok(get_stroke(&points, &options) + .into_iter() + .map(|point| Vec2 { x: point[0], y: point[1] }) + .collect()) +} + +/// Returns the axis-aligned bounds of a committed freehand outline. +/// +/// # Errors +/// +/// Returns the decoded stroke-property error when the properties are malformed. +pub fn stroke_bounds(properties: &ShapeProperties) -> Result { + Ok(bounds_from_points(&stroke_outline(properties)?)) +} + /// Returns a shape's axis-aligned bounds in document coordinates. #[must_use] pub fn world_shape_bounds(document: &Document, shape_id: &ShapeId) -> Bounds { diff --git a/crates/inkfinite-core/src/engine/mod.rs b/crates/inkfinite-core/src/engine/mod.rs index b37ce26..df90f07 100644 --- a/crates/inkfinite-core/src/engine/mod.rs +++ b/crates/inkfinite-core/src/engine/mod.rs @@ -16,7 +16,7 @@ use crate::proto::{ use crate::sync::{PeerSync, SyncDisposition, SyncMessage}; use crate::{ ActorId, AssetId, BindingId, ChangeHash, ContainerLayout, Document, DocumentId, LayerId, Origin, PageId, - RecordVersion, ShapeId, ShapeParent, ShapeProperties, ShapeRecord, SiblingAnchor, + RecordVersion, ShapeId, ShapeParent, ShapeProperties, ShapeRecord, SiblingAnchor, normalize_shape_properties, }; use thiserror::Error; @@ -103,7 +103,11 @@ impl TransactionEngine { /// /// Returns an error when the initial document violates an invariant or /// cannot be encoded by the CRDT adapter. - pub fn create(document_id: DocumentId, actor_id: ActorId, document: Document) -> Result { + pub fn create(document_id: DocumentId, actor_id: ActorId, mut document: Document) -> Result { + for shape in document.shapes.values_mut() { + shape.properties = normalize_shape_properties(shape.kind.as_str(), &shape.properties) + .map_err(|error| EngineError::Schema(format!("shape {}: {error}", shape.id)))?; + } validate_document(&document)?; Ok(Self { crdt: AutomergeDocument::create(document_id, actor_id, document)?, diff --git a/crates/inkfinite-core/src/engine/operations.rs b/crates/inkfinite-core/src/engine/operations.rs index 6d8c769..cdb9773 100644 --- a/crates/inkfinite-core/src/engine/operations.rs +++ b/crates/inkfinite-core/src/engine/operations.rs @@ -9,6 +9,7 @@ use super::validation::ensure_binding_endpoints; use super::{ AssetId, AssetPatch, BTreeMap, BTreeSet, Document, EngineError, LayerContentsDisposition, LayerId, LayerPatch, LayoutAxis, Operation, PageId, RecordVersion, ShapeAlignment, ShapeId, ShapeParent, ShapePatch, SiblingAnchor, + normalize_shape_properties, }; #[allow(clippy::too_many_lines)] @@ -79,8 +80,11 @@ pub fn apply_operation(document: &mut Document, operation: &Operation) -> Result "new shape child_ids must be empty; create children separately".into(), )); } - insert_shape_child(document, &shape.parent, shape.id.clone(), anchor)?; - document.shapes.insert(shape.id.clone(), shape.clone()); + let mut canonical_shape = shape.clone(); + canonical_shape.properties = normalize_shape_properties(shape.kind.as_str(), &shape.properties) + .map_err(|error| EngineError::Schema(format!("shape {}: {error}", shape.id)))?; + insert_shape_child(document, &canonical_shape.parent, canonical_shape.id.clone(), anchor)?; + document.shapes.insert(canonical_shape.id.clone(), canonical_shape); Ok(vec![Operation::DeleteShape { shape_id: shape.id.clone(), expected_version: Some(shape.version), @@ -179,6 +183,15 @@ pub fn patch_layer( pub fn patch_shape( document: &mut Document, shape_id: &ShapeId, patch: &ShapePatch, expected: Option, ) -> Result, EngineError> { + let normalized_properties = patch + .properties + .as_ref() + .map(|properties| { + let kind = shape(document, shape_id, expected)?.kind.clone(); + normalize_shape_properties(kind.as_str(), properties) + .map_err(|error| EngineError::Schema(format!("shape {shape_id}: {error}"))) + }) + .transpose()?; let shape = shape_mut(document, shape_id, expected)?; let inverse = ShapePatch { transform: patch.transform.map(|_| shape.transform), @@ -190,8 +203,8 @@ pub fn patch_shape( if let Some(value) = patch.transform { shape.transform = value; } - if let Some(value) = &patch.properties { - shape.properties.clone_from(value); + if let Some(value) = normalized_properties { + shape.properties = value; } if let Some(value) = &patch.metadata { shape.metadata.clone_from(value); diff --git a/crates/inkfinite-core/src/engine/tests.rs b/crates/inkfinite-core/src/engine/tests.rs index f252ddd..625edfa 100644 --- a/crates/inkfinite-core/src/engine/tests.rs +++ b/crates/inkfinite-core/src/engine/tests.rs @@ -111,6 +111,65 @@ fn transaction(engine: &mut TransactionEngine, actor: &str, id: &str, operations } } +#[test] +fn geometry_is_normalized_and_bounded_at_the_commit_boundary() { + let mut engine = engine(); + let path_id = ShapeId::from("shape:path"); + let stroke_id = ShapeId::from("shape:stroke"); + let mut path = shape(path_id.as_str(), 120.0); + path.kind = ShapeKind::from(crate::PATH_KIND); + path.properties = ShapeProperties::from([ + ( + "subpaths".into(), + json!([{ + "segments": [ + { "type": "move", "to": { "x": 0.0, "y": 0.0 } }, + { "type": "cubic", "control_1": { "x": 0.0, "y": 30.0 }, "control_2": { "x": 30.0, "y": 30.0 }, "to": { "x": 30.0, "y": 0.0 } } + ], + "closed": true + }]), + ), + ("fill_rule".into(), json!("evenodd")), + ]); + let mut stroke = shape(stroke_id.as_str(), 180.0); + stroke.kind = ShapeKind::from(crate::STROKE_KIND); + stroke.properties = ShapeProperties::from([ + ("points".into(), json!([[0.0, 0.0], [40.0, 20.0], [80.0, 0.0]])), + ("style".into(), json!({ "color": "#000", "opacity": 1.0 })), + ( + "brush".into(), + json!({ + "size": 12.0, + "thinning": 0.5, + "smoothing": 0.5, + "streamline": 0.5, + "simulatePressure": true + }), + ), + ]); + let draft = transaction( + &mut engine, + "actor:local", + "create geometry", + vec![ + Operation::CreateShape { shape: path, anchor: SiblingAnchor::Last }, + Operation::CreateShape { shape: stroke, anchor: SiblingAnchor::Last }, + ], + ); + let result = engine.commit(draft).expect("valid geometry should commit"); + assert!(result.affected_regions.iter().any(|region| region.bounds.width > 0.0)); + + let snapshot = engine.snapshot().expect("snapshot after geometry commit"); + let committed_path = &snapshot.document.shapes[&path_id]; + assert_eq!(committed_path.properties["fill_rule"], json!("evenodd")); + assert_eq!( + committed_path.properties["subpaths"][0]["segments"][0]["type"], + json!("move") + ); + let committed_stroke = &snapshot.document.shapes[&stroke_id]; + assert_eq!(committed_stroke.properties["brush"]["simulatePressure"], json!(true)); +} + #[test] fn transaction_is_atomic_and_returns_inverse_patch_heads_and_regions() { let mut engine = engine(); diff --git a/crates/inkfinite-core/src/lib.rs b/crates/inkfinite-core/src/lib.rs index 901a414..b424ad3 100644 --- a/crates/inkfinite-core/src/lib.rs +++ b/crates/inkfinite-core/src/lib.rs @@ -169,6 +169,29 @@ pub fn is_builtin_shape_kind(kind: &str) -> bool { /// Kind-specific shape properties owned by the shape registry. pub type ShapeProperties = BTreeMap; +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct StrokeBrushProperties { + pub(crate) size: f64, + pub(crate) thinning: f64, + pub(crate) smoothing: f64, + pub(crate) streamline: f64, + pub(crate) simulate_pressure: bool, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(crate) struct StrokeStyleProperties { + pub(crate) color: String, + pub(crate) opacity: f64, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(crate) struct StrokeProperties { + pub(crate) points: Vec>, + pub(crate) style: StrokeStyleProperties, + pub(crate) brush: StrokeBrushProperties, +} + /// Storage form for asset contents. #[derive(Clone, Debug, Eq, JsonSchema, PartialEq, Serialize, Deserialize, TS)] #[serde(rename_all = "snake_case", tag = "kind")] @@ -218,6 +241,9 @@ pub enum ShapePropertyError { /// Native path properties do not decode or fail path geometry validation. #[error("shape kind {kind} has invalid path geometry: {message}")] InvalidPath { kind: String, message: String }, + /// Freehand properties do not decode or fail committed stroke validation. + #[error("shape kind {kind} has invalid stroke geometry: {message}")] + InvalidStroke { kind: String, message: String }, } /// Anchor used to place an item in an ordered child list without numeric indexes. @@ -870,12 +896,12 @@ pub fn blank_document(document_id: &DocumentId, page_name: Option<&str>) -> Docu /// Unknown properties remain available for shape-specific extensions. The /// registry gives common `width` and `height` properties cross-language numeric /// and non-negative constraints, and validates the normalized geometry of path -/// shapes. +/// and freehand stroke shapes. /// /// # Errors /// -/// Returns [`ShapePropertyError`] when the kind is unknown or a supplied common -/// dimension cannot be represented as a finite, non-negative number. +/// Returns [`ShapePropertyError`] when the kind, a common dimension, or a +/// path or stroke geometry value is invalid. pub fn validate_shape_properties(kind: &str, properties: &ShapeProperties) -> Result<(), ShapePropertyError> { if !is_builtin_shape_kind(kind) { return Err(ShapePropertyError::UnknownKind { kind: kind.to_owned() }); @@ -900,10 +926,119 @@ pub fn validate_shape_properties(kind: &str, properties: &ShapeProperties) -> Re .map_err(|error| ShapePropertyError::InvalidPath { kind: kind.to_owned(), message: error.to_string() })?; validate_path_geometry(&geometry) .map_err(|error| ShapePropertyError::InvalidPath { kind: kind.to_owned(), message: error.to_string() })?; + } else if kind == STROKE_KIND { + validate_stroke_properties(properties) + .map_err(|message| ShapePropertyError::InvalidStroke { kind: kind.to_owned(), message })?; + } + Ok(()) +} + +/// Normalizes geometry properties at the canonical transaction boundary. +/// +/// Path properties are decoded and reserialized from the native representation. +/// Freehand points and brush settings are likewise decoded and written back with +/// the stable browser-facing field names. Other shape properties are cloned +/// unchanged. Callers should use this before storing a shape or patching its +/// geometry so equivalent editor values have one durable representation. +/// +/// # Errors +/// +/// Returns [`ShapePropertyError`] when the kind or its geometry is invalid. +pub fn normalize_shape_properties( + kind: &str, properties: &ShapeProperties, +) -> Result { + validate_shape_properties(kind, properties)?; + let mut normalized = properties.clone(); + if kind == PATH_KIND { + let geometry = path_geometry_from_properties(properties) + .map_err(|error| ShapePropertyError::InvalidPath { kind: kind.to_owned(), message: error.to_string() })?; + normalized.insert( + "subpaths".into(), + serde_json::to_value(geometry.subpaths).map_err(|error| ShapePropertyError::InvalidPath { + kind: kind.to_owned(), + message: error.to_string(), + })?, + ); + normalized.insert( + "fill_rule".into(), + serde_json::to_value(geometry.fill_rule).map_err(|error| ShapePropertyError::InvalidPath { + kind: kind.to_owned(), + message: error.to_string(), + })?, + ); + } else if kind == STROKE_KIND { + let stroke = decode_stroke_properties(properties) + .map_err(|message| ShapePropertyError::InvalidStroke { kind: kind.to_owned(), message })?; + normalized.insert( + "points".into(), + serde_json::to_value(stroke.points).map_err(|error| ShapePropertyError::InvalidStroke { + kind: kind.to_owned(), + message: error.to_string(), + })?, + ); + normalized.insert( + "style".into(), + serde_json::to_value(stroke.style).map_err(|error| ShapePropertyError::InvalidStroke { + kind: kind.to_owned(), + message: error.to_string(), + })?, + ); + normalized.insert( + "brush".into(), + serde_json::to_value(stroke.brush).map_err(|error| ShapePropertyError::InvalidStroke { + kind: kind.to_owned(), + message: error.to_string(), + })?, + ); + } + Ok(normalized) +} + +fn validate_stroke_properties(properties: &ShapeProperties) -> Result<(), String> { + let stroke = decode_stroke_properties(properties)?; + if stroke.points.len() < 2 { + return Err("stroke must contain at least two points".into()); + } + for (index, point) in stroke.points.iter().enumerate() { + if !(2..=3).contains(&point.len()) { + return Err(format!( + "stroke point {index} must contain x, y, and an optional pressure" + )); + } + if !point[..2].iter().all(|value| value.is_finite()) { + return Err(format!("stroke point {index} has a non-finite coordinate")); + } + if let Some(pressure) = point.get(2) + && (!pressure.is_finite() || !(0.0..=1.0).contains(pressure)) + { + return Err(format!("stroke point {index} has invalid pressure")); + } + } + if !stroke.brush.size.is_finite() || stroke.brush.size <= 0.0 { + return Err("stroke brush size must be finite and positive".into()); + } + if ![ + stroke.brush.thinning, + stroke.brush.smoothing, + stroke.brush.streamline, + stroke.style.opacity, + ] + .into_iter() + .all(f64::is_finite) + { + return Err("stroke brush and style values must be finite".into()); + } + if !(0.0..=1.0).contains(&stroke.style.opacity) { + return Err("stroke style opacity must be between 0 and 1".into()); } Ok(()) } +fn decode_stroke_properties(properties: &ShapeProperties) -> Result { + serde_json::from_value(Value::Object(properties.clone().into_iter().collect())) + .map_err(|error| format!("stroke properties could not be decoded: {error}")) +} + #[cfg(test)] mod tests { use super::*; @@ -1013,6 +1148,45 @@ mod tests { Err(ShapePropertyError::InvalidPath { .. }) )); + let stroke_properties = BTreeMap::from([ + ("points".into(), serde_json::json!([[0.0, 0.0, 0.25], [20.0, 10.0]])), + ("style".into(), serde_json::json!({ "color": "#000", "opacity": 0.75 })), + ( + "brush".into(), + serde_json::json!({ + "size": 8.0, + "thinning": 0.5, + "smoothing": 0.5, + "streamline": 0.5, + "simulatePressure": true + }), + ), + ]); + assert!(validate_shape_properties(STROKE_KIND, &stroke_properties).is_ok()); + let normalized = normalize_shape_properties(STROKE_KIND, &stroke_properties).expect("stroke normalizes"); + assert_eq!(normalized["points"], stroke_properties["points"]); + assert_eq!(normalized["brush"]["simulatePressure"], Value::Bool(true)); + assert!(matches!( + validate_shape_properties( + STROKE_KIND, + &BTreeMap::from([ + ("points".into(), serde_json::json!([[0.0, 0.0]])), + ("style".into(), serde_json::json!({ "color": "#000", "opacity": 1.0 })), + ( + "brush".into(), + serde_json::json!({ + "size": 8.0, + "thinning": 0.5, + "smoothing": 0.5, + "streamline": 0.5, + "simulatePressure": true + }) + ) + ]) + ), + Err(ShapePropertyError::InvalidStroke { .. }) + )); + assert_eq!(BuiltinShapeKind::parse("rect"), Some(BuiltinShapeKind::Rectangle)); assert_eq!(BuiltinShapeKind::parse("path"), Some(BuiltinShapeKind::Path)); assert_eq!(BuiltinShapeKind::Rectangle.to_string(), RECTANGLE_KIND); diff --git a/crates/inkfinite-core/src/render/mod.rs b/crates/inkfinite-core/src/render/mod.rs index d1b7a09..9bce9bf 100644 --- a/crates/inkfinite-core/src/render/mod.rs +++ b/crates/inkfinite-core/src/render/mod.rs @@ -3,12 +3,13 @@ use std::collections::{BTreeMap, BTreeSet}; use std::fmt::Write as _; -use perfect_freehand::{InputPoint, StrokeOptions, get_stroke}; use serde::Deserialize; use serde_json::Value; use thiserror::Error; -use crate::engine::geometry::{Affine, bounds_from_points, intersects, union, world_transform}; +use crate::engine::geometry::{ + Affine, bounds_from_points, intersects, stroke_outline as canonical_stroke_outline, union, world_transform, +}; use crate::proto::Bounds; use crate::{ AssetId, AssetSource, BindingAnchor, BuiltinShapeKind, Document, DocumentSnapshot, LayerId, PageId, PathFillRule, @@ -231,8 +232,14 @@ impl Renderer<'_> { self.render_markdown(shape, &transform, &fill_opacity, &stroke_opacity, &mut output)?; } Some(BuiltinShapeKind::Stroke) => { - let props: StrokeProps = properties(shape)?; - let outline = stroke_outline(&props); + let props: StrokePaintProperties = properties(shape)?; + let outline = canonical_stroke_outline(&shape.properties).map_err(|message| { + SvgRenderError::InvalidShapeProperties { + shape_id: shape.id.clone(), + kind: shape.kind.to_string(), + message, + } + })?; if !outline.is_empty() { let path = outline .iter() @@ -574,10 +581,8 @@ struct PathProps { } #[derive(Deserialize)] -struct StrokeProps { - points: Vec>, +struct StrokePaintProperties { style: StrokeStyle, - brush: Brush, } #[derive(Deserialize)] @@ -586,16 +591,6 @@ struct StrokeStyle { opacity: f64, } -#[derive(Deserialize)] -struct Brush { - size: f64, - thinning: f64, - smoothing: f64, - streamline: f64, - #[serde(rename = "simulatePressure")] - simulate_pressure: bool, -} - struct MarkdownLine { text: String, font_size: f64, @@ -762,10 +757,16 @@ fn shape_local_bounds(shape: &ShapeRecord) -> Result { let props: MarkdownProps = properties(shape)?; Bounds { x: 0.0, y: 0.0, width: props.width, height: props.height.unwrap_or(props.font_size * 10.0) } } - Some(BuiltinShapeKind::Stroke) => { - let props: StrokeProps = properties(shape)?; - bounds_from_points(&stroke_outline(&props)) - } + Some(BuiltinShapeKind::Stroke) => canonical_stroke_outline(&shape.properties).map_or_else( + |message| { + Err(SvgRenderError::InvalidShapeProperties { + shape_id: shape.id.clone(), + kind: shape.kind.to_string(), + message, + }) + }, + |outline| Ok(bounds_from_points(&outline)), + )?, Some(BuiltinShapeKind::Path) => { let geometry = crate::path_geometry_from_properties(&shape.properties).map_err(|error| { SvgRenderError::InvalidShapeProperties { @@ -828,30 +829,6 @@ fn path_fill_rule(rule: PathFillRule) -> &'static str { } } -fn stroke_outline(props: &StrokeProps) -> Vec { - if props.points.len() < 2 { - return Vec::new(); - } - let points = props - .points - .iter() - .filter(|point| point.len() >= 2) - .map(|point| InputPoint::Array([point[0], point[1]], point.get(2).copied())) - .collect::>(); - let options = StrokeOptions { - size: Some(props.brush.size), - thinning: Some(props.brush.thinning), - smoothing: Some(props.brush.smoothing), - streamline: Some(props.brush.streamline), - simulate_pressure: Some(props.brush.simulate_pressure), - ..StrokeOptions::default() - }; - get_stroke(&points, &options) - .into_iter() - .map(|point| Vec2 { x: point[0], y: point[1] }) - .collect() -} - fn render_shape_world_bounds(document: &Document, shape: &ShapeRecord) -> Result { Ok(world_transform(document, shape).transform_bounds(shape_local_bounds(shape)?)) } diff --git a/crates/inkfinite-core/tests/committed_geometry.rs b/crates/inkfinite-core/tests/committed_geometry.rs new file mode 100644 index 0000000..91d5a0b --- /dev/null +++ b/crates/inkfinite-core/tests/committed_geometry.rs @@ -0,0 +1,67 @@ +use inkfinite_core::engine::geometry::{path_bounds, stroke_bounds}; +use inkfinite_core::{PathGeometry, ShapeProperties}; +use serde::Deserialize; +use serde_json::Value; + +#[derive(Deserialize)] +struct GeometryFixture { + path_cases: Vec, + stroke_cases: Vec, +} + +#[derive(Deserialize)] +struct PathCase { + name: String, + geometry: PathGeometry, + expected_bounds: BoundsFixture, +} + +#[derive(Deserialize)] +struct StrokeCase { + name: String, + points: Value, + brush: Value, + style: Value, + committed_bounds: BoundsFixture, +} + +#[derive(Deserialize)] +struct BoundsFixture { + x: f64, + y: f64, + width: f64, + height: f64, +} + +fn assert_close(actual: f64, expected: f64, name: &str, field: &str) { + assert!( + (actual - expected).abs() < 1e-12, + "{name} {field}: expected {expected}, got {actual}" + ); +} + +#[test] +fn rust_committed_geometry_matches_shared_fixture() { + let fixture: GeometryFixture = + serde_json::from_str(include_str!("../../../fixtures/native/geometry/committed.json")) + .expect("committed geometry fixture should decode"); + for case in fixture.path_cases { + let actual = path_bounds(&case.geometry); + assert_close(actual.x, case.expected_bounds.x, &case.name, "x"); + assert_close(actual.y, case.expected_bounds.y, &case.name, "y"); + assert_close(actual.width, case.expected_bounds.width, &case.name, "width"); + assert_close(actual.height, case.expected_bounds.height, &case.name, "height"); + } + for case in fixture.stroke_cases { + let properties = ShapeProperties::from([ + ("points".into(), case.points), + ("brush".into(), case.brush), + ("style".into(), case.style), + ]); + let actual = stroke_bounds(&properties).expect("stroke fixture should validate"); + assert_close(actual.x, case.committed_bounds.x, &case.name, "x"); + assert_close(actual.y, case.committed_bounds.y, &case.name, "y"); + assert_close(actual.width, case.committed_bounds.width, &case.name, "width"); + assert_close(actual.height, case.committed_bounds.height, &case.name, "height"); + } +} diff --git a/crates/inkfinite-wasm/src/lib.rs b/crates/inkfinite-wasm/src/lib.rs index 2f802ae..abdc4f4 100644 --- a/crates/inkfinite-wasm/src/lib.rs +++ b/crates/inkfinite-wasm/src/lib.rs @@ -765,8 +765,30 @@ mod tests { "parent": {"kind": "layer", "id": "layer:one"}, "transform": {"a": 1, "b": 0, "c": 0, "d": 1, "e": 4, "f": 5}, "anchor": {"position": "last"} + }, { + "type": "create_shape", + "shape": {"id": "shape:path", "kind": "path", "properties": { + "subpaths": [{"segments": [ + {"type": "move", "to": {"x": 0, "y": 0}}, + {"type": "line", "to": {"x": 20, "y": 10}} + ], "closed": false}], + "fill_rule": "nonzero" + }, "metadata": null, "style": {"opacity": 1, "fill_opacity": null, "stroke_opacity": null}, "layout": null}, + "parent": {"kind": "layer", "id": "layer:one"}, + "transform": {"a": 1, "b": 0, "c": 0, "d": 1, "e": 30, "f": 5}, + "anchor": {"position": "last"} + }, { + "type": "create_shape", + "shape": {"id": "shape:stroke", "kind": "stroke", "properties": { + "points": [[0, 0], [20, 10]], + "style": {"color": "#000000", "opacity": 1}, + "brush": {"size": 8, "thinning": 0.5, "smoothing": 0.5, "streamline": 0.5, "simulatePressure": true} + }, "metadata": null, "style": {"opacity": 1, "fill_opacity": null, "stroke_opacity": null}, "layout": null}, + "parent": {"kind": "layer", "id": "layer:one"}, + "transform": {"a": 1, "b": 0, "c": 0, "d": 1, "e": 60, "f": 5}, + "anchor": {"position": "last"} }], - "actor_id": "browser", "origin": "human", "transaction_id": "transaction:create", "description": "Create rectangle", "timestamp": 1 + "actor_id": "browser", "origin": "human", "transaction_id": "transaction:create", "description": "Create geometry", "timestamp": 1 }); let response: Value = serde_json::from_str(&session.apply_editor_patches(&request.to_string())).expect("commit response"); @@ -774,6 +796,11 @@ mod tests { let state: Value = serde_json::from_str(&session.state_json().expect("state should serialize")).expect("state JSON"); assert!(state["snapshot"]["document"]["shapes"]["shape:rect"].is_object()); + assert!(state["snapshot"]["document"]["shapes"]["shape:path"].is_object()); + assert_eq!( + state["snapshot"]["document"]["shapes"]["shape:stroke"]["properties"]["brush"]["simulatePressure"], + true + ); assert!(session.can_undo()); let saved = session.save().expect("session should save"); let mut reopened = open_document(&saved, "browser").expect("saved bytes should reopen"); diff --git a/fixtures/native/geometry/committed.json b/fixtures/native/geometry/committed.json new file mode 100644 index 0000000..e15399a --- /dev/null +++ b/fixtures/native/geometry/committed.json @@ -0,0 +1,47 @@ +{ + "path_cases": [ + { + "name": "quadratic-and-cubic-extrema", + "geometry": { + "subpaths": [ + { + "segments": [ + { "type": "move", "to": { "x": 0, "y": 0 } }, + { "type": "quadratic", "control": { "x": 10, "y": 20 }, "to": { "x": 20, "y": 0 } }, + { "type": "cubic", "control_1": { "x": 30, "y": -20 }, "control_2": { "x": 40, "y": 20 }, "to": { "x": 50, "y": 0 } } + ], + "closed": false + } + ], + "fill_rule": "nonzero" + }, + "expected_bounds": { "x": 0, "y": -5.773502691896258, "width": 50, "height": 15.773502691896258 } + } + ], + "stroke_cases": [ + { + "name": "canonical-freehand-line", + "points": [[0, 0], [100, 0]], + "brush": { + "size": 16, + "thinning": 0.5, + "smoothing": 0.5, + "streamline": 0.5, + "simulatePressure": true + }, + "style": { "color": "#000000", "opacity": 1 }, + "committed_bounds": { + "x": -6.518636079036312, + "y": -6.5664765625, + "width": 92.51052395669203, + "height": 13.132953125 + }, + "preview_bounds": { + "x": -5.244746912062406, + "y": -5.28323828125, + "width": 90.74833137481014, + "height": 10.5664765625 + } + } + ] +} diff --git a/fixtures/native/shape-registry.json b/fixtures/native/shape-registry.json index 764e764..d068fa1 100644 --- a/fixtures/native/shape-registry.json +++ b/fixtures/native/shape-registry.json @@ -316,6 +316,56 @@ }, "valid": true }, + { + "kind": "stroke", + "properties": { + "brush": { + "simulatePressure": true, + "size": 8.0, + "smoothing": 0.5, + "streamline": 0.5, + "thinning": 0.5 + }, + "points": [ + [ + 0.0, + 0.0 + ], + [ + 20.0, + 10.0 + ] + ], + "style": { + "color": "#000000", + "opacity": 1.0 + } + }, + "valid": true + }, + { + "kind": "stroke", + "properties": { + "brush": { + "simulatePressure": true, + "size": 8.0, + "smoothing": 0.5, + "streamline": 0.5, + "thinning": 0.5 + }, + "points": [ + [ + 0.0, + 0.0 + ] + ], + "style": { + "color": "#000000", + "opacity": 1.0 + } + }, + "valid": false + }, { "kind": "rect", "properties": { diff --git a/packages/bindings/src/conformance.ts b/packages/bindings/src/conformance.ts index 762b51f..fd756aa 100644 --- a/packages/bindings/src/conformance.ts +++ b/packages/bindings/src/conformance.ts @@ -7,6 +7,14 @@ import type { Bounds, ShapePatch, TransactionDraft } from './transaction.js'; const transform: Transform = { translation: { x: 10, y: 20 }, rotation: 0.5, scale_x: 2, scale_y: 1.5 }; const properties: ShapeProperties = { width: 40, height: 20 }; +const strokeProperties: ShapeProperties = { + points: [ + [0, 0], + [20, 10] + ], + style: { color: '#000000', opacity: 1 }, + brush: { size: 8, thinning: 0.5, smoothing: 0.5, streamline: 0.5, simulatePressure: true } +}; const pathGeometry: PathGeometry = { subpaths: [ @@ -68,6 +76,7 @@ const bounds: Bounds = boundsForShape(protocolShape); if ( !validateShapeProperties(shape.kind, properties) || + !validateShapeProperties('stroke', strokeProperties) || !validatePathGeometry(pathGeometry) || bounds.width <= 0 || bounds.height <= 0 diff --git a/packages/bindings/src/registry.ts b/packages/bindings/src/registry.ts index da6e2eb..2c10b08 100644 --- a/packages/bindings/src/registry.ts +++ b/packages/bindings/src/registry.ts @@ -78,6 +78,49 @@ export function validatePathGeometry(value: unknown): value is PathGeometry { }); } +function validateStrokeProperties(properties: Record): boolean { + const points = properties.points; + if (!Array.isArray(points) || points.length < 2) return false; + if ( + !points.every( + (point) => + Array.isArray(point) && + (point.length === 2 || point.length === 3) && + point.every( + (value, index) => + typeof value === 'number' && Number.isFinite(value) && (index < 2 || (value >= 0 && value <= 1)) + ) + ) + ) + return false; + const style = properties.style; + if ( + !isRecord(style) || + typeof style.color !== 'string' || + typeof style.opacity !== 'number' || + !Number.isFinite(style.opacity) || + style.opacity < 0 || + style.opacity > 1 + ) + return false; + const brush = properties.brush; + if ( + !isRecord(brush) || + typeof brush.size !== 'number' || + !Number.isFinite(brush.size) || + brush.size <= 0 || + typeof brush.thinning !== 'number' || + !Number.isFinite(brush.thinning) || + typeof brush.smoothing !== 'number' || + !Number.isFinite(brush.smoothing) || + typeof brush.streamline !== 'number' || + !Number.isFinite(brush.streamline) || + typeof brush.simulatePressure !== 'boolean' + ) + return false; + return true; +} + function numericProperty(properties: Record, name: string): number { const value = properties[name]; return typeof value === 'number' && Number.isFinite(value) ? value : 0; @@ -176,6 +219,7 @@ export function validateShapeProperties(kind: string, properties: Record { const value = properties[name]; return value === undefined || (typeof value === 'number' && Number.isFinite(value) && value >= 0); diff --git a/packages/core/tests/committed-geometry.test.ts b/packages/core/tests/committed-geometry.test.ts new file mode 100644 index 0000000..7effafa --- /dev/null +++ b/packages/core/tests/committed-geometry.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import { boundsFromOutline, computeOutline, pathGeometryBounds } from '../src/geom'; +import type { BrushConfig, PathGeometry, StrokePoint } from '../src/model'; +import fixture from '../../../fixtures/native/geometry/committed.json'; + +type BoundsFixture = { x: number; y: number; width: number; height: number }; + +function expectBounds(actual: { min: { x: number; y: number }; max: { x: number; y: number } }, expected: BoundsFixture) { + expect(actual.min.x).toBeCloseTo(expected.x, 12); + expect(actual.min.y).toBeCloseTo(expected.y, 12); + expect(actual.max.x - actual.min.x).toBeCloseTo(expected.width, 12); + expect(actual.max.y - actual.min.y).toBeCloseTo(expected.height, 12); +} + +describe('committed geometry fixtures', () => { + it('keeps TypeScript path previews aligned with canonical path bounds', () => { + for (const testCase of fixture.path_cases) { + const bounds = pathGeometryBounds(testCase.geometry as PathGeometry); + expectBounds(bounds, testCase.expected_bounds); + } + }); + + it('keeps the TypeScript freehand preview fixture explicit beside Rust committed bounds', () => { + for (const testCase of fixture.stroke_cases) { + const brush = testCase.brush as BrushConfig; + const outline = computeOutline(testCase.points as StrokePoint[], brush); + expect(outline.length).toBeGreaterThan(0); + expectBounds(boundsFromOutline(outline), testCase.preview_bounds); + for (const field of ['x', 'y', 'width', 'height'] as const) { + expect(Math.abs(testCase.preview_bounds[field] - testCase.committed_bounds[field])).toBeLessThan(3); + } + } + }); +});