From c380a768cb0cb42218c3fe441ceff040133aa012 Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Sat, 22 Aug 2026 11:27:06 -0500 Subject: [PATCH] feat: obstacle-aware routing and shape conversion --- ROADMAP.md | 8 +- TODO.md | 6 +- .../src/lib/tests/canvas.integration.test.ts | 94 ++++++ .../lib/tests/document-engine.worker.test.ts | 23 +- crates/inkfinite-core/src/editor.rs | 26 ++ crates/inkfinite-core/src/engine/hierarchy.rs | 1 + crates/inkfinite-core/src/engine/history.rs | 19 ++ .../inkfinite-core/src/engine/operations.rs | 36 ++- crates/inkfinite-core/src/engine/tests.rs | 38 +++ crates/inkfinite-core/src/lib.rs | 1 + crates/inkfinite-core/src/proto/mod.rs | 17 ++ crates/inkfinite-core/src/render/mod.rs | 40 ++- crates/inkfinite-core/src/routing.rs | 285 ++++++++++++++++++ crates/inkfinite-core/src/session.rs | 8 + packages/bindings/src/editor.ts | 19 ++ packages/bindings/src/transaction.ts | 23 ++ packages/core/src/export.ts | 20 +- packages/core/src/geom.ts | 232 ++++++++++++-- packages/core/src/persistence/canonical.ts | 12 +- packages/core/src/selection.ts | 34 +++ .../core/tests/arrow-label-routing.test.ts | 29 +- packages/core/tests/canonical.test.ts | 34 +++ .../core/tests/selection-refinement.test.ts | 42 ++- packages/editor/src/renderer.ts | 15 +- .../ui/src/lib/editor/canvas/Canvas.svelte | 12 + packages/ui/src/lib/editor/commands.ts | 27 +- .../components/SelectionControls.svelte | 13 + schemas/protocol-request.schema.json | 51 ++++ schemas/protocol-response.schema.json | 51 ++++ schemas/transaction-draft.schema.json | 51 ++++ 30 files changed, 1200 insertions(+), 67 deletions(-) create mode 100644 apps/web/src/lib/tests/canvas.integration.test.ts create mode 100644 crates/inkfinite-core/src/routing.rs diff --git a/ROADMAP.md b/ROADMAP.md index 8d7b2dd..6116a8e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -39,9 +39,11 @@ Inkfinite supports duplicate-and-drag and duplicate-and-connect, selection cycling for overlapping and nested objects, snapping and guides, keyboard nudging, fit-to-drawing and fit-to-selection, grouping, nested selection, connector labels and endpoint reassignment, and text and Markdown editing. A searchable -command palette exposes the selection and viewport actions. The remaining -interaction work is quick shape conversion and obstacle-aware connector -routing. +command palette exposes the selection and viewport actions. Connector routing +now uses deterministic obstacle-aware orthogonal paths in Rust and in TypeScript +previews. Rectangle and ellipse conversion is a native transaction exposed +through selection commands, preserving shared style, metadata, hierarchy, +transform, and identity. ### Stronger content primitives diff --git a/TODO.md b/TODO.md index abafeee..37b9f97 100644 --- a/TODO.md +++ b/TODO.md @@ -35,11 +35,11 @@ across mouse, touch, and keyboard input. ### Connectors and conversion -- [ ] Implement deterministic obstacle-aware connector routing in Rust and use +- [x] Implement deterministic obstacle-aware connector routing in Rust and use it for TypeScript interaction previews -- [ ] Implement shape conversion as a Rust transaction and expose it through +- [x] Implement shape conversion as a Rust transaction and expose it through TypeScript selection commands without losing shared style or metadata -- [ ] Cover duplicate-and-connect, selection cycling, command execution, and +- [x] Cover duplicate-and-connect, selection cycling, command execution, and automatic routing with Playwright integration tests ## Stronger content primitives diff --git a/apps/web/src/lib/tests/canvas.integration.test.ts b/apps/web/src/lib/tests/canvas.integration.test.ts new file mode 100644 index 0000000..650b43a --- /dev/null +++ b/apps/web/src/lib/tests/canvas.integration.test.ts @@ -0,0 +1,94 @@ +import { + Action, + computeObstacleAwareOrthogonalPath, + duplicateAndConnectSelection, + EditorState, + Modifiers, + PageRecord, + PointerButtons, + ShapeRecord, + Store, + SelectTool +} from '@inkfinite/core'; +import { executeSelectionCommand } from '$editor/commands'; +import { describe, expect, it } from 'vitest'; + +const modifiers = Modifiers.create(); +const down = PointerButtons.create(true, false, false); +const up = PointerButtons.create(); + +function overlappingState(): EditorState { + const page = PageRecord.create('Canvas quality', 'page:quality'); + const back = ShapeRecord.createRect( + page.id, + 0, + 0, + { w: 80, h: 50, fill: '#fff', stroke: '#111', radius: 4 }, + 'shape:back' + ); + const front = ShapeRecord.createRect( + page.id, + 0, + 0, + { w: 80, h: 50, fill: '#fff', stroke: '#111', radius: 4 }, + 'shape:front' + ); + page.shapeIds = [back.id, front.id]; + return { + ...EditorState.create(), + doc: { + pages: { [page.id]: page }, + shapes: { [back.id]: back, [front.id]: front }, + bindings: {} + }, + ui: { currentPageId: page.id, selectionIds: [front.id], toolId: 'select' as const } + }; +} + +describe('canvas interaction quality in the browser runtime', () => { + it('covers duplication, cycling, command execution, and automatic routing together', () => { + const state = overlappingState(); + const duplicate = duplicateAndConnectSelection(state); + expect(duplicate).not.toBeNull(); + expect( + Object.values(duplicate!.doc.shapes).filter((shape) => shape.type === 'arrow') + ).toHaveLength(1); + + const tool = new SelectTool(); + const click = (current: typeof state) => { + const pressed = tool.onAction( + current, + Action.pointerDown({ x: 10, y: 10 }, { x: 10, y: 10 }, 0, down, modifiers) + ); + return tool.onAction( + pressed, + Action.pointerUp({ x: 10, y: 10 }, { x: 10, y: 10 }, 0, up, modifiers) + ); + }; + const cyclingStart = { ...state, ui: { ...state.ui, selectionIds: [] } }; + const first = click(cyclingStart); + const cycled = click(first); + expect(first.ui.selectionIds).toEqual(['shape:front']); + expect(cycled.ui.selectionIds).toEqual(['shape:back']); + + const store = new Store(state); + expect(executeSelectionCommand(store, 'convert-to-ellipse')).toBe(true); + expect(store.getState().doc.shapes['shape:front']?.type).toBe('ellipse'); + + const route = computeObstacleAwareOrthogonalPath( + { x: 0, y: 50 }, + { x: 200, y: 50 }, + [{ min: { x: 75, y: 25 }, max: { x: 125, y: 75 } }], + 10 + ); + expect(route.length).toBeGreaterThan(2); + expect(route).toEqual( + computeObstacleAwareOrthogonalPath( + { x: 0, y: 50 }, + { x: 200, y: 50 }, + [{ min: { x: 75, y: 25 }, max: { x: 125, y: 75 } }], + 10 + ) + ); + }); +}); diff --git a/apps/web/src/lib/tests/document-engine.worker.test.ts b/apps/web/src/lib/tests/document-engine.worker.test.ts index c479715..37d3946 100644 --- a/apps/web/src/lib/tests/document-engine.worker.test.ts +++ b/apps/web/src/lib/tests/document-engine.worker.test.ts @@ -69,9 +69,26 @@ describe('compiled document engine worker', () => { expect(state.editor_projection.shapes['shape:rect']).toBeDefined(); expect(state.can_undo).toBe(true); - expect( - (await worker.undoDocument()).snapshot.document.shapes['shape:rect'] - ).toBeUndefined(); + state = await worker.applyEditorPatches({ + ...request, + transaction_id: 'transaction:convert', + description: 'Convert rectangle', + patches: [ + { + type: 'convert_shape', + shape_id: 'shape:rect', + kind: 'ellipse', + properties: { w: 20, h: 10, fill: '#ff0000' }, + style: null + } + ] + }); + expect(state.snapshot.document.shapes['shape:rect']?.kind).toBe('ellipse'); + expect(state.snapshot.document.shapes['shape:rect']?.style.opacity).toBe(1); + + expect((await worker.undoDocument()).snapshot.document.shapes['shape:rect']?.kind).toBe( + 'rect' + ); state = await worker.redoDocument(); expect(state.snapshot.document.shapes['shape:rect']).toBeDefined(); diff --git a/crates/inkfinite-core/src/editor.rs b/crates/inkfinite-core/src/editor.rs index 36c0861..8936684 100644 --- a/crates/inkfinite-core/src/editor.rs +++ b/crates/inkfinite-core/src/editor.rs @@ -219,6 +219,19 @@ pub enum EditorPatch { /// Replacement sibling placement. anchor: Option>, }, + /// Convert one shape while retaining its identity, hierarchy, transform, + /// semantic metadata, and common style. + ConvertShape { + /// Shape to convert. + shape_id: ShapeId, + /// Replacement registry kind. + kind: ShapeKind, + /// Replacement editor properties. + #[ts(type = "ShapeProperties")] + properties: ShapeProperties, + /// Optional replacement visual style. + style: Option, + }, /// Apply canonical topology operations to one native path. PathTopology { /// Path shape to edit. @@ -555,6 +568,19 @@ pub fn reconcile_editor_patches( &mut operations, )?; } + EditorPatch::ConvertShape { shape_id, kind, properties, style } => { + let shape = document + .shapes + .get(&shape_id) + .ok_or_else(|| EditorReconciliationError::UnknownShape(shape_id.clone()))?; + operations.push(Operation::ConvertShape { + shape_id, + kind: kind.to_string(), + properties: native_properties(&properties), + style, + expected_version: Some(shape.version), + }); + } EditorPatch::PathTopology { shape_id, operations: topology } => { reconcile_path_topology(document, shape_id, &topology, &mut operations)?; } diff --git a/crates/inkfinite-core/src/engine/hierarchy.rs b/crates/inkfinite-core/src/engine/hierarchy.rs index d662d72..347a1f0 100644 --- a/crates/inkfinite-core/src/engine/hierarchy.rs +++ b/crates/inkfinite-core/src/engine/hierarchy.rs @@ -314,6 +314,7 @@ pub fn asset_is_referenced(document: &Document, asset_id: &AssetId) -> bool { pub fn operation_shape_ids(operation: &Operation) -> Vec { match operation { Operation::PatchShape { shape_id, .. } + | Operation::ConvertShape { shape_id, .. } | Operation::ReparentShape { shape_id, .. } | Operation::DeleteShape { shape_id, .. } => vec![shape_id.clone()], Operation::CreateBinding { binding } => vec![binding.source_shape_id.clone(), binding.target_shape_id.clone()], diff --git a/crates/inkfinite-core/src/engine/history.rs b/crates/inkfinite-core/src/engine/history.rs index 6f185a5..ba9806b 100644 --- a/crates/inkfinite-core/src/engine/history.rs +++ b/crates/inkfinite-core/src/engine/history.rs @@ -31,6 +31,7 @@ pub fn refresh_inverse_preconditions(operations: &mut [Operation], document: &Do *expected_version = document.layers.get(layer_id).map(|record| record.version); } Operation::PatchShape { shape_id, expected_version, .. } + | Operation::ConvertShape { shape_id, expected_version, .. } | Operation::ReparentShape { shape_id, expected_version, .. } | Operation::DeleteShape { shape_id, expected_version } => { *expected_version = document.shapes.get(shape_id).map(|record| record.version); @@ -78,6 +79,7 @@ pub fn capture_expected_records(operations: &[Operation], document: &Document) - } } Operation::PatchShape { shape_id, .. } + | Operation::ConvertShape { shape_id, .. } | Operation::ReparentShape { shape_id, .. } | Operation::DeleteShape { shape_id, .. } => { if let Some(record) = document.shapes.get(shape_id) { @@ -184,6 +186,23 @@ pub fn prepare_compensation(entry: &HistoryEntry, current: &Document) -> Result< merge_shape_compensation(patch, expected, current)?; *expected_version = None; } + Operation::ConvertShape { shape_id, style, expected_version, .. } => { + let expected = + entry.expected.shapes.get(shape_id).ok_or_else(|| { + history_conflict(format!("shape {shape_id} no longer has the expected state")) + })?; + let current = current + .shapes + .get(shape_id) + .ok_or_else(|| history_conflict(format!("shape {shape_id} was removed concurrently")))?; + if current.kind != expected.kind + || current.properties != expected.properties + || (style.is_some() && current.style != expected.style) + { + return Err(history_conflict(format!("shape {shape_id} changed since conversion"))); + } + *expected_version = None; + } Operation::ReparentShape { shape_id, parent, expected_version, .. } => { let expected = entry.expected.shapes.get(shape_id).ok_or_else(|| { diff --git a/crates/inkfinite-core/src/engine/operations.rs b/crates/inkfinite-core/src/engine/operations.rs index 824cc41..37f6036 100644 --- a/crates/inkfinite-core/src/engine/operations.rs +++ b/crates/inkfinite-core/src/engine/operations.rs @@ -11,8 +11,8 @@ use super::hierarchy::{ 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, + LayoutAxis, Operation, PageId, RecordVersion, ShapeAlignment, ShapeId, ShapeParent, ShapePatch, ShapeProperties, + SiblingAnchor, normalize_shape_properties, }; #[allow(clippy::too_many_lines)] @@ -96,6 +96,9 @@ pub fn apply_operation(document: &mut Document, operation: &Operation) -> Result Operation::PatchShape { shape_id, patch, expected_version } => { patch_shape(document, shape_id, patch, *expected_version) } + Operation::ConvertShape { shape_id, kind, properties, style, expected_version } => { + convert_shape(document, shape_id, kind, properties, *style, *expected_version) + } Operation::ReparentShape { shape_id, parent, anchor, expected_version } => { reparent_shape(document, shape_id, parent, anchor, *expected_version) } @@ -226,6 +229,35 @@ pub fn patch_shape( }]) } +pub fn convert_shape( + document: &mut Document, shape_id: &ShapeId, kind: &str, properties: &ShapeProperties, + style: Option, expected: Option, +) -> Result, EngineError> { + let normalized_properties = normalize_shape_properties(kind, properties) + .map_err(|error| EngineError::Schema(format!("shape {shape_id}: {error}")))?; + let shape = shape_mut(document, shape_id, expected)?; + if shape.kind.as_str() == crate::CONTAINER_KIND || kind == crate::CONTAINER_KIND { + return Err(EngineError::Schema("container shapes cannot be converted".into())); + } + let mut inverse = Operation::ConvertShape { + shape_id: shape_id.clone(), + kind: shape.kind.to_string(), + properties: shape.properties.clone(), + style: style.map(|_| shape.style), + expected_version: None, + }; + shape.kind = kind.into(); + shape.properties = normalized_properties; + if let Some(value) = style { + shape.style = value; + } + shape.version = next_version(shape.version)?; + if let Operation::ConvertShape { expected_version, .. } = &mut inverse { + *expected_version = Some(shape.version); + } + Ok(vec![inverse]) +} + pub fn patch_asset( document: &mut Document, asset_id: &AssetId, patch: &AssetPatch, expected: Option, ) -> Result, EngineError> { diff --git a/crates/inkfinite-core/src/engine/tests.rs b/crates/inkfinite-core/src/engine/tests.rs index 55de9d6..8f30e0e 100644 --- a/crates/inkfinite-core/src/engine/tests.rs +++ b/crates/inkfinite-core/src/engine/tests.rs @@ -112,6 +112,44 @@ fn transaction(engine: &mut TransactionEngine, actor: &str, id: &str, operations } } +#[test] +fn conversion_changes_kind_without_losing_style_or_metadata() { + let mut engine = engine(); + let shape_id = ShapeId::from("shape:a"); + let before = engine.snapshot().unwrap().document.shapes[&shape_id].clone(); + let transaction = transaction( + &mut engine, + "actor:local", + "transaction:convert", + vec![Operation::ConvertShape { + shape_id: shape_id.clone(), + kind: crate::ELLIPSE_KIND.into(), + properties: ShapeProperties::from([ + ("width".into(), json!(20.0)), + ("height".into(), json!(12.0)), + ("fill".into(), json!("#fff")), + ]), + style: None, + expected_version: Some(before.version), + }], + ); + + engine.commit(transaction).unwrap(); + let converted = engine.snapshot().unwrap().document.shapes[&shape_id].clone(); + assert_eq!(converted.kind.as_str(), crate::ELLIPSE_KIND); + assert_eq!(converted.metadata, before.metadata); + assert_eq!(converted.style, before.style); + assert_eq!(converted.parent, before.parent); + assert_eq!(converted.transform, before.transform); + + engine.undo(&ActorId::from("actor:local")).unwrap(); + let restored = engine.snapshot().unwrap().document.shapes[&shape_id].clone(); + assert_eq!(restored.kind, before.kind); + assert_eq!(restored.properties, before.properties); + assert_eq!(restored.metadata, before.metadata); + assert_eq!(restored.style, before.style); +} + #[test] fn geometry_is_normalized_and_bounded_at_the_commit_boundary() { let mut engine = engine(); diff --git a/crates/inkfinite-core/src/lib.rs b/crates/inkfinite-core/src/lib.rs index e265a0e..b806feb 100644 --- a/crates/inkfinite-core/src/lib.rs +++ b/crates/inkfinite-core/src/lib.rs @@ -10,6 +10,7 @@ pub mod ipc; pub mod path; pub mod proto; pub mod render; +pub mod routing; pub mod session; pub mod svg_import; pub mod svg_transaction; diff --git a/crates/inkfinite-core/src/proto/mod.rs b/crates/inkfinite-core/src/proto/mod.rs index 8f55efd..5d034ce 100644 --- a/crates/inkfinite-core/src/proto/mod.rs +++ b/crates/inkfinite-core/src/proto/mod.rs @@ -220,6 +220,23 @@ pub enum Operation { /// Optional optimistic record version. expected_version: Option, }, + /// Change a shape's registry kind and kind-specific properties. + /// + /// Transform, common style, semantic metadata, hierarchy, and stable ID + /// remain attached to the existing shape record. + ConvertShape { + /// Shape to convert. + shape_id: ShapeId, + /// Replacement registry kind. + kind: String, + /// Replacement kind-specific properties. + #[ts(type = "ShapeProperties")] + properties: ShapeProperties, + /// Optional replacement common style. + style: Option, + /// Optional optimistic record version. + expected_version: Option, + }, /// Delete a shape and its owned descendants. DeleteShape { /// Shape to delete. diff --git a/crates/inkfinite-core/src/render/mod.rs b/crates/inkfinite-core/src/render/mod.rs index ee90c6b..1086120 100644 --- a/crates/inkfinite-core/src/render/mod.rs +++ b/crates/inkfinite-core/src/render/mod.rs @@ -11,6 +11,7 @@ use crate::engine::geometry::{ Affine, bounds_from_points, intersects, stroke_outline as canonical_stroke_outline, union, world_transform, }; use crate::proto::Bounds; +use crate::routing::obstacle_aware_orthogonal_route; use crate::{ AssetId, AssetSource, BindingAnchor, BuiltinShapeKind, Document, DocumentSnapshot, LayerId, PageId, PathFillRule, PathGeometry, PathSegment, PathSubpath, ShapeId, ShapeRecord, Vec2, @@ -355,7 +356,36 @@ impl Renderer<'_> { if routing.automatic { "orthogonal" } else { routing.kind.as_str() } }); if routing_kind == "orthogonal" { - points = orthogonal(points[0], points[points.len() - 1]); + let excluded_targets: BTreeSet<_> = self + .document + .bindings + .values() + .filter(|binding| binding.source_shape_id == shape.id) + .map(|binding| binding.target_shape_id.clone()) + .collect(); + let obstacles: Vec<_> = self + .document + .shapes + .values() + .filter(|candidate| { + candidate.id != shape.id + && !excluded_targets.contains(&candidate.id) + && !matches!( + BuiltinShapeKind::parse(candidate.kind.as_str()), + Some(BuiltinShapeKind::Arrow | BuiltinShapeKind::Line | BuiltinShapeKind::Container) + ) + }) + .filter_map(|candidate| render_shape_world_bounds(self.document, candidate).ok()) + .collect(); + let world_route = obstacle_aware_orthogonal_route( + matrix.point(points[0]), + matrix.point(points[points.len() - 1]), + &obstacles, + 12.0, + ); + if let Some(inverse) = inverse { + points = world_route.into_iter().map(|point| inverse.point(point)).collect(); + } } else if routing_kind == "curved" { points = curved(points.as_slice()); } @@ -921,14 +951,6 @@ fn binding_point(bounds: Bounds, anchor: BindingAnchor, arrow_width: f64) -> Vec } } -fn orthogonal(start: Vec2, end: Vec2) -> Vec { - if (end.x - start.x).abs() < 0.1 || (end.y - start.y).abs() < 0.1 { - return vec![start, end]; - } - let middle = start.x + (end.x - start.x) / 2.0; - vec![start, Vec2 { x: middle, y: start.y }, Vec2 { x: middle, y: end.y }, end] -} - fn curved(points: &[Vec2]) -> Vec { if points.len() < 3 { return points.to_vec(); diff --git a/crates/inkfinite-core/src/routing.rs b/crates/inkfinite-core/src/routing.rs new file mode 100644 index 0000000..15e92d6 --- /dev/null +++ b/crates/inkfinite-core/src/routing.rs @@ -0,0 +1,285 @@ +//! Deterministic orthogonal routing for connectors. +//! +//! Routing is intentionally based on axis-aligned obstacle bounds rather than +//! renderer-specific geometry. The browser editor mirrors this algorithm for +//! pointer previews, while the native renderer uses this implementation for +//! persisted documents and exports. + +use std::cmp::Ordering; + +use crate::Vec2; +use crate::proto::Bounds; + +const EPSILON: f64 = 1e-9; +const TURN_PENALTY: f64 = 12.0; + +/// Computes a deterministic Manhattan route between two connector endpoints. +/// +/// Obstacles are expanded by `padding` before the route is searched. The +/// returned path contains the endpoints and only horizontal or vertical +/// segments. When no detour is required, the route uses the same centered +/// elbow shape as the editor's previous orthogonal renderer. +#[must_use] +pub fn obstacle_aware_orthogonal_route(start: Vec2, end: Vec2, obstacles: &[Bounds], padding: f64) -> Vec { + if (start.x - end.x).abs() <= EPSILON && (start.y - end.y).abs() <= EPSILON { + return vec![start, end]; + } + + let padding = padding.max(0.0); + let obstacles: Vec = obstacles + .iter() + .filter_map(|obstacle| { + let width = obstacle.width.abs(); + let height = obstacle.height.abs(); + if width <= EPSILON && height <= EPSILON { + return None; + } + let min_x = obstacle.x.min(obstacle.x + obstacle.width) - padding; + let max_x = obstacle.x.max(obstacle.x + obstacle.width) + padding; + let min_y = obstacle.y.min(obstacle.y + obstacle.height) - padding; + let max_y = obstacle.y.max(obstacle.y + obstacle.height) + padding; + Some(Rect { min_x, max_x, min_y, max_y }) + }) + .collect(); + + let fallback = centered_orthogonal_route(start, end); + if obstacles.is_empty() || path_is_clear(&fallback, &obstacles) { + return fallback; + } + + let mut x_values = vec![start.x, end.x]; + let mut y_values = vec![start.y, end.y]; + for obstacle in &obstacles { + x_values.extend([obstacle.min_x, obstacle.max_x]); + y_values.extend([obstacle.min_y, obstacle.max_y]); + } + sort_unique(&mut x_values); + sort_unique(&mut y_values); + + let mut nodes = Vec::with_capacity(x_values.len() * y_values.len() + 2); + for x in &x_values { + for y in &y_values { + let point = Vec2 { x: *x, y: *y }; + if !point_is_inside(point, &obstacles) { + nodes.push(point); + } + } + } + let start_index = ensure_node(&mut nodes, start); + let end_index = ensure_node(&mut nodes, end); + + let mut states = vec![State::default(); nodes.len() * 3]; + states[start_index * 3 + 2].cost = 0.0; + let mut settled = vec![false; states.len()]; + + loop { + let Some(current_state) = states + .iter() + .enumerate() + .filter(|(index, state)| !settled[*index] && state.cost.is_finite()) + .min_by(|(_, left), (_, right)| { + left.cost + .partial_cmp(&right.cost) + .unwrap_or(Ordering::Equal) + .then_with(|| left.node.cmp(&right.node)) + .then_with(|| left.direction.cmp(&right.direction)) + }) + .map(|(index, _)| index) + else { + break; + }; + settled[current_state] = true; + let node_index = current_state / 3; + let direction = current_state % 3; + if node_index == end_index { + return simplify_route(reconstruct_path(&states, &nodes, current_state)); + } + + for (next_index, next) in nodes.iter().enumerate() { + if next_index == node_index || !axis_aligned(nodes[node_index], *next) { + continue; + } + if !segment_is_clear(nodes[node_index], *next, &obstacles) { + continue; + } + let next_direction = if (next.x - nodes[node_index].x).abs() > EPSILON { 0 } else { 1 }; + let next_state = next_index * 3 + next_direction; + let distance = manhattan(nodes[node_index], *next); + let turn = if direction < 2 && direction != next_direction { TURN_PENALTY } else { 0.0 }; + let cost = states[current_state].cost + distance + turn; + if cost + EPSILON < states[next_state].cost + || ((cost - states[next_state].cost).abs() <= EPSILON + && current_state < states[next_state].previous.unwrap_or(usize::MAX)) + { + states[next_state].cost = cost; + states[next_state].previous = Some(current_state); + states[next_state].node = next_index; + states[next_state].direction = next_direction; + } + } + } + + fallback +} + +/// Computes the centered two-elbow route used when no obstacle blocks it. +#[must_use] +pub fn centered_orthogonal_route(start: Vec2, end: Vec2) -> Vec { + if (end.x - start.x).abs() <= 0.1 || (end.y - start.y).abs() <= 0.1 { + return vec![start, end]; + } + let middle = start.x + (end.x - start.x) / 2.0; + vec![start, Vec2 { x: middle, y: start.y }, Vec2 { x: middle, y: end.y }, end] +} + +#[derive(Clone, Copy, Debug)] +struct Rect { + min_x: f64, + max_x: f64, + min_y: f64, + max_y: f64, +} + +#[derive(Clone, Copy, Debug)] +struct State { + cost: f64, + previous: Option, + node: usize, + direction: usize, +} + +impl Default for State { + fn default() -> Self { + Self { cost: f64::INFINITY, previous: None, node: 0, direction: 2 } + } +} + +fn sort_unique(values: &mut Vec) { + values.sort_by(|left, right| left.partial_cmp(right).unwrap_or(Ordering::Equal)); + values.dedup_by(|left, right| (*left - *right).abs() <= EPSILON); +} + +fn ensure_node(nodes: &mut Vec, point: Vec2) -> usize { + if let Some(index) = nodes.iter().position(|candidate| same_point(*candidate, point)) { + return index; + } + nodes.push(point); + nodes.len() - 1 +} + +fn same_point(left: Vec2, right: Vec2) -> bool { + (left.x - right.x).abs() <= EPSILON && (left.y - right.y).abs() <= EPSILON +} + +fn axis_aligned(left: Vec2, right: Vec2) -> bool { + (left.x - right.x).abs() <= EPSILON || (left.y - right.y).abs() <= EPSILON +} + +fn point_is_inside(point: Vec2, obstacles: &[Rect]) -> bool { + obstacles.iter().any(|obstacle| { + point.x > obstacle.min_x + EPSILON + && point.x < obstacle.max_x - EPSILON + && point.y > obstacle.min_y + EPSILON + && point.y < obstacle.max_y - EPSILON + }) +} + +fn segment_is_clear(start: Vec2, end: Vec2, obstacles: &[Rect]) -> bool { + if !axis_aligned(start, end) { + return false; + } + obstacles.iter().all(|obstacle| { + if (start.y - end.y).abs() <= EPSILON { + let y_inside = start.y > obstacle.min_y + EPSILON && start.y < obstacle.max_y - EPSILON; + !y_inside || !intervals_overlap(start.x, end.x, obstacle.min_x, obstacle.max_x) + } else { + let x_inside = start.x > obstacle.min_x + EPSILON && start.x < obstacle.max_x - EPSILON; + !x_inside || !intervals_overlap(start.y, end.y, obstacle.min_y, obstacle.max_y) + } + }) +} + +fn intervals_overlap(first_start: f64, first_end: f64, second_start: f64, second_end: f64) -> bool { + first_start.min(first_end) < second_end - EPSILON && first_start.max(first_end) > second_start + EPSILON +} + +fn path_is_clear(path: &[Vec2], obstacles: &[Rect]) -> bool { + path.windows(2) + .all(|segment| segment_is_clear(segment[0], segment[1], obstacles)) +} + +fn manhattan(left: Vec2, right: Vec2) -> f64 { + (left.x - right.x).abs() + (left.y - right.y).abs() +} + +fn reconstruct_path(states: &[State], nodes: &[Vec2], mut current: usize) -> Vec { + let mut path = Vec::new(); + loop { + path.push(nodes[states[current].node]); + let Some(previous) = states[current].previous else { break }; + current = previous; + } + path.reverse(); + path +} + +fn simplify_route(path: Vec) -> Vec { + let mut simplified = Vec::with_capacity(path.len()); + for point in path { + if let Some(previous) = simplified.last().copied() { + if same_point(previous, point) { + continue; + } + if simplified.len() >= 2 { + let before = simplified[simplified.len() - 2]; + if axis_aligned(before, point) && axis_aligned(point, previous) { + simplified.pop(); + } + } + } + simplified.push(point); + } + simplified +} + +#[cfg(test)] +mod tests { + use super::*; + + fn bounds(x: f64, y: f64, width: f64, height: f64) -> Bounds { + Bounds { x, y, width, height } + } + + #[test] + fn keeps_the_centered_route_when_no_obstacle_blocks_it() { + let route = obstacle_aware_orthogonal_route(Vec2 { x: 0.0, y: 0.0 }, Vec2 { x: 100.0, y: 100.0 }, &[], 8.0); + assert_eq!( + route, + centered_orthogonal_route(Vec2 { x: 0.0, y: 0.0 }, Vec2 { x: 100.0, y: 100.0 }) + ); + } + + #[test] + fn routes_around_a_blocking_obstacle() { + let route = obstacle_aware_orthogonal_route( + Vec2 { x: 0.0, y: 50.0 }, + Vec2 { x: 200.0, y: 50.0 }, + &[bounds(75.0, 25.0, 50.0, 50.0)], + 10.0, + ); + assert!(route.len() > 2); + assert!(path_is_clear( + &route, + &[Rect { min_x: 65.0, max_x: 135.0, min_y: 15.0, max_y: 85.0 }] + )); + assert_eq!( + route, + obstacle_aware_orthogonal_route( + Vec2 { x: 0.0, y: 50.0 }, + Vec2 { x: 200.0, y: 50.0 }, + &[bounds(75.0, 25.0, 50.0, 50.0)], + 10.0 + ) + ); + } +} diff --git a/crates/inkfinite-core/src/session.rs b/crates/inkfinite-core/src/session.rs index 49ed9b5..fdd3309 100644 --- a/crates/inkfinite-core/src/session.rs +++ b/crates/inkfinite-core/src/session.rs @@ -1265,6 +1265,14 @@ fn operation_preview( vec![RecordId::Shape(shape_id.clone())], vec![shape_id.clone()], ), + Operation::ConvertShape { shape_id, kind, .. } => ( + format!( + "Convert {} to {kind}", + shape_description_from_documents(shape_id, before, after) + ), + vec![RecordId::Shape(shape_id.clone())], + vec![shape_id.clone()], + ), Operation::ReparentShape { shape_id, .. } => ( format!("Move {}", shape_description_from_documents(shape_id, before, after)), vec![RecordId::Shape(shape_id.clone())], diff --git a/packages/bindings/src/editor.ts b/packages/bindings/src/editor.ts index 8c9ec23..f0505e6 100644 --- a/packages/bindings/src/editor.ts +++ b/packages/bindings/src/editor.ts @@ -446,6 +446,25 @@ export type EditorPatch = */ anchor: SiblingAnchor | null; } + | { + type: 'convert_shape'; + /** + * Shape to convert. + */ + shape_id: ShapeId; + /** + * Replacement registry kind. + */ + kind: ShapeKind; + /** + * Replacement editor properties. + */ + properties: ShapeProperties; + /** + * Optional replacement visual style. + */ + style: ShapeStyle | null; + } | { type: 'path_topology'; /** diff --git a/packages/bindings/src/transaction.ts b/packages/bindings/src/transaction.ts index 9bb3175..9fade2b 100644 --- a/packages/bindings/src/transaction.ts +++ b/packages/bindings/src/transaction.ts @@ -290,6 +290,29 @@ export type Operation = */ expected_version: RecordVersion | null; } + | { + type: 'convert_shape'; + /** + * Shape to convert. + */ + shape_id: ShapeId; + /** + * Replacement registry kind. + */ + kind: string; + /** + * Replacement kind-specific properties. + */ + properties: ShapeProperties; + /** + * Optional replacement common style. + */ + style: ShapeStyle | null; + /** + * Optional optimistic record version. + */ + expected_version: RecordVersion | null; + } | { type: 'delete_shape'; /** diff --git a/packages/core/src/export.ts b/packages/core/src/export.ts index 5d55031..e2a25da 100644 --- a/packages/core/src/export.ts +++ b/packages/core/src/export.ts @@ -1,11 +1,9 @@ import { - arrowPath, + arrowPathForShape, computePolylineLength, getPointAtDistance, localToWorld, - resolveArrowEndpoints, shapeBounds, - worldToLocal, } from "./geom"; import type { Box2 } from "./math"; import { Box2 as Box2Ops } from "./math"; @@ -231,11 +229,9 @@ function lineToSVG(shape: LineShape, transform: string): string { } function arrowToSVG(shape: ArrowShape, transform: string, state: EditorState): string { - const resolved = resolveArrowEndpoints(state, shape.id); - if (!resolved || shape.props.points.length < 2) return ""; - const endpoints = [worldToLocal(resolved.a, shape), ...shape.props.points.slice(1, -1), worldToLocal(resolved.b, shape)]; + const points = arrowPathForShape(state, shape); + if (points.length < 2) return ""; const routing = shape.props.routing?.automatic ? "orthogonal" : shape.props.routing?.kind ?? "straight"; - const points = arrowPath(endpoints, routing); const stroke = escapeXML(shape.props.style.stroke); const width = svgNumber(shape.props.style.width); const last = points.at(-1)!; @@ -249,7 +245,7 @@ function arrowToSVG(shape: ArrowShape, transform: string, state: EditorState): s return ``; }; const pathData = routing === "curved" - ? curvedPathData(endpoints) + ? curvedPathData(points) : points.map((point, index) => `${index === 0 ? "M" : "L"} ${svgNumber(point.x)} ${svgNumber(point.y)}`).join(" "); const elements = routing === "straight" ? points.slice(1).map((point, index) => ``) @@ -367,11 +363,9 @@ function escapeXML(string_: string): string { function exportBounds(state: EditorState, shape: ShapeRecord): Box2 { if (shape.type !== "arrow") return shapeBounds(shape); - const resolved = resolveArrowEndpoints(state, shape.id); - if (!resolved || shape.props.points.length < 2) return shapeBounds(shape); - const endpoints = [worldToLocal(resolved.a, shape), ...shape.props.points.slice(1, -1), worldToLocal(resolved.b, shape)]; - const routing = shape.props.routing?.automatic ? "orthogonal" : shape.props.routing?.kind ?? "straight"; - return Box2Ops.fromPoints(arrowPath(endpoints, routing).map((point) => localToWorld(shape, point))); + const points = arrowPathForShape(state, shape); + if (points.length < 2) return shapeBounds(shape); + return Box2Ops.fromPoints(points.map((point) => localToWorld(shape, point))); } function getExportSelection(state: EditorState): ShapeRecord[] { diff --git a/packages/core/src/geom.ts b/packages/core/src/geom.ts index 4ca5753..b038c82 100644 --- a/packages/core/src/geom.ts +++ b/packages/core/src/geom.ts @@ -20,7 +20,7 @@ import type { TextShape } from './model'; import type { EditorState } from './reactivity'; -import { getInteractiveShapesOnCurrentPage } from './reactivity'; +import { getInteractiveShapesOnCurrentPage, getShapesOnCurrentPage } from './reactivity'; const strokeOutlineCache = new WeakMap(); @@ -723,20 +723,7 @@ function hitTestShape(state: EditorState, shape: ShapeRecord, worldPoint: Vec2, case 'line': return pointNearLine(worldPoint, shape, tolerance); case 'arrow': { - const resolved = resolveArrowEndpoints(state, shape.id); - const points = resolved - ? arrowPath( - [ - worldToLocal(resolved.a, shape), - ...shape.props.points.slice(1, -1), - worldToLocal(resolved.b, shape) - ], - shape.props.routing?.automatic ? 'orthogonal' : (shape.props.routing?.kind ?? 'straight') - ) - : arrowPath( - shape.props.points, - shape.props.routing?.automatic ? 'orthogonal' : (shape.props.routing?.kind ?? 'straight') - ); + const points = arrowPathForShape(state, shape); const localPoint = worldToLocal(worldPoint, shape); return points.some( (point, index) => index > 0 && pointNearSegment(localPoint, points[index - 1], point, tolerance) @@ -889,6 +876,213 @@ export function computeOrthogonalPath(start: Vec2, end: Vec2): Vec2[] { return [start, { x: midX, y: start.y }, { x: midX, y: end.y }, end]; } +type RouteObstacle = { minX: number; maxX: number; minY: number; maxY: number }; +type RouteState = { cost: number; previous: number | null; node: number; direction: number }; +const ROUTE_EPSILON = 1e-9; +const ROUTE_TURN_PENALTY = 12; + +/** + * Compute a deterministic orthogonal route around axis-aligned obstacles. + * + * This is the browser preview counterpart to `inkfinite_core::routing`; keep + * its candidate grid, tie-breaking, and padding rules in sync with Rust. + */ +export function computeObstacleAwareOrthogonalPath( + start: Vec2, + end: Vec2, + obstacles: readonly Box2[], + padding = 12 +): Vec2[] { + if (Math.abs(start.x - end.x) <= ROUTE_EPSILON && Math.abs(start.y - end.y) <= ROUTE_EPSILON) { + return [start, end]; + } + const expanded = obstacles + .map((obstacle) => ({ + minX: Math.min(obstacle.min.x, obstacle.max.x) - Math.max(0, padding), + maxX: Math.max(obstacle.min.x, obstacle.max.x) + Math.max(0, padding), + minY: Math.min(obstacle.min.y, obstacle.max.y) - Math.max(0, padding), + maxY: Math.max(obstacle.min.y, obstacle.max.y) + Math.max(0, padding) + })) + .filter( + (obstacle) => obstacle.maxX - obstacle.minX > ROUTE_EPSILON || obstacle.maxY - obstacle.minY > ROUTE_EPSILON + ); + const fallback = computeOrthogonalPath(start, end); + if (expanded.length === 0 || routeIsClear(fallback, expanded)) return fallback; + + const xValues = uniqueSorted([start.x, end.x, ...expanded.flatMap((obstacle) => [obstacle.minX, obstacle.maxX])]); + const yValues = uniqueSorted([start.y, end.y, ...expanded.flatMap((obstacle) => [obstacle.minY, obstacle.maxY])]); + const nodes: Vec2[] = []; + for (const x of xValues) { + for (const y of yValues) { + const point = { x, y }; + if (!routePointInside(point, expanded)) nodes.push(point); + } + } + const startIndex = ensureRouteNode(nodes, start); + const endIndex = ensureRouteNode(nodes, end); + const states: RouteState[] = Array.from({ length: nodes.length * 3 }, () => ({ + cost: Number.POSITIVE_INFINITY, + previous: null, + node: 0, + direction: 2 + })); + states[startIndex * 3 + 2] = { cost: 0, previous: null, node: startIndex, direction: 2 }; + const settled = new Set(); + + while (true) { + let current = -1; + for (let index = 0; index < states.length; index += 1) { + const candidate = states[index]; + if (settled.has(index) || !Number.isFinite(candidate.cost)) continue; + if ( + current < 0 || + candidate.cost < states[current].cost - ROUTE_EPSILON || + (Math.abs(candidate.cost - states[current].cost) <= ROUTE_EPSILON && + (candidate.node < states[current].node || + (candidate.node === states[current].node && candidate.direction < states[current].direction))) + ) { + current = index; + } + } + if (current < 0) break; + settled.add(current); + const currentNode = states[current].node; + if (currentNode === endIndex) return simplifyRoute(reconstructRoute(states, nodes, current)); + for (let nextNode = 0; nextNode < nodes.length; nextNode += 1) { + if (nextNode === currentNode || !routeAxisAligned(nodes[currentNode], nodes[nextNode])) continue; + if (!routeSegmentIsClear(nodes[currentNode], nodes[nextNode], expanded)) continue; + const direction = Math.abs(nodes[nextNode].x - nodes[currentNode].x) > ROUTE_EPSILON ? 0 : 1; + const nextState = nextNode * 3 + direction; + const distance = + Math.abs(nodes[nextNode].x - nodes[currentNode].x) + Math.abs(nodes[nextNode].y - nodes[currentNode].y); + const turn = + states[current].direction < 2 && states[current].direction !== direction ? ROUTE_TURN_PENALTY : 0; + const cost = states[current].cost + distance + turn; + const existing = states[nextState]; + if ( + cost < existing.cost - ROUTE_EPSILON || + (Math.abs(cost - existing.cost) <= ROUTE_EPSILON && + current < (existing.previous ?? Number.POSITIVE_INFINITY)) + ) { + states[nextState] = { cost, previous: current, node: nextNode, direction }; + } + } + } + return fallback; +} + +/** Resolve an arrow's rendered path, including automatic obstacle routing. */ +export function arrowPathForShape(state: EditorState, shape: ArrowShape): Vec2[] { + const resolved = resolveArrowEndpoints(state, shape.id); + if (!resolved) return []; + const endpoints = [ + worldToLocal(resolved.a, shape), + ...shape.props.points.slice(1, -1), + worldToLocal(resolved.b, shape) + ]; + const routing = shape.props.routing?.automatic ? 'orthogonal' : (shape.props.routing?.kind ?? 'straight'); + if (routing !== 'orthogonal') return arrowPath(endpoints, routing); + const boundTargets = new Set( + Object.values(state.doc.bindings) + .filter((binding) => binding.fromShapeId === shape.id) + .map((binding) => binding.toShapeId) + ); + const obstacles = getShapesOnCurrentPage(state) + .filter( + (candidate) => + candidate.id !== shape.id && + candidate.type !== 'arrow' && + candidate.type !== 'line' && + candidate.type !== 'container' && + !boundTargets.has(candidate.id) + ) + .map(shapeBounds); + const worldPath = computeObstacleAwareOrthogonalPath(resolved.a, resolved.b, obstacles); + return worldPath.map((point) => worldToLocal(point, shape)); +} + +function uniqueSorted(values: number[]): number[] { + return values + .sort((left, right) => left - right) + .filter((value, index, all) => index === 0 || Math.abs(value - all[index - 1]!) > ROUTE_EPSILON); +} + +function ensureRouteNode(nodes: Vec2[], point: Vec2): number { + const index = nodes.findIndex( + (candidate) => + Math.abs(candidate.x - point.x) <= ROUTE_EPSILON && Math.abs(candidate.y - point.y) <= ROUTE_EPSILON + ); + if (index >= 0) return index; + nodes.push({ ...point }); + return nodes.length - 1; +} + +function routePointInside(point: Vec2, obstacles: readonly RouteObstacle[]): boolean { + return obstacles.some( + (obstacle) => + point.x > obstacle.minX + ROUTE_EPSILON && + point.x < obstacle.maxX - ROUTE_EPSILON && + point.y > obstacle.minY + ROUTE_EPSILON && + point.y < obstacle.maxY - ROUTE_EPSILON + ); +} + +function routeAxisAligned(left: Vec2, right: Vec2): boolean { + return Math.abs(left.x - right.x) <= ROUTE_EPSILON || Math.abs(left.y - right.y) <= ROUTE_EPSILON; +} + +function routeSegmentIsClear(start: Vec2, end: Vec2, obstacles: readonly RouteObstacle[]): boolean { + if (!routeAxisAligned(start, end)) return false; + return obstacles.every((obstacle) => { + if (Math.abs(start.y - end.y) <= ROUTE_EPSILON) { + const yInside = start.y > obstacle.minY + ROUTE_EPSILON && start.y < obstacle.maxY - ROUTE_EPSILON; + return !yInside || !routeIntervalsOverlap(start.x, end.x, obstacle.minX, obstacle.maxX); + } + const xInside = start.x > obstacle.minX + ROUTE_EPSILON && start.x < obstacle.maxX - ROUTE_EPSILON; + return !xInside || !routeIntervalsOverlap(start.y, end.y, obstacle.minY, obstacle.maxY); + }); +} + +function routeIntervalsOverlap(firstStart: number, firstEnd: number, secondStart: number, secondEnd: number): boolean { + return ( + Math.min(firstStart, firstEnd) < secondEnd - ROUTE_EPSILON && + Math.max(firstStart, firstEnd) > secondStart + ROUTE_EPSILON + ); +} + +function routeIsClear(path: readonly Vec2[], obstacles: readonly RouteObstacle[]): boolean { + return path.slice(1).every((point, index) => routeSegmentIsClear(path[index]!, point, obstacles)); +} + +function reconstructRoute(states: readonly RouteState[], nodes: readonly Vec2[], current: number): Vec2[] { + const path: Vec2[] = []; + let cursor: number | null = current; + while (cursor !== null) { + path.push(nodes[states[cursor]!.node]!); + cursor = states[cursor]!.previous; + } + return path.reverse(); +} + +function simplifyRoute(path: readonly Vec2[]): Vec2[] { + const result: Vec2[] = []; + for (const point of path) { + const previous = result.at(-1); + if ( + previous && + Math.abs(previous.x - point.x) <= ROUTE_EPSILON && + Math.abs(previous.y - point.y) <= ROUTE_EPSILON + ) + continue; + if (result.length >= 2) { + const before = result.at(-2)!; + if (routeAxisAligned(before, point) && routeAxisAligned(point, previous!)) result.pop(); + } + result.push({ ...point }); + } + return result; +} + /** Return a sampled quadratic path through arrow bend points. */ export function computeCurvedPath(points: readonly Vec2[], samplesPerCurve = 12): Vec2[] { if (points.length < 3) return points.map((point) => ({ ...point })); @@ -923,9 +1117,13 @@ export function computeCurvedPath(points: readonly Vec2[], samplesPerCurve = 12) } /** Resolve the local polyline used to draw an arrow. */ -export function arrowPath(points: readonly Vec2[], routing: 'straight' | 'curved' | 'orthogonal' = 'straight'): Vec2[] { +export function arrowPath( + points: readonly Vec2[], + routing: 'straight' | 'curved' | 'orthogonal' = 'straight', + obstacles: readonly Box2[] = [] +): Vec2[] { if (points.length < 2) return points.map((point) => ({ ...point })); - if (routing === 'orthogonal') return computeOrthogonalPath(points[0], points.at(-1)!); + if (routing === 'orthogonal') return computeObstacleAwareOrthogonalPath(points[0], points.at(-1)!, obstacles); if (routing === 'curved') return computeCurvedPath(points); return points.map((point) => ({ ...point })); } diff --git a/packages/core/src/persistence/canonical.ts b/packages/core/src/persistence/canonical.ts index 4bf21a2..7cadbac 100644 --- a/packages/core/src/persistence/canonical.ts +++ b/packages/core/src/persistence/canonical.ts @@ -578,7 +578,17 @@ function shapePatch( afterDocument: Document, skipDeletedLayerParent: boolean, skipProperties: boolean -): Extract | null { +): EditorPatch | null { + if (before.type !== after.type) { + const styleChanged = JSON.stringify(shapeStyle(before)) !== JSON.stringify(shapeStyle(after)); + return { + type: 'convert_shape', + shape_id: after.id, + kind: after.type, + properties: cloneProperties(after.props), + style: styleChanged ? shapeStyle(after) : null + }; + } const parentBefore = shapeParent(before, beforeDocument); const parentAfter = shapeParent(after, afterDocument); let transformChanged = diff --git a/packages/core/src/selection.ts b/packages/core/src/selection.ts index 503ea93..dbe28ed 100644 --- a/packages/core/src/selection.ts +++ b/packages/core/src/selection.ts @@ -7,6 +7,40 @@ export type DuplicateConnectOffset = { x: number; y: number }; type DuplicateResult = { state: EditorState; mapping: Map; roots: string[] }; +/** Shape kinds supported by the selection conversion command. */ +export type SelectionConversionTarget = 'rect' | 'ellipse'; + +/** + * Converts selected drawable shapes to a rectangle or ellipse in one editor + * state update. Common shape fields and transforms stay on the existing + * record; persistence turns the type change into one Rust conversion + * transaction. + */ +export function convertSelectedShapes(state: EditorState, target: SelectionConversionTarget): EditorState { + if (state.ui.selectionIds.length === 0) return state; + const hasBinding = (shapeId: string) => + Object.values(state.doc.bindings).some( + (binding) => binding.fromShapeId === shapeId || binding.toShapeId === shapeId + ); + const shapes = { ...state.doc.shapes }; + let changed = false; + for (const shapeId of state.ui.selectionIds) { + const shape = state.doc.shapes[shapeId]; + if ( + !shape || + (shape.type !== 'rect' && shape.type !== 'ellipse') || + shape.type === target || + hasBinding(shape.id) + ) + continue; + const { w, h, fill, stroke } = shape.props; + const props = target === 'rect' ? { w, h, fill, stroke, radius: 0 } : { w, h, fill, stroke }; + shapes[shape.id] = { ...shape, type: target, props } as ShapeRecordType; + changed = true; + } + return changed ? { ...state, doc: { ...state.doc, shapes } } : state; +} + /** * Duplicate the selected roots and their descendants. * diff --git a/packages/core/tests/arrow-label-routing.test.ts b/packages/core/tests/arrow-label-routing.test.ts index f62279f..93a155f 100644 --- a/packages/core/tests/arrow-label-routing.test.ts +++ b/packages/core/tests/arrow-label-routing.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { computePolylineLength, getPointAtDistance } from "../src/geom"; +import { + computeObstacleAwareOrthogonalPath, + computePolylineLength, + getPointAtDistance, +} from "../src/geom"; import type { ArrowShape } from "../src/model"; describe("Arrow label placement under zoom/pan", () => { @@ -123,6 +127,29 @@ describe("Arrow label placement under zoom/pan", () => { }); }); +describe("Obstacle-aware arrow routing", () => { + it("detours around a blocking obstacle deterministically", () => { + const obstacle = { min: { x: 75, y: 25 }, max: { x: 125, y: 75 } }; + const first = computeObstacleAwareOrthogonalPath( + { x: 0, y: 50 }, + { x: 200, y: 50 }, + [obstacle], + 10, + ); + const second = computeObstacleAwareOrthogonalPath( + { x: 0, y: 50 }, + { x: 200, y: 50 }, + [obstacle], + 10, + ); + + expect(first).toEqual(second); + expect(first.length).toBeGreaterThan(2); + expect(first.every((point, index) => index === 0 || point.x === first[index - 1].x || point.y === first[index - 1].y)).toBe(true); + expect(first.some((point) => point.y <= 15 || point.y >= 85)).toBe(true); + }); +}); + describe("Arrow routing toggle", () => { it("should default to straight routing", () => { const arrow: ArrowShape = { diff --git a/packages/core/tests/canonical.test.ts b/packages/core/tests/canonical.test.ts index dd69532..dbb1e95 100644 --- a/packages/core/tests/canonical.test.ts +++ b/packages/core/tests/canonical.test.ts @@ -64,6 +64,40 @@ describe('toCanonicalDocumentSnapshot', () => { }); }); + it('turns a shape kind change into a native conversion patch', () => { + const page = PageRecord.create('Page 1', 'page:one'); + const rect = ShapeRecord.createRect( + page.id, + 10, + 20, + { w: 40, h: 20, fill: 'red', stroke: 'none', radius: 4 }, + 'shape:rect' + ); + page.shapeIds.push(rect.id); + const before: Document = { pages: { [page.id]: page }, shapes: { [rect.id]: rect }, bindings: {} }; + const after: Document = { + ...before, + shapes: { [rect.id]: { ...rect, type: 'ellipse', props: { w: 40, h: 20, fill: 'red', stroke: 'none' } } } + }; + + const request = createEditorReconciliationRequest(before, after, { + actor_id: 'browser', + origin: 'human', + transaction_id: 'transaction:convert', + description: 'Convert rectangle', + timestamp: 1 + }); + + expect(request.patches).toEqual([ + expect.objectContaining({ + type: 'convert_shape', + shape_id: rect.id, + kind: 'ellipse', + properties: { w: 40, h: 20, fill: 'red', stroke: 'none' } + }) + ]); + }); + it('routes topology edits as canonical path patches', () => { const page = PageRecord.create('Page 1', 'page:one'); const props: PathProps = { diff --git a/packages/core/tests/selection-refinement.test.ts b/packages/core/tests/selection-refinement.test.ts index cc2f88d..1d3aa77 100644 --- a/packages/core/tests/selection-refinement.test.ts +++ b/packages/core/tests/selection-refinement.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { Action, Modifiers, PointerButtons } from '../src/actions'; -import { duplicateAndConnectSelection } from '../src/selection'; +import { convertSelectedShapes, duplicateAndConnectSelection } from '../src/selection'; import { PageRecord, ShapeRecord } from '../src/model'; import { EditorState } from '../src/reactivity'; import { RectTool, SelectTool } from '../src/tools'; @@ -199,6 +199,46 @@ describe('selection and movement refinements', () => { ); }); + it('converts a selection while preserving its common shape fields', () => { + const state = selectionState(); + const next = convertSelectedShapes(state, 'ellipse'); + const shape = next.doc.shapes['shape:one']; + expect(shape.type).toBe('ellipse'); + expect(shape).toMatchObject({ x: 0, y: 0, rot: 0 }); + if (shape.type !== 'ellipse') throw new Error('expected ellipse'); + expect(shape.props).toEqual({ w: 40, h: 30, fill: '', stroke: '' }); + }); + + it('does not convert shapes that participate in a connector binding', () => { + const state = selectionState() as EditorState; + const arrow = ShapeRecord.createArrow( + state.doc.pages['page:selection']!.id, + 0, + 0, + { + points: [ + { x: 0, y: 0 }, + { x: 100, y: 0 } + ], + start: { kind: 'free' }, + end: { kind: 'free' }, + style: { stroke: '#000', width: 2 } + }, + 'shape:arrow' + ); + state.doc.shapes[arrow.id] = arrow; + state.doc.bindings['binding:one'] = { + id: 'binding:one', + type: 'arrow-end', + fromShapeId: arrow.id, + toShapeId: 'shape:one', + handle: 'end', + anchor: { kind: 'center' } + }; + const next = convertSelectedShapes(state, 'ellipse'); + expect(next.doc.shapes['shape:one']?.type).toBe('rect'); + }); + it('uses Shift and Alt for square centered rectangle creation', () => { const tool = new RectTool(); const page = PageRecord.create('Draw page', 'page:draw'); diff --git a/packages/editor/src/renderer.ts b/packages/editor/src/renderer.ts index a4fee84..bcd74b6 100644 --- a/packages/editor/src/renderer.ts +++ b/packages/editor/src/renderer.ts @@ -17,7 +17,7 @@ import type { SnapGuide } from '@inkfinite/core'; import { - arrowPath, + arrowPathForShape, computePolylineLength, getPointAtDistance, getLayersOnCurrentPage, @@ -742,17 +742,8 @@ function drawArrow(context: CanvasRenderingContext2D, state: EditorState, shape: const style = shape.props.style; const shapeAlpha = context.globalAlpha; - const resolved = resolveArrowEndpoints(state, shape.id); - if (!resolved) return; - - const a = worldToLocal(resolved.a, shape); - const b = worldToLocal(resolved.b, shape); - - const endpoints = [a, ...shape.props.points.slice(1, -1), b]; - const points = arrowPath( - endpoints, - shape.props.routing?.automatic ? 'orthogonal' : (shape.props.routing?.kind ?? 'straight') - ); + const points = arrowPathForShape(state, shape); + if (points.length < 2) return; context.beginPath(); context.moveTo(points[0].x, points[0].y); diff --git a/packages/ui/src/lib/editor/canvas/Canvas.svelte b/packages/ui/src/lib/editor/canvas/Canvas.svelte index c7817c2..76d3008 100644 --- a/packages/ui/src/lib/editor/canvas/Canvas.svelte +++ b/packages/ui/src/lib/editor/canvas/Canvas.svelte @@ -485,6 +485,18 @@ shortcut: '⌥⌘/Ctrl D', disabled: selected.length === 0 }, + { + id: 'convert-to-rect', + label: SELECTION_COMMAND_LABELS['convert-to-rect'], + icon: 'rectangle', + disabled: selected.length === 0 + }, + { + id: 'convert-to-ellipse', + label: SELECTION_COMMAND_LABELS['convert-to-ellipse'], + icon: 'ellipse', + disabled: selected.length === 0 + }, { id: 'group', label: SELECTION_COMMAND_LABELS.group, diff --git a/packages/ui/src/lib/editor/commands.ts b/packages/ui/src/lib/editor/commands.ts index 3dd4fdf..c5e54c9 100644 --- a/packages/ui/src/lib/editor/commands.ts +++ b/packages/ui/src/lib/editor/commands.ts @@ -8,6 +8,8 @@ import { setShapesLocked, SnapshotCommand, ungroupShapes, + convertSelectedShapes, + type SelectionConversionTarget, type EditorState, type ShapeAlignment, type Store @@ -27,7 +29,8 @@ export type SelectionCommand = | 'lock' | 'unlock' | 'agent-editable' - | 'agent-readonly'; + | 'agent-readonly' + | `convert-to-${SelectionConversionTarget}`; /** User-facing names used for history entries and command menus. */ export const SELECTION_COMMAND_LABELS: Record = { @@ -48,7 +51,9 @@ export const SELECTION_COMMAND_LABELS: Record = { lock: 'Lock', unlock: 'Unlock', 'agent-editable': 'Allow Agent Edits', - 'agent-readonly': 'Prevent Agent Edits' + 'agent-readonly': 'Prevent Agent Edits', + 'convert-to-rect': 'Convert to Rectangle', + 'convert-to-ellipse': 'Convert to Ellipse' }; /** Applies one selection command without adding a history entry. */ @@ -75,6 +80,10 @@ export function applySelectionCommand(state: EditorState, command: SelectionComm return setShapesAgentEditable(state, ids, true); case 'agent-readonly': return setShapesAgentEditable(state, ids, false); + case 'convert-to-rect': + return convertSelectedShapes(state, 'rect'); + case 'convert-to-ellipse': + return convertSelectedShapes(state, 'ellipse'); case 'distribute-horizontal': return distributeShapes(state, ids, 'horizontal'); case 'distribute-vertical': @@ -158,6 +167,20 @@ export function getCommandPaletteEntries( keywords: 'copy connector arrow' }, ...alignmentEntries, + { + id: 'convert-to-rect', + label: SELECTION_COMMAND_LABELS['convert-to-rect'], + group: 'Selection', + disabled: selectedCount === 0, + keywords: 'shape convert rectangle' + }, + { + id: 'convert-to-ellipse', + label: SELECTION_COMMAND_LABELS['convert-to-ellipse'], + group: 'Selection', + disabled: selectedCount === 0, + keywords: 'shape convert ellipse oval' + }, { id: 'group', label: SELECTION_COMMAND_LABELS.group, diff --git a/packages/ui/src/lib/editor/components/SelectionControls.svelte b/packages/ui/src/lib/editor/components/SelectionControls.svelte index 7204aec..f65667a 100644 --- a/packages/ui/src/lib/editor/components/SelectionControls.svelte +++ b/packages/ui/src/lib/editor/components/SelectionControls.svelte @@ -377,6 +377,19 @@ disabled: !hasGroupedSelection }, { type: 'separator' }, + { + id: 'convert-to-rect', + label: SELECTION_COMMAND_LABELS['convert-to-rect'], + icon: 'rectangle', + disabled: selectionCount === 0 + }, + { + id: 'convert-to-ellipse', + label: SELECTION_COMMAND_LABELS['convert-to-ellipse'], + icon: 'ellipse', + disabled: selectionCount === 0 + }, + { type: 'separator' }, { id: 'forward', label: SELECTION_COMMAND_LABELS.forward, diff --git a/schemas/protocol-request.schema.json b/schemas/protocol-request.schema.json index b25d85c..5122d0a 100644 --- a/schemas/protocol-request.schema.json +++ b/schemas/protocol-request.schema.json @@ -868,6 +868,57 @@ ], "type": "object" }, + { + "description": "Change a shape's registry kind and kind-specific properties.\n\nTransform, common style, semantic metadata, hierarchy, and stable ID\nremain attached to the existing shape record.", + "properties": { + "expected_version": { + "anyOf": [ + { + "$ref": "#/$defs/RecordVersion" + }, + { + "type": "null" + } + ], + "description": "Optional optimistic record version." + }, + "kind": { + "description": "Replacement registry kind.", + "type": "string" + }, + "properties": { + "additionalProperties": true, + "description": "Replacement kind-specific properties.", + "type": "object" + }, + "shape_id": { + "$ref": "#/$defs/ShapeId", + "description": "Shape to convert." + }, + "style": { + "anyOf": [ + { + "$ref": "#/$defs/ShapeStyle" + }, + { + "type": "null" + } + ], + "description": "Optional replacement common style." + }, + "type": { + "const": "convert_shape", + "type": "string" + } + }, + "required": [ + "type", + "shape_id", + "kind", + "properties" + ], + "type": "object" + }, { "description": "Delete a shape and its owned descendants.", "properties": { diff --git a/schemas/protocol-response.schema.json b/schemas/protocol-response.schema.json index 7f2ebe6..4c07c11 100644 --- a/schemas/protocol-response.schema.json +++ b/schemas/protocol-response.schema.json @@ -1092,6 +1092,57 @@ ], "type": "object" }, + { + "description": "Change a shape's registry kind and kind-specific properties.\n\nTransform, common style, semantic metadata, hierarchy, and stable ID\nremain attached to the existing shape record.", + "properties": { + "expected_version": { + "anyOf": [ + { + "$ref": "#/$defs/RecordVersion" + }, + { + "type": "null" + } + ], + "description": "Optional optimistic record version." + }, + "kind": { + "description": "Replacement registry kind.", + "type": "string" + }, + "properties": { + "additionalProperties": true, + "description": "Replacement kind-specific properties.", + "type": "object" + }, + "shape_id": { + "$ref": "#/$defs/ShapeId", + "description": "Shape to convert." + }, + "style": { + "anyOf": [ + { + "$ref": "#/$defs/ShapeStyle" + }, + { + "type": "null" + } + ], + "description": "Optional replacement common style." + }, + "type": { + "const": "convert_shape", + "type": "string" + } + }, + "required": [ + "type", + "shape_id", + "kind", + "properties" + ], + "type": "object" + }, { "description": "Delete a shape and its owned descendants.", "properties": { diff --git a/schemas/transaction-draft.schema.json b/schemas/transaction-draft.schema.json index 9c06084..f66dd61 100644 --- a/schemas/transaction-draft.schema.json +++ b/schemas/transaction-draft.schema.json @@ -828,6 +828,57 @@ ], "type": "object" }, + { + "description": "Change a shape's registry kind and kind-specific properties.\n\nTransform, common style, semantic metadata, hierarchy, and stable ID\nremain attached to the existing shape record.", + "properties": { + "expected_version": { + "anyOf": [ + { + "$ref": "#/$defs/RecordVersion" + }, + { + "type": "null" + } + ], + "description": "Optional optimistic record version." + }, + "kind": { + "description": "Replacement registry kind.", + "type": "string" + }, + "properties": { + "additionalProperties": true, + "description": "Replacement kind-specific properties.", + "type": "object" + }, + "shape_id": { + "$ref": "#/$defs/ShapeId", + "description": "Shape to convert." + }, + "style": { + "anyOf": [ + { + "$ref": "#/$defs/ShapeStyle" + }, + { + "type": "null" + } + ], + "description": "Optional replacement common style." + }, + "type": { + "const": "convert_shape", + "type": "string" + } + }, + "required": [ + "type", + "shape_id", + "kind", + "properties" + ], + "type": "object" + }, { "description": "Delete a shape and its owned descendants.", "properties": { -- 2.51.2