From 71fa9baa5bba7e2a83f2e546ba55b2d79d96d351 Mon Sep 17 00:00:00 2001 From: Kayshen-X Date: Fri, 19 Jun 2026 23:39:02 +0800 Subject: [PATCH] refactor(editor-ui): re-export scene from jian-scene/op-editor-core Delete the moved modules (layout_scene, layout_scene_hit, scene_vars + their tests); shim re-export jian_scene::{layout_scene,layout_scene_hit} + op_editor_core::scene_vars so every consumer path (incl. dirty hosts) resolves unchanged. Paint imports path geometry from jian-scene. 647 tests green. --- crates/op-editor-ui/Cargo.toml | 7 + crates/op-editor-ui/src/layout_scene.rs | 741 ------------------ crates/op-editor-ui/src/layout_scene_hit.rs | 471 ----------- .../src/layout_scene_unit_tests.rs | 184 ----- crates/op-editor-ui/src/lib.rs | 9 +- crates/op-editor-ui/src/scene_vars.rs | 515 ------------ crates/op-editor-ui/src/scene_vars_tests.rs | 613 --------------- .../src/widgets/canvas_path_overlay.rs | 2 +- .../src/widgets/canvas_viewport_paint.rs | 75 +- .../widgets/canvas_viewport_paint_tests.rs | 5 +- 10 files changed, 18 insertions(+), 2604 deletions(-) delete mode 100644 crates/op-editor-ui/src/layout_scene.rs delete mode 100644 crates/op-editor-ui/src/layout_scene_hit.rs delete mode 100644 crates/op-editor-ui/src/layout_scene_unit_tests.rs delete mode 100644 crates/op-editor-ui/src/scene_vars.rs delete mode 100644 crates/op-editor-ui/src/scene_vars_tests.rs diff --git a/crates/op-editor-ui/Cargo.toml b/crates/op-editor-ui/Cargo.toml index b626c675d..6251f2e03 100644 --- a/crates/op-editor-ui/Cargo.toml +++ b/crates/op-editor-ui/Cargo.toml @@ -22,6 +22,8 @@ accesskit = "0.24" jian-core = { path = "../../vendor/jian/crates/jian-core", version = "0.0.1" } jian-ops-schema = { path = "../../vendor/jian/crates/jian-ops-schema" } jian-widgets = { path = "../../vendor/jian/crates/jian-widgets", version = "0.0.1" } +# Canonical render scene (LayoutScene / hit-test / path geometry). +jian-scene = { path = "../../vendor/jian/crates/jian-scene", version = "0.0.1" } # i18n string tables + the canonical editor state model. op-i18n = { path = "../op-i18n" } @@ -34,3 +36,8 @@ op-editor-core = { path = "../op-editor-core" } base64 = "0.22" serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } + +[dev-dependencies] +# Enable jian-scene's find-visit instrumentation so the canvas-viewport +# paint tests can assert scene-traversal visit counts. +jian-scene = { path = "../../vendor/jian/crates/jian-scene", features = ["test-support"] } diff --git a/crates/op-editor-ui/src/layout_scene.rs b/crates/op-editor-ui/src/layout_scene.rs deleted file mode 100644 index e66021f51..000000000 --- a/crates/op-editor-ui/src/layout_scene.rs +++ /dev/null @@ -1,741 +0,0 @@ -//! `LayoutScene` — a paint-only, layout-resolved render scene. -//! -//! This is the migration target for `CanvasViewport`: a tree of -//! resolved render nodes that a painter can walk and reproduce the -//! current canvas pixel-for-pixel, WITHOUT depending on the editor's -//! `Document` (which mixes in selection / chat / history / UI state). -//! -//! Distinctions from [`op_editor_core::EditorState`]: -//! -//! - **No editor state.** `LayoutScene` carries no `selected`, -//! `tool`, `viewport`, `chat`, `history`, `components`, `ui`. The -//! selection overlay + grid + viewport transform are the host -//! painter's concern, layered on top of the scene. -//! - **Layout-resolved geometry.** Every [`SceneNode::bounds`] is the -//! absolute doc-space AABB produced by jian's taffy `LayoutEngine` — -//! NOT the authored `(x, y, w, h)`. A future painter draws straight -//! from `bounds` with no second layout pass. -//! - **Fills are concrete.** Variable `$ref` fills / strokes are -//! resolved against the editor's variables + active theme at build -//! time, so [`SceneNode::fill`] is always a final paintable colour. -//! -//! The builder lives in `op-pen-loader` -//! (`editor_state_to_layout_scene`); nothing consumes `LayoutScene` -//! yet — `CanvasViewport` is flipped onto it in a later step. -//! -//! wasm32-clean: only `crate::{Color, Point2D, Rect}` + the scene's -//! own paint enums (`NodeKind` / `Effect`). The web host builds -//! scenes too. - -use crate::{Color, Point2D, Rect}; -#[cfg(test)] -use std::cell::Cell; - -#[cfg(test)] -thread_local! { - static FIND_VISIT_COUNT: Cell = const { Cell::new(0) }; -} - -#[cfg(test)] -pub(crate) fn reset_find_visit_count() { - FIND_VISIT_COUNT.with(|count| count.set(0)); -} - -#[cfg(test)] -pub(crate) fn find_visit_count() -> usize { - FIND_VISIT_COUNT.with(Cell::get) -} - -#[cfg(test)] -fn record_find_visit() { - FIND_VISIT_COUNT.with(|count| count.set(count.get() + 1)); -} - -/// Node kinds the canvas painter draws. `Other` round-trips unknown -/// kinds so an unfamiliar serialized node never errors the painter. -/// -/// This is a paint-time scene enum — it lives with [`LayoutScene`] -/// because the resolved render tree is its only consumer. It is -/// deliberately NOT the canonical `jian_ops_schema` node model; the -/// scene builder maps `PenNode` variants onto it. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum NodeKind { - Frame, - Group, - Rect, - Ellipse, - Polygon, - Line, - /// Pen-tool polyline. Geometry in `SceneNode.points`. - Path, - Text, - Other(String), -} - -impl NodeKind { - /// Human-facing label for LayerPanel + PropertyPanel. - pub fn label(&self) -> &str { - match self { - NodeKind::Frame => "Frame", - NodeKind::Group => "Group", - NodeKind::Rect => "Rect", - NodeKind::Ellipse => "Ellipse", - NodeKind::Polygon => "Polygon", - NodeKind::Line => "Line", - NodeKind::Path => "Path", - NodeKind::Text => "Text", - NodeKind::Other(s) => s.as_str(), - } - } -} - -/// Shadow effect — offset + blur + colour, doc-px units. Mirrors the -/// TS `PenEffect` shadow variant (`offsetX` / `offsetY` / `blur` / -/// `color` / `inner`). An outer shadow paints behind the node's fill; -/// an `inner` shadow paints inset, clipped to the node silhouette. -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct DropShadow { - pub offset_x: f32, - pub offset_y: f32, - pub blur: f32, - pub color: Color, - /// `true` = inset (inner) shadow; `false` = outer drop shadow. - pub inner: bool, -} - -/// One resolved gradient stop — offset 0.0..=1.0 plus the stop's -/// concrete colour (already hex-parsed and `$ref`-resolved by the -/// scene builder). -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct SceneGradientStop { - pub offset: f32, - pub color: Color, -} - -/// A paintable gradient — either linear (canonical `.op` angle in -/// degrees, where 0° = bottom→top, 90° = left→right, 180° = top→bottom -/// — i.e. CSS `to-top` convention; the renderer subtracts 90° before -/// projecting endpoints) or radial (centre + radius as 0.0..=1.0 -/// fractions of the node's bounds, with radius scaled against -/// `max(w, h)` for parity with the TS `pen-renderer`). Carries the -/// per-fill `opacity` so the painter can fold it into every stop's -/// alpha without mutating the stops themselves. -#[derive(Debug, Clone, PartialEq)] -pub enum SceneGradient { - Linear { - /// Canonical `.op` angle (degrees, 0° = bottom→top). - angle_deg: f32, - opacity: f32, - stops: Vec, - }, - Radial { - /// Centre x as a 0.0..=1.0 fraction of bounds width (0.5 = centre). - cx: f32, - /// Centre y as a 0.0..=1.0 fraction of bounds height. - cy: f32, - /// Outer radius as a 0.0..=1.0 fraction of `max(w, h)` — matches - /// `pen-renderer/src/node-renderer.ts::node_renderer` so `.op` - /// files import / export at the same radial size as the TS app. - radius: f32, - opacity: f32, - stops: Vec, - }, -} - -/// A visual effect painted with a [`SceneNode`]. v1 ships drop -/// shadow (what the property panel's effects section needs). -#[derive(Debug, Clone, Copy, PartialEq)] -pub enum Effect { - DropShadow(DropShadow), -} - -/// A paint-only, layout-resolved render scene. -/// -/// Built from an `op_editor_core::EditorState` by running jian's flex -/// layout pass and resolving variable `$ref` colours. Carries the -/// multi-page structure the canvas lays out plus, per page, the -/// resolved render-node tree. -#[derive(Debug, Clone, Default, PartialEq)] -pub struct LayoutScene { - /// Pages, in document order. The canvas paints one page at a time - /// (`active_page_index`), but every page is resolved so page - /// switches don't need a rebuild. - pub pages: Vec, - /// Index into `pages` of the page the editor currently shows. - /// Clamped into range by the builder. - pub active_page_index: usize, -} - -impl LayoutScene { - /// The page the editor currently shows, or `None` when the scene - /// has no pages. - pub fn active_page(&self) -> Option<&ScenePage> { - self.pages.get(self.active_page_index) - } - - /// Union AABB of every top-level node on the active page, in - /// document space — the extent a "zoom to fit" frames. `None` - /// when the page is empty (nothing to fit). - pub fn content_bounds(&self) -> Option { - let page = self.active_page()?; - let mut acc: Option = None; - for child in &page.children { - let b = child.aggregate_bounds(); - if b.size.x <= 0.0 && b.size.y <= 0.0 { - continue; - } - acc = Some(match acc { - None => b, - Some(a) => { - let min_x = a.origin.x.min(b.origin.x); - let min_y = a.origin.y.min(b.origin.y); - let max_x = (a.origin.x + a.size.x).max(b.origin.x + b.size.x); - let max_y = (a.origin.y + a.size.y).max(b.origin.y + b.size.y); - Rect { - origin: Point2D::new(min_x, min_y), - size: Point2D::new(max_x - min_x, max_y - min_y), - } - } - }); - } - acc - } - - /// Translate selected render subtrees in-place without rebuilding the - /// full layout scene. Used by live node drags: the canonical - /// `EditorState` is still updated first, but simple absolute moves can - /// keep paint in sync by patching the current scene until release-time - /// layout reconciliation. - pub fn translate_nodes(&mut self, ids: &[String], dx: f32, dy: f32) -> bool { - if ids.is_empty() || (dx == 0.0 && dy == 0.0) { - return false; - } - let Some(page) = self.pages.get_mut(self.active_page_index) else { - return false; - }; - page.translate_nodes(ids, dx, dy) - } -} - -impl ScenePage { - /// Depth-first search for the node with `id` anywhere in this - /// page's render tree. Mirrors `ScenePage::find` so the - /// selection-overlay + pen-rubber-band painters can map an editor - /// selection id onto the resolved scene node. - pub fn find(&self, id: &str) -> Option<&SceneNode> { - for child in &self.children { - if let Some(found) = child.find(id) { - return Some(found); - } - } - None - } - - fn translate_nodes(&mut self, ids: &[String], dx: f32, dy: f32) -> bool { - translate_matching_nodes(&mut self.children, ids, dx, dy) - } -} - -fn translate_matching_nodes(nodes: &mut [SceneNode], ids: &[String], dx: f32, dy: f32) -> bool { - let mut changed = false; - for node in nodes { - if ids.iter().any(|id| id == &node.id) { - node.translate_subtree(dx, dy); - changed = true; - } else if translate_matching_nodes(&mut node.children, ids, dx, dy) { - changed = true; - } - } - changed -} - -/// One resolved page — an artboard / page id + name + the top-level -/// resolved render nodes. -#[derive(Debug, Clone, PartialEq)] -pub struct ScenePage { - /// Page id (the `.op` page id, or `"page-1"` for the single-page - /// fallback). Identity only — the painter does not key off it. - pub id: String, - /// Page name — surfaced by the layer panel, not painted on canvas. - pub name: String, - /// Top-level resolved render nodes for this page. - pub children: Vec, -} - -/// A resolved render node — everything the canvas painter reads to -/// draw one node, with geometry already baked by the layout pass and -/// fills already resolved to concrete colours. -/// -/// Mirrors the fields `CanvasViewport`'s painter reads off -/// `SceneNode` today (`kind`, `bounds`, `fill`, `stroke`, -/// `rotation`, `corner_radius`, `text`, `font_size`, `font_weight`, -/// `text_wrap`, `points`, `effects`, `children`, `hidden`) so a -/// painter over `LayoutScene` can reproduce the current canvas -/// pixel-for-pixel. -/// How a path anchor's two control handles relate. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ScenePointType { - /// Handles independent; no smoothing across the anchor. - Corner, - /// Handles collinear + equal length. - Mirrored, - /// Handles move freely + independently. - Independent, -} - -/// A path bezier anchor resolved into absolute doc coords — the -/// anchor point plus its (optional) incoming / outgoing control -/// handles. Handle positions are absolute, not anchor-relative. -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct SceneAnchor { - pub pos: Point2D, - pub handle_in: Option, - pub handle_out: Option, - pub point_type: ScenePointType, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum SceneTextAlign { - Left, - Center, - Right, - Justify, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum SceneTextVerticalAlign { - Top, - Middle, - Bottom, -} - -/// One styled run of a Text node's flattened content — a byte range -/// into [`SceneNode::text`] plus the per-segment style overrides the -/// canonical schema's `TextContent::Styled` segments carry. Inherited -/// (unset) attributes use the sentinel conventions of the node-level -/// fields (`0.0` font size / `0` weight / `None` fill = inherit). -/// -/// Runs exist ALONGSIDE the flat string: line wrapping, the text -/// editor, and hit-testing all stay on the flat text; the painter maps -/// each wrapped line back onto run byte ranges so painted slices carry -/// their segment's style. A wrapped line that splits mid-segment keeps -/// per-slice styling exact; only the WRAP POSITION itself is computed -/// with node-level metrics (documented v1 approximation). -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct SceneTextRun { - /// Inclusive start byte offset into the node's flat `text`. - pub start: usize, - /// Exclusive end byte offset into the node's flat `text`. - pub end: usize, - /// Per-run font size in doc-px. `0.0` = inherit the node's. - pub font_size: f32, - /// Per-run CSS-style weight (100-900). `0` = inherit the node's. - pub font_weight: u16, - /// Per-run fill colour. `None` = inherit the node's resolved fill. - pub fill: Option, - /// Render this run with an italic (slanted) face. - pub italic: bool, - /// Stroke an underline beneath this run's painted slices. - pub underline: bool, - /// Stroke a strikethrough across this run's painted slices. - pub strikethrough: bool, -} - -#[derive(Debug, Clone, PartialEq)] -pub struct SceneNode { - /// Stable node id (the `.op` schema id). Identity for hit-test / - /// selection mapping done by the host on top of the scene. - pub id: String, - /// Node kind — drives per-kind paint (Frame fill, Ellipse oval, - /// Polygon triangle, Line diagonal, Path polyline, Text run, …). - /// `Other("icon_font")` carries a lucide glyph name in `text`. - pub kind: NodeKind, - /// Layout-resolved absolute doc-space rect. Already offset by the - /// page-root's authored `(x, y)` — paint applies only the - /// viewport transform. - pub bounds: Rect, - /// Precomputed aggregate subtree bounds for unbounded containers. - /// `Rect::ZERO` means "not precomputed" so hand-built tests and - /// helpers that mutate `children` after [`SceneNode::leaf`] keep - /// the existing recursive behaviour. The loader fills this once, - /// bottom up, so hot paint / hit-test paths do not repeatedly - /// union large subtrees every frame. - pub aggregate_bounds_cache: Rect, - /// Cumulative node opacity (0.0..=1.0), already multiplied down - /// the subtree. Fill / stroke / gradient / shadow colours have it - /// baked into their alpha at scene-build; the painter applies it - /// directly only for raster images (which carry no colour to - /// bake into). 1.0 = fully opaque. - pub opacity: f32, - /// Rotation in radians, clockwise about the node's bounds centre. - pub rotation: f32, - /// Mirror horizontally around the node's aggregate-bounds centre. - pub flip_x: bool, - /// Mirror vertically around the node's aggregate-bounds centre. - pub flip_y: bool, - /// Corner radius in doc-px — honoured by Rect / Frame paint. - pub corner_radius: f32, - /// Whether this container clips its children to its bounds - /// (rounded by `corner_radius`, clamped to half the height — TS - /// flattener rule). The scene builder also sets this for root - /// frames, which clip like artboards even without an authored - /// `clipContent: true` (TS `document-flattener.ts` parity). - pub clip_content: bool, - /// Resolved fill colour. `$ref` variable fills are already - /// resolved against the editor's variables + active theme; a - /// gradient keeps its first stop here (parity with the current - /// canvas, which paints the first solid colour). `None` = no fill. - pub fill: Option, - /// Fill paint mode — `Solid` / `LinearGradient` / `RadialGradient` - /// / `Image`. The current canvas paints all of them as the solid - /// `fill` colour; carried so a richer painter can branch later. - pub fill_type: SceneFillType, - /// Resolved gradient body — populated when `fill_type` is - /// `LinearGradient` or `RadialGradient`. `fill` still holds the - /// first stop's colour so non-gradient code paths (Polygon / - /// Path / Ellipse / Line painters that have no gradient overload - /// yet) keep a sensible fallback. - pub gradient: Option, - /// Resolved stroke (colour + width). `$ref` stroke colours are - /// resolved at build time. `None` = no stroke. - pub stroke: Option, - /// Text content — `Some` for Text nodes (and the lucide glyph - /// name for `icon_font`). `None` for non-text kinds. Styled text - /// is flattened into this string; per-segment styling rides in - /// `text_runs`. - pub text: Option, - /// Per-segment style runs over `text` (byte ranges). Empty for - /// plain (single-style) text — the painter then uses the - /// node-level style for the whole content. - pub text_runs: Vec, - /// CSS font-family stack for text nodes. Empty = renderer default. - pub font_family: String, - /// Text size in doc-px. `0.0` = the painter's default (13 px). - pub font_size: f32, - /// CSS-style font weight (100-900). `0` = default (400). - pub font_weight: u16, - /// Node-level italic (`fontStyle: italic`). Per-run overrides ride - /// in `text_runs`. - pub italic: bool, - /// Node-level underline decoration. - pub underline: bool, - /// Node-level strikethrough decoration. - pub strikethrough: bool, - /// Line-height multiplier (`1.2` = 120%). `0.0` = renderer default. - pub line_height: f32, - /// Extra tracking in doc-px between glyphs. - pub letter_spacing: f32, - /// Horizontal text alignment inside `bounds`. - pub text_align: SceneTextAlign, - /// Vertical text alignment inside `bounds`. - pub text_vertical_align: SceneTextVerticalAlign, - /// Whether the painter wraps the text to `bounds.size.x`. - pub text_wrap: bool, - /// Polyline / path geometry in absolute doc-space coords — - /// populated for `Path` (and any kind the painter walks as - /// points). Empty otherwise. - pub points: Vec, - /// Bezier anchors for `Path` nodes — parallel to `points` but - /// carrying the editable control handles + point type. Empty for - /// non-Path kinds. - pub path_anchors: Vec, - /// Whether a `Path` node is closed (last anchor links to first). - pub path_closed: bool, - /// Preserved SVG path data for imported Path nodes. Coordinates - /// are local doc-px relative to `bounds.origin`. - pub svg_path: Option, - /// Ellipse arc start angle in degrees. `None` = full ellipse. - pub arc_start_angle: Option, - /// Ellipse arc sweep angle in degrees. `None` = full ellipse. - pub arc_sweep_angle: Option, - /// Ellipse donut-hole radius (0.0..=1.0 fraction). `None` / 0 = - /// solid. - pub arc_inner_radius: Option, - /// Polygon side count. `3` is the canonical triangle default. - pub polygon_sides: u32, - /// Image source for nodes that paint a bitmap (`PenNode::Image`). - /// Carries the canonical schema's `src` field verbatim — usually - /// a `data:image/...;base64,...` URL produced by the host's file - /// picker, or a plain file path / remote URL on documents that - /// reference external media. `None` for non-image nodes. - pub image_src: Option, - /// Stable content hash for `image_src`. The canvas painter uses it - /// as a per-frame cache key without hashing large data URLs again. - pub image_src_id: u64, - /// How `image_src` is placed into `bounds`. - pub image_fit: SceneImageFit, - /// Per-image colour adjustments from the image-fill editor. - pub image_adjustments: crate::ImageAdjustments, - /// Drop-shadow / effects painted behind the node's fill. - pub effects: Vec, - /// Whether the node (and its subtree) is hidden — the painter - /// skips hidden nodes entirely. - pub hidden: bool, - /// Whether the node is locked. Paint ignores this; the host's - /// canvas hit-test reads it so a locked node's body opts out of - /// selection while its children stay hittable (parity with the - /// `Document`-bound `hit_test_walk`). - pub locked: bool, - /// Composite-widget descriptor for the interactive node family - /// (switch / checkbox / slider / progress / select / radio_group / - /// text_input / text_area / number_input / tabs). When set, the - /// design-surface painter draws a recognizable static visual - /// (track + knob, box + check, chevron, bar, …) instead of the - /// bare degraded rect / text. `None` for ordinary shapes. - pub widget: Option, - /// Child render nodes, in paint order. - pub children: Vec, -} - -/// Static composite-widget props carried on a [`SceneNode`]. Mirrors -/// `op_pen_loader::WidgetPayload`; the canvas painter reads it to draw -/// the non-interactive design-time visual that matches the widget's -/// preview-mode rendering (jian-core's `emit_widget_visual`). Numeric -/// sentinels fall back to the runtime defaults (`min=0`, `max=100`). -#[derive(Debug, Clone, Default, PartialEq)] -pub struct SceneWidget { - /// Widget family tag (`switch` / `checkbox` / `slider` / `progress` - /// / `select` / `radio_group` / `text_input` / `text_area` / - /// `number_input` / `tabs`). - pub kind: String, - /// On/off state for switch / checkbox. - pub checked: Option, - /// Numeric value for slider / progress / number_input. - pub value_num: Option, - /// String value for select / radio_group / tabs / text inputs. - pub value_str: Option, - /// Placeholder text for inputs / select. - pub placeholder: Option, - /// Leading / trailing lucide glyph for input widgets (Phase 1). The - /// painter draws the icon and insets the text/caret past it. - pub leading_icon: Option, - pub trailing_icon: Option, - /// Adjacent label (checkbox). - pub label: Option, - /// Range minimum (slider / number_input). `None` = 0. - pub min: Option, - /// Range maximum (slider / progress / number_input). `None` = 100. - pub max: Option, - /// Range step. `None` = 1. - pub step: Option, - /// `(value, label)` rows for select / radio_group / tabs. - pub options: Vec, -} - -/// One `value` + `label` option for select / radio_group / tabs. -#[derive(Debug, Clone, Default, PartialEq)] -pub struct SceneWidgetOption { - pub value: String, - pub label: String, -} - -impl SceneNode { - /// Depth-first search for the node with `id` in this subtree - /// (self included). Mirrors `SceneNode::find`. - pub fn find(&self, id: &str) -> Option<&SceneNode> { - #[cfg(test)] - record_find_visit(); - - if self.id == id { - return Some(self); - } - for child in &self.children { - if let Some(found) = child.find(id) { - return Some(found); - } - } - None - } - - fn translate_subtree(&mut self, dx: f32, dy: f32) { - self.bounds.origin.x += dx; - self.bounds.origin.y += dy; - if rect_has_extent(self.aggregate_bounds_cache) { - self.aggregate_bounds_cache.origin.x += dx; - self.aggregate_bounds_cache.origin.y += dy; - } - for point in &mut self.points { - point.x += dx; - point.y += dy; - } - for anchor in &mut self.path_anchors { - anchor.pos.x += dx; - anchor.pos.y += dy; - if let Some(handle) = anchor.handle_in.as_mut() { - handle.x += dx; - handle.y += dy; - } - if let Some(handle) = anchor.handle_out.as_mut() { - handle.x += dx; - handle.y += dy; - } - } - for child in &mut self.children { - child.translate_subtree(dx, dy); - } - } - - /// Resolved bounds for selection / rotation-pivot math: the node's - /// own `bounds` when it is bounded, otherwise a loader-precomputed - /// subtree rect when available, otherwise the union of children's - /// aggregate bounds for hand-built scenes. - pub fn aggregate_bounds(&self) -> Rect { - if rect_has_extent(self.bounds) { - return self.bounds; - } - if rect_has_extent(self.aggregate_bounds_cache) { - return self.aggregate_bounds_cache; - } - Self::compute_aggregate_bounds(self.bounds, &self.children) - } - - /// Compute the aggregate subtree rect from resolved own bounds and - /// children. Used by the loader during scene construction and by - /// [`aggregate_bounds`](Self::aggregate_bounds) as the uncached - /// fallback for hand-built test scenes. - pub fn compute_aggregate_bounds(bounds: Rect, children: &[SceneNode]) -> Rect { - if rect_has_extent(bounds) { - return bounds; - } - let mut iter = children - .iter() - .map(SceneNode::aggregate_bounds) - .filter(|r| rect_has_extent(*r)); - let Some(first) = iter.next() else { - return Rect::ZERO; - }; - let (mut min_x, mut min_y) = (first.origin.x, first.origin.y); - let (mut max_x, mut max_y) = (first.origin.x + first.size.x, first.origin.y + first.size.y); - for r in iter { - min_x = min_x.min(r.origin.x); - min_y = min_y.min(r.origin.y); - max_x = max_x.max(r.origin.x + r.size.x); - max_y = max_y.max(r.origin.y + r.size.y); - } - Rect::xywh(min_x, min_y, max_x - min_x, max_y - min_y) - } - - /// Construct a leaf render node with all paint fields cleared. - /// Builders set `bounds` / `fill` / `text` / … after. - pub fn leaf(id: impl Into, kind: NodeKind) -> Self { - Self { - id: id.into(), - kind, - bounds: Rect::ZERO, - aggregate_bounds_cache: Rect::ZERO, - opacity: 1.0, - rotation: 0.0, - flip_x: false, - flip_y: false, - corner_radius: 0.0, - clip_content: false, - fill: None, - fill_type: SceneFillType::Solid, - gradient: None, - stroke: None, - text: None, - text_runs: Vec::new(), - font_family: String::new(), - font_size: 0.0, - font_weight: 0, - italic: false, - underline: false, - strikethrough: false, - line_height: 0.0, - letter_spacing: 0.0, - text_align: SceneTextAlign::Left, - text_vertical_align: SceneTextVerticalAlign::Top, - text_wrap: false, - points: Vec::new(), - path_anchors: Vec::new(), - path_closed: false, - svg_path: None, - arc_start_angle: None, - arc_sweep_angle: None, - arc_inner_radius: None, - polygon_sides: 3, - image_src: None, - image_src_id: 0, - image_fit: SceneImageFit::Fill, - image_adjustments: crate::ImageAdjustments::default(), - effects: Vec::new(), - hidden: false, - locked: false, - widget: None, - children: Vec::new(), - } - } -} - -fn rect_has_extent(rect: Rect) -> bool { - rect.size.x > 0.0 || rect.size.y > 0.0 -} - -pub fn stable_image_source_id(src: &str) -> u64 { - use std::hash::{Hash, Hasher}; - let mut h = std::collections::hash_map::DefaultHasher::new(); - src.hash(&mut h); - h.finish() -} - -/// Vertices for a regular polygon fitted inside `rect`. -pub fn regular_polygon_points(rect: Rect, sides: u32) -> Vec { - let n = sides.clamp(3, 100) as usize; - let cx = rect.origin.x + rect.size.x / 2.0; - let cy = rect.origin.y + rect.size.y / 2.0; - let rx = rect.size.x / 2.0; - let ry = rect.size.y / 2.0; - let start = -std::f32::consts::FRAC_PI_2; - (0..n) - .map(|i| { - let angle = start + i as f32 * std::f32::consts::TAU / n as f32; - Point2D::new(cx + rx * angle.cos(), cy + ry * angle.sin()) - }) - .collect() -} - -/// Resolved stroke descriptor — colour already `$ref`-resolved. -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct SceneStroke { - pub color: Color, - pub width: f32, -} - -/// Fill paint mode for a [`SceneNode`]. Mirrors -/// `op_editor_core::FillType`; kept as its own enum so the scene -/// type does not re-export an editor-model enum that may diverge. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum SceneFillType { - #[default] - Solid, - LinearGradient, - RadialGradient, - Image, -} - -/// Image placement mode carried by a [`SceneNode`]. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum SceneImageFit { - #[default] - Fill, - Fit, - Crop, - Tile, - Stretch, -} - -impl SceneImageFit { - pub fn to_draw_mode(self) -> crate::ImageDrawMode { - match self { - Self::Fill => crate::ImageDrawMode::Fill, - Self::Fit => crate::ImageDrawMode::Fit, - Self::Crop => crate::ImageDrawMode::Crop, - Self::Tile => crate::ImageDrawMode::Tile, - Self::Stretch => crate::ImageDrawMode::Stretch, - } - } -} - -#[cfg(test)] -#[path = "layout_scene_unit_tests.rs"] -mod tests; diff --git a/crates/op-editor-ui/src/layout_scene_hit.rs b/crates/op-editor-ui/src/layout_scene_hit.rs deleted file mode 100644 index 6583b1ce0..000000000 --- a/crates/op-editor-ui/src/layout_scene_hit.rs +++ /dev/null @@ -1,471 +0,0 @@ -//! Canvas hit-test over a [`LayoutScene`]. -//! -//! These are the input-path geometry queries that used to live as -//! `&Document`-bound helpers (`Document::node_at_doc_point`, -//! `Document::nodes_intersecting_doc_rect`). The reorg moves the -//! editor hosts' input dispatch off the derived `Document` snapshot -//! and onto the layout-resolved [`LayoutScene`], so the hit-test -//! reads the same resolved geometry the painter walks. -//! -//! Hit semantics are preserved bit-for-bit from `document/walkers.rs`: -//! top-most-first z-order (`children[0]` is frontmost), per-node rotation inverse-transform, the -//! tighter Ellipse / Polygon / Line geometry, the locked-node -//! body-opts-out-children-stay rule, and the hidden-subtree skip. - -use crate::layout_scene::NodeKind; -use crate::layout_scene::{regular_polygon_points, LayoutScene, SceneNode}; -use crate::widgets::canvas_viewport_paint::{flatten_path_points, PathPoints}; -use crate::{Point2D, Rect}; - -impl LayoutScene { - /// Topmost node id whose geometry contains `point` (doc space) - /// on the active page. Walks children in front-to-back z-order - /// (`children[0]` is frontmost). `None` on dead space. - /// - /// `zoom` is the live viewport zoom — it scales the Line stroke - /// hit slack so a thin line stays clickable at any zoom. - pub fn node_at_doc_point(&self, point: Point2D, zoom: f32) -> Option { - let zoom = zoom.max(0.0001); - let page = self.active_page()?; - for child in &page.children { - if let Some(hit) = hit_test_walk(child, point, zoom) { - return Some(hit); - } - } - None - } - - /// Top-level node ids on the active page whose aggregate bounds - /// intersect `rect` (doc space). Backs the marquee rect-select. - /// Descends only into top-level children — same as the click - /// hit-test, so the result set selects as a unit. - pub fn nodes_intersecting_doc_rect(&self, rect: Rect) -> Vec { - let Some(page) = self.active_page() else { - return Vec::new(); - }; - let nx = rect.origin.x.min(rect.origin.x + rect.size.x); - let ny = rect.origin.y.min(rect.origin.y + rect.size.y); - let nw = rect.size.x.abs(); - let nh = rect.size.y.abs(); - let mut out = Vec::new(); - for child in &page.children { - let b = child.aggregate_bounds(); - if b.size.x <= 0.0 && b.size.y <= 0.0 { - continue; - } - let bx = b.origin.x.min(b.origin.x + b.size.x); - let by = b.origin.y.min(b.origin.y + b.size.y); - let bw = b.size.x.abs(); - let bh = b.size.y.abs(); - // AABB intersection test. - if bx + bw < nx || nx + nw < bx || by + bh < ny || ny + nh < by { - continue; - } - out.push(child.id.clone()); - } - out - } -} - -/// Recursive hit-test — returns the topmost id whose geometry -/// contains `point`. When a node carries rotation, the test point -/// is inverse-rotated about the node's pivot BEFORE testing -/// children + self, so the hit area matches the rendered geometry. -fn hit_test_walk(node: &SceneNode, point: Point2D, zoom: f32) -> Option { - // Hidden nodes skip hit-test entirely — the subtree inherits. - if node.hidden { - return None; - } - let bounds = node.aggregate_bounds(); - let local = if node.rotation.abs() > f32::EPSILON { - if let Some(pivot) = rotation_pivot(node, bounds) { - let dx = point.x - pivot.x; - let dy = point.y - pivot.y; - let cos_t = (-node.rotation).cos(); - let sin_t = (-node.rotation).sin(); - Point2D::new( - pivot.x + dx * cos_t - dy * sin_t, - pivot.y + dx * sin_t + dy * cos_t, - ) - } else { - point - } - } else { - point - }; - let local = if node.flip_x || node.flip_y { - if let Some(pivot) = rotation_pivot(node, bounds) { - Point2D::new( - if node.flip_x { - 2.0 * pivot.x - local.x - } else { - local.x - }, - if node.flip_y { - 2.0 * pivot.y - local.y - } else { - local.y - }, - ) - } else { - local - } - } else { - local - }; - for child in &node.children { - if let Some(hit) = hit_test_walk(child, local, zoom) { - return Some(hit); - } - } - // Locked nodes can't be selected via canvas hit, but their - // children still can — this check runs AFTER the child walk so - // descendants of a locked Frame remain hittable; only the - // Frame's own body opts out. - if node.locked { - return None; - } - if point_in_node(node, local, bounds, zoom) { - return Some(node.id.clone()); - } - None -} - -/// Rotation pivot for hit-test. Most kinds rotate around the -/// aggregate-bounds center; Lines need a kind-specific path because -/// a Line with a negative-size dimension collapses its aggregate to -/// `Rect::ZERO` (the segment midpoint is still well defined from -/// `bounds`). Returns `None` when no valid pivot exists. -fn rotation_pivot(node: &SceneNode, bounds: Rect) -> Option { - if matches!(node.kind, NodeKind::Line) { - let raw = node.bounds; - if raw.size.x.abs() < f32::EPSILON && raw.size.y.abs() < f32::EPSILON { - return None; - } - return Some(Point2D::new( - raw.origin.x + raw.size.x / 2.0, - raw.origin.y + raw.size.y / 2.0, - )); - } - if bounds.size.x > 0.0 && bounds.size.y > 0.0 { - return Some(Point2D::new( - bounds.origin.x + bounds.size.x / 2.0, - bounds.origin.y + bounds.size.y / 2.0, - )); - } - None -} - -/// Per-NodeKind hit-test. Frames / Groups / Rects / Text / Other -/// use the axis-aligned bounds; Ellipse / Polygon / Line use tighter -/// geometry so the click area matches what the painter draws. -fn point_in_node(node: &SceneNode, local: Point2D, bounds: Rect, zoom: f32) -> bool { - // Lines get a dedicated path: horizontal / vertical segments - // have a zero-dimension bounds rect, and negative-size bounds - // collapse the aggregate to `Rect::ZERO`, so the Line path reads - // `node.bounds` directly. The distance-to-segment helper is - // sign-independent. - if matches!(node.kind, NodeKind::Line) { - let raw = node.bounds; - if raw.size.x.abs() < f32::EPSILON && raw.size.y.abs() < f32::EPSILON { - return false; - } - let from = raw.origin; - let to = Point2D::new(raw.origin.x + raw.size.x, raw.origin.y + raw.size.y); - let stroke_half = node.stroke.map(|s| s.width / 2.0).unwrap_or(1.0); - // 4 screen px of slack regardless of zoom — the point is in - // doc space, so scale by `1/zoom`. - let screen_slack = 4.0 / zoom.max(0.0001); - return distance_point_to_segment(local, from, to) <= stroke_half + screen_slack; - } - // Paths hit-test against their flattened (bezier-aware) outline, - // not the bounding box — a curved / thin path otherwise selects - // empty bbox space, and a zero-height stroked path (which fails - // the positive-area gate below) stays clickable. - if matches!(node.kind, NodeKind::Path) { - let poly = path_hit_points(node); - let points = poly.as_slice(); - if points.len() < 2 { - return false; - } - let stroke_half = node.stroke.map(|s| s.width / 2.0).unwrap_or(1.0); - let slack = 4.0 / zoom.max(0.0001); - for seg in points.windows(2) { - if distance_point_to_segment(local, seg[0], seg[1]) <= stroke_half + slack { - return true; - } - } - // A filled closed path is also hittable across its interior. - return node.path_closed && node.fill.is_some() && point_in_polygon(local, points); - } - // Non-line kinds need real positive area on both axes. - if bounds.size.x <= 0.0 || bounds.size.y <= 0.0 { - return false; - } - let in_bounds = local.x >= bounds.origin.x - && local.x <= bounds.origin.x + bounds.size.x - && local.y >= bounds.origin.y - && local.y <= bounds.origin.y + bounds.size.y; - if !in_bounds { - return false; - } - let cx = bounds.origin.x + bounds.size.x / 2.0; - let cy = bounds.origin.y + bounds.size.y / 2.0; - let rx = (bounds.size.x / 2.0).max(0.0001); - let ry = (bounds.size.y / 2.0).max(0.0001); - match node.kind { - NodeKind::Ellipse => { - let dx = (local.x - cx) / rx; - let dy = (local.y - cy) / ry; - let r2 = dx * dx + dy * dy; - if r2 > 1.0 { - return false; - } - // Arc ellipse: also require the point inside the pie / - // donut sector so a missing wedge is not selectable. - let inner = node.arc_inner_radius.unwrap_or(0.0).clamp(0.0, 1.0); - let has_arc = - node.arc_start_angle.is_some() || node.arc_sweep_angle.is_some() || inner > 0.001; - if !has_arc { - return true; - } - let sweep = node.arc_sweep_angle.unwrap_or(360.0); - if sweep.abs() < 359.9 { - let start = node.arc_start_angle.unwrap_or(0.0); - // Normalise to a forward sweep — a negative sweep - // covers the angular range `[start + sweep, start]`. - let (sector_start, span) = if sweep < 0.0 { - (start + sweep, -sweep) - } else { - (start, sweep) - }; - let ang = dy.atan2(dx).to_degrees(); - let rel = (ang - sector_start).rem_euclid(360.0); - if rel > span { - return false; - } - } - r2 >= inner * inner - } - NodeKind::Polygon => { - let points = regular_polygon_points(bounds, node.polygon_sides); - point_in_polygon(local, &points) - } - // Frame, Group, Rect, Text, Other, Path — bounds-only hit. - _ => true, - } -} - -fn path_hit_points(node: &SceneNode) -> PathPoints<'_> { - flatten_path_points(node) -} - -/// Even-odd ray-cast point-in-polygon test over a closed vertex ring. -fn point_in_polygon(p: Point2D, poly: &[Point2D]) -> bool { - if poly.len() < 3 { - return false; - } - let mut inside = false; - let mut j = poly.len() - 1; - for i in 0..poly.len() { - let (a, b) = (poly[i], poly[j]); - if (a.y > p.y) != (b.y > p.y) && p.x < (b.x - a.x) * (p.y - a.y) / (b.y - a.y) + a.x { - inside = !inside; - } - j = i; - } - inside -} - -/// Shortest distance from `p` to the segment `a`–`b`. -fn distance_point_to_segment(p: Point2D, a: Point2D, b: Point2D) -> f32 { - let dx = b.x - a.x; - let dy = b.y - a.y; - let len_sq = dx * dx + dy * dy; - if len_sq < f32::EPSILON { - let pdx = p.x - a.x; - let pdy = p.y - a.y; - return (pdx * pdx + pdy * pdy).sqrt(); - } - let t = (((p.x - a.x) * dx + (p.y - a.y) * dy) / len_sq).clamp(0.0, 1.0); - let cx = a.x + t * dx; - let cy = a.y + t * dy; - let pdx = p.x - cx; - let pdy = p.y - cy; - (pdx * pdx + pdy * pdy).sqrt() -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::layout_scene::{LayoutScene, SceneNode, ScenePage}; - - fn leaf(id: &str, kind: NodeKind, bounds: Rect) -> SceneNode { - let mut n = SceneNode::leaf(id, kind); - n.bounds = bounds; - n - } - - fn one_page(children: Vec) -> LayoutScene { - LayoutScene { - pages: vec![ScenePage { - id: "p".into(), - name: "P".into(), - children, - }], - active_page_index: 0, - } - } - - #[test] - fn node_at_doc_point_treats_first_child_as_frontmost() { - let scene = one_page(vec![ - leaf("over", NodeKind::Rect, Rect::xywh(0.0, 0.0, 50.0, 50.0)), - leaf("under", NodeKind::Rect, Rect::xywh(10.0, 10.0, 50.0, 50.0)), - ]); - // Overlap region -> children[0] is the top layer. - assert_eq!( - scene - .node_at_doc_point(Point2D::new(20.0, 20.0), 1.0) - .as_deref(), - Some("over") - ); - // Only "under" covers (55, 55). - assert_eq!( - scene - .node_at_doc_point(Point2D::new(55.0, 55.0), 1.0) - .as_deref(), - Some("under") - ); - // Dead space. - assert!(scene - .node_at_doc_point(Point2D::new(200.0, 200.0), 1.0) - .is_none()); - } - - #[test] - fn hidden_node_is_not_hit() { - let mut n = leaf("h", NodeKind::Rect, Rect::xywh(0.0, 0.0, 50.0, 50.0)); - n.hidden = true; - let scene = one_page(vec![n]); - assert!(scene - .node_at_doc_point(Point2D::new(25.0, 25.0), 1.0) - .is_none()); - } - - #[test] - fn locked_node_body_opts_out_but_child_stays_hittable() { - let child = leaf("c", NodeKind::Rect, Rect::xywh(10.0, 10.0, 10.0, 10.0)); - let mut frame = leaf("f", NodeKind::Frame, Rect::xywh(0.0, 0.0, 100.0, 100.0)); - frame.locked = true; - frame.children = vec![child]; - let scene = one_page(vec![frame]); - // Click on the child → child id even though parent is locked. - assert_eq!( - scene - .node_at_doc_point(Point2D::new(15.0, 15.0), 1.0) - .as_deref(), - Some("c") - ); - // Click on the locked frame's bare body → no hit. - assert!(scene - .node_at_doc_point(Point2D::new(80.0, 80.0), 1.0) - .is_none()); - } - - #[test] - fn ellipse_uses_tight_oval_geometry() { - let scene = one_page(vec![leaf( - "e", - NodeKind::Ellipse, - Rect::xywh(0.0, 0.0, 100.0, 100.0), - )]); - // Center → inside the oval. - assert_eq!( - scene - .node_at_doc_point(Point2D::new(50.0, 50.0), 1.0) - .as_deref(), - Some("e") - ); - // Corner of the bounds → outside the oval. - assert!(scene - .node_at_doc_point(Point2D::new(2.0, 2.0), 1.0) - .is_none()); - } - - #[test] - fn line_hit_uses_segment_distance() { - let mut line = leaf("l", NodeKind::Line, Rect::xywh(0.0, 0.0, 100.0, 0.0)); - line.stroke = Some(crate::layout_scene::SceneStroke { - color: crate::Color::WHITE, - width: 1.0, - }); - let scene = one_page(vec![line]); - // Right on the horizontal segment. - assert_eq!( - scene - .node_at_doc_point(Point2D::new(50.0, 0.0), 1.0) - .as_deref(), - Some("l") - ); - // Far from the segment → miss. - assert!(scene - .node_at_doc_point(Point2D::new(50.0, 40.0), 1.0) - .is_none()); - } - - #[test] - fn rotated_node_hit_tracks_rendered_geometry() { - let mut n = leaf("r", NodeKind::Rect, Rect::xywh(0.0, 0.0, 100.0, 20.0)); - n.rotation = std::f32::consts::FRAC_PI_2; // 90° - let scene = one_page(vec![n]); - // After a 90° rotation about (50, 10), the painted rect spans - // roughly x 40..60, y -40..60 — a point at (50, 50) lands - // inside the rotated body though it's outside authored bounds. - assert_eq!( - scene - .node_at_doc_point(Point2D::new(50.0, 50.0), 1.0) - .as_deref(), - Some("r") - ); - } - - #[test] - fn path_hit_points_borrows_handle_free_open_path() { - let mut path = leaf("p", NodeKind::Path, Rect::xywh(0.0, 0.0, 100.0, 0.0)); - path.points = vec![Point2D::new(0.0, 0.0), Point2D::new(100.0, 0.0)]; - - let points = path_hit_points(&path); - - match points { - crate::widgets::canvas_viewport_paint::PathPoints::Borrowed(slice) => { - assert!(std::ptr::eq(slice.as_ptr(), path.points.as_ptr())); - } - crate::widgets::canvas_viewport_paint::PathPoints::Owned(_) => { - panic!("handle-free open path hit-test should borrow points") - } - } - } - - #[test] - fn nodes_intersecting_doc_rect_returns_overlapping_top_level_ids() { - let scene = one_page(vec![ - leaf("a", NodeKind::Rect, Rect::xywh(0.0, 0.0, 30.0, 30.0)), - leaf("b", NodeKind::Rect, Rect::xywh(100.0, 100.0, 30.0, 30.0)), - ]); - let hits = scene.nodes_intersecting_doc_rect(Rect::xywh(10.0, 10.0, 80.0, 80.0)); - assert_eq!(hits, vec!["a".to_string()]); - } - - #[test] - fn nodes_intersecting_doc_rect_handles_negative_size() { - let scene = one_page(vec![leaf( - "a", - NodeKind::Rect, - Rect::xywh(0.0, 0.0, 30.0, 30.0), - )]); - // Negative-size marquee still intersects. - let hits = scene.nodes_intersecting_doc_rect(Rect::xywh(40.0, 40.0, -30.0, -30.0)); - assert_eq!(hits, vec!["a".to_string()]); - } -} diff --git a/crates/op-editor-ui/src/layout_scene_unit_tests.rs b/crates/op-editor-ui/src/layout_scene_unit_tests.rs deleted file mode 100644 index cd4cf272f..000000000 --- a/crates/op-editor-ui/src/layout_scene_unit_tests.rs +++ /dev/null @@ -1,184 +0,0 @@ -//! Sibling unit tests for `layout_scene.rs` (800-line cap -//! convention). - -use super::*; - -#[test] -fn empty_scene_has_no_active_page() { - let scene = LayoutScene::default(); - assert!(scene.pages.is_empty()); - assert!(scene.active_page().is_none()); -} - -#[test] -fn active_page_indexes_into_pages() { - let scene = LayoutScene { - pages: vec![ - ScenePage { - id: "a".into(), - name: "A".into(), - children: Vec::new(), - }, - ScenePage { - id: "b".into(), - name: "B".into(), - children: Vec::new(), - }, - ], - active_page_index: 1, - }; - assert_eq!(scene.active_page().map(|p| p.id.as_str()), Some("b")); -} - -#[test] -fn find_locates_a_nested_node() { - let mut leaf = SceneNode::leaf("deep", NodeKind::Rect); - leaf.bounds = Rect::xywh(0.0, 0.0, 10.0, 10.0); - let mut group = SceneNode::leaf("g", NodeKind::Group); - group.children = vec![leaf]; - let page = ScenePage { - id: "p".into(), - name: "P".into(), - children: vec![group], - }; - assert_eq!(page.find("deep").map(|n| n.id.as_str()), Some("deep")); - assert!(page.find("missing").is_none()); -} - -#[test] -fn aggregate_bounds_unions_children_for_unbounded_container() { - let mut a = SceneNode::leaf("a", NodeKind::Rect); - a.bounds = Rect::xywh(10.0, 10.0, 20.0, 20.0); - let mut b = SceneNode::leaf("b", NodeKind::Rect); - b.bounds = Rect::xywh(50.0, 5.0, 10.0, 40.0); - let mut group = SceneNode::leaf("g", NodeKind::Group); - group.children = vec![a, b]; - // Unbounded group → union of children: x 10..60, y 5..45. - assert_eq!(group.aggregate_bounds(), Rect::xywh(10.0, 5.0, 50.0, 40.0)); -} - -#[test] -fn aggregate_bounds_uses_precomputed_unbounded_container_bounds() { - let mut child = SceneNode::leaf("child", NodeKind::Rect); - child.bounds = Rect::xywh(10.0, 10.0, 20.0, 20.0); - let mut group = SceneNode::leaf("g", NodeKind::Group); - group.children = vec![child]; - group.aggregate_bounds_cache = Rect::xywh(1.0, 2.0, 300.0, 400.0); - - assert_eq!( - group.aggregate_bounds(), - Rect::xywh(1.0, 2.0, 300.0, 400.0), - "layout-built scenes should answer aggregate bounds from the cached subtree rect" - ); -} - -#[test] -fn aggregate_bounds_keeps_own_bounds_when_bounded() { - let mut frame = SceneNode::leaf("f", NodeKind::Frame); - frame.bounds = Rect::xywh(0.0, 0.0, 100.0, 200.0); - let mut child = SceneNode::leaf("c", NodeKind::Rect); - child.bounds = Rect::xywh(0.0, 0.0, 999.0, 999.0); - frame.children = vec![child]; - assert_eq!(frame.aggregate_bounds(), Rect::xywh(0.0, 0.0, 100.0, 200.0)); -} - -#[test] -fn translate_nodes_moves_matching_subtree_once() { - let mut child = SceneNode::leaf("child", NodeKind::Rect); - child.bounds = Rect::xywh(10.0, 20.0, 30.0, 40.0); - let mut parent = SceneNode::leaf("parent", NodeKind::Group); - parent.bounds = Rect::xywh(0.0, 0.0, 100.0, 100.0); - parent.children = vec![child]; - let mut scene = LayoutScene { - pages: vec![ScenePage { - id: "p".into(), - name: "P".into(), - children: vec![parent], - }], - active_page_index: 0, - }; - - assert!(scene.translate_nodes(&["parent".into(), "child".into()], 5.0, 7.0)); - let page = scene.active_page().expect("active page"); - let parent = page.find("parent").expect("parent"); - let child = page.find("child").expect("child"); - assert_eq!(parent.bounds.origin, Point2D::new(5.0, 7.0)); - assert_eq!(child.bounds.origin, Point2D::new(15.0, 27.0)); -} - -#[test] -fn translate_nodes_moves_path_absolute_geometry() { - let mut path = SceneNode::leaf("path", NodeKind::Path); - path.bounds = Rect::xywh(1.0, 2.0, 30.0, 40.0); - path.points = vec![Point2D::new(3.0, 4.0)]; - path.path_anchors = vec![SceneAnchor { - pos: Point2D::new(5.0, 6.0), - handle_in: Some(Point2D::new(7.0, 8.0)), - handle_out: Some(Point2D::new(9.0, 10.0)), - point_type: ScenePointType::Corner, - }]; - let mut scene = LayoutScene { - pages: vec![ScenePage { - id: "p".into(), - name: "P".into(), - children: vec![path], - }], - active_page_index: 0, - }; - - assert!(scene.translate_nodes(&["path".into()], 11.0, 13.0)); - let path = scene.active_page().and_then(|p| p.find("path")).unwrap(); - assert_eq!(path.bounds.origin, Point2D::new(12.0, 15.0)); - assert_eq!(path.points[0], Point2D::new(14.0, 17.0)); - assert_eq!(path.path_anchors[0].pos, Point2D::new(16.0, 19.0)); - assert_eq!( - path.path_anchors[0].handle_in, - Some(Point2D::new(18.0, 21.0)) - ); - assert_eq!( - path.path_anchors[0].handle_out, - Some(Point2D::new(20.0, 23.0)) - ); -} - -#[test] -fn leaf_node_clears_paint_fields() { - let n = SceneNode::leaf("n1", NodeKind::Rect); - assert_eq!(n.bounds, Rect::ZERO); - assert!(n.fill.is_none()); - assert!(n.stroke.is_none()); - assert!(n.children.is_empty()); - assert_eq!(n.fill_type, SceneFillType::Solid); -} - -#[test] -fn content_bounds_unions_top_level_nodes() { - let mut a = SceneNode::leaf("a", NodeKind::Rect); - a.bounds = Rect::xywh(10.0, 20.0, 30.0, 40.0); // → x[10,40] y[20,60] - let mut b = SceneNode::leaf("b", NodeKind::Rect); - b.bounds = Rect::xywh(100.0, 0.0, 50.0, 10.0); // → x[100,150] y[0,10] - let scene = LayoutScene { - pages: vec![ScenePage { - id: "p".into(), - name: "P".into(), - children: vec![a, b], - }], - active_page_index: 0, - }; - let bounds = scene.content_bounds().expect("non-empty page has bounds"); - // Union: x[10,150] y[0,60] → origin (10,0) size (140,60). - assert_eq!(bounds, Rect::xywh(10.0, 0.0, 140.0, 60.0)); -} - -#[test] -fn content_bounds_none_for_empty_page() { - let scene = LayoutScene { - pages: vec![ScenePage { - id: "p".into(), - name: "P".into(), - children: vec![], - }], - active_page_index: 0, - }; - assert!(scene.content_bounds().is_none()); -} diff --git a/crates/op-editor-ui/src/lib.rs b/crates/op-editor-ui/src/lib.rs index f895e4d53..db55e528c 100644 --- a/crates/op-editor-ui/src/lib.rs +++ b/crates/op-editor-ui/src/lib.rs @@ -32,9 +32,12 @@ pub use op_editor_core::render_backend; pub mod accessibility; pub mod font_catalog; -pub mod layout_scene; -pub mod layout_scene_hit; -pub mod scene_vars; +// The render scene now lives in the `jian-scene` crate and `scene_vars` in +// op-editor-core. Re-exported here so existing consumer paths keep resolving +// (`op_editor_ui::layout_scene::*` / `::layout_scene_hit::*` / `::scene_vars::*`), +// including the dirty host crates that must not be edited. +pub use jian_scene::{layout_scene, layout_scene_hit}; +pub use op_editor_core::scene_vars; pub mod svg_export; pub mod theme; pub mod util; diff --git a/crates/op-editor-ui/src/scene_vars.rs b/crates/op-editor-ui/src/scene_vars.rs deleted file mode 100644 index 0c36d3ce8..000000000 --- a/crates/op-editor-ui/src/scene_vars.rs +++ /dev/null @@ -1,515 +0,0 @@ -//! Paint-time design-variable aggregation — `VariableTable`. -//! -//! Mirrors the canonical `jian_ops_schema::variable` model (name → -//! typed value, theme axes, active-theme selection) but is a -//! shell-core-local *paint-time* aggregate: it folds the persisted -//! definitions together with the editor's `fill_refs` / `stroke_refs` -//! resolution caches so the `LayoutScene` builder can resolve every -//! `$ref` fill / stroke to a concrete `Color` at scene-build time. -//! -//! It deliberately stays separate from `op_editor_core` (which owns -//! the canonical editor model on `PenDocument`) so this crate's -//! scene-resolution path keeps its own narrow aggregate type. - -use std::collections::BTreeMap; - -/// Variable type discriminator. Mirrors `VariableKind` in -/// jian_ops_schema for direct round-trip. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum VariableKind { - Color, - Number, - Boolean, - String, -} - -/// Single typed scalar — the leaf value of a variable. Untagged -/// because `.op` JSON uses raw scalars: `"#ff0000"` / `12.5` / `true`. -#[derive(Debug, Clone, PartialEq)] -pub enum VariableScalar { - Bool(bool), - Num(f64), - Str(String), -} - -/// A scalar value paired with the theme combination it applies to. -/// `theme = None` is the "default" entry used when no themed entry -/// matches the active selection. -#[derive(Debug, Clone, PartialEq)] -pub struct ThemedValue { - pub value: VariableScalar, - /// Axis → axis-value map. e.g. `{"mode": "dark"}`. Match the active - /// theme against this map to pick the right value. - pub theme: Option>, -} - -/// Variable value: either a single scalar (theme-agnostic) or a list -/// of themed alternatives. -#[derive(Debug, Clone, PartialEq)] -pub enum VariableValue { - Scalar(VariableScalar), - Themed(Vec), -} - -/// A named variable definition. `name` is the lookup key referenced -/// by `$color-1` style refs in node fill / stroke fields. -#[derive(Debug, Clone, PartialEq)] -pub struct Variable { - pub name: String, - pub kind: VariableKind, - pub value: VariableValue, -} - -impl Variable { - /// Resolve the variable's current scalar under `active_theme`. - /// Themed values pick the entry whose `theme` map is a subset of - /// `active_theme`; falls back to the entry with `theme = None` - /// if no match. Returns None for empty `Themed([])`. - pub fn resolve<'a>( - &'a self, - active_theme: &BTreeMap, - ) -> Option<&'a VariableScalar> { - match &self.value { - VariableValue::Scalar(s) => Some(s), - VariableValue::Themed(entries) => { - // First pass: pick the entry whose every theme axis - // matches active_theme. - for e in entries { - if let Some(t) = &e.theme { - if t.iter().all(|(k, v)| active_theme.get(k) == Some(v)) { - return Some(&e.value); - } - } - } - // Fallback: entry with theme = None, else the FIRST - // entry (TS `resolveThemedValue` falls back to - // `values[0]` so fully-themed lists still resolve - // before the user picks an axis value). - entries - .iter() - .find(|e| e.theme.is_none()) - .or_else(|| entries.first()) - .map(|e| &e.value) - } - } - } -} - -/// One theme axis (e.g. "mode" with values ["light", "dark"]). -#[derive(Debug, Clone, PartialEq)] -pub struct ThemeAxis { - pub name: String, - pub values: Vec, -} - -/// Per-document variable + theme registry. Populated by the -/// canonical `.op` loader from `PenDocument.variables` / -/// `PenDocument.themes`. v1 preserves the data + supports lookup + -/// active-theme selection; paint-time `$ref` substitution is a -/// follow-up. -#[derive(Debug, Clone, Default, PartialEq)] -pub struct VariableTable { - pub variables: Vec, - pub themes: Vec, - /// Current selection per axis. e.g. {"mode": "dark"}. - pub active_theme: BTreeMap, - /// Map of `node_id → variable name` for nodes whose fill is - /// `$ref:name`. Paint reads this first, falls back to `node.fill`. - /// Side-table avoids touching every `Node { ... }` literal. - /// `HashMap` (not `BTreeMap`): `NodeId` is a string with no - /// meaningful order, and lookups are point queries. - pub fill_refs: std::collections::HashMap, - /// Map of `node_id → variable name` for nodes whose stroke colour - /// follows a `$ref`. Parallel to `fill_refs`; paint pre-resolves - /// via `stroke_color_for(node_id)`. - pub stroke_refs: std::collections::HashMap, -} - -impl VariableTable { - /// Register that `node_id`'s fill follows variable `ref_name`. - /// Subsequent `fill_for(node_id)` looks the variable up under - /// the current `active_theme`. - pub fn set_fill_ref(&mut self, node_id: op_editor_core::NodeId, ref_name: impl Into) { - self.fill_refs.insert(node_id, ref_name.into()); - } - /// Resolve the paint-time fill color for `node_id`. Returns - /// None when no `fill_ref` is registered or the referenced - /// variable doesn't resolve (paint then falls back to - /// `node.fill`). - pub fn fill_for(&self, node_id: &op_editor_core::NodeId) -> Option { - let name = self.fill_refs.get(node_id)?; - self.resolve_color(name) - } - /// Stroke parallel to `set_fill_ref` — registers the variable - /// driving a node's stroke colour at paint time. - pub fn set_stroke_ref(&mut self, node_id: op_editor_core::NodeId, ref_name: impl Into) { - self.stroke_refs.insert(node_id, ref_name.into()); - } - /// Resolve the paint-time stroke color for `node_id`. Same shape - /// as `fill_for`; paint falls back to `node.stroke.color` when - /// None. - pub fn stroke_color_for(&self, node_id: &op_editor_core::NodeId) -> Option { - let name = self.stroke_refs.get(node_id)?; - self.resolve_color(name) - } - /// Set the active value of a theme axis (e.g. `("mode", "dark")`). - /// Mirrors the TS theme picker — flips colours across every - /// themed variable in one call. Paint reflows on the next frame. - pub fn set_active_theme(&mut self, axis: impl Into, value: impl Into) { - self.active_theme.insert(axis.into(), value.into()); - } - /// Remove an axis from the active theme map; subsequent - /// resolutions fall back to the variable's `theme: None` default. - pub fn clear_active_axis(&mut self, axis: &str) { - self.active_theme.remove(axis); - } - - /// Cycle the active value of `axis` to the next entry in the - /// `ThemeAxis::values` list. Returns `false` (no-op) when the - /// axis isn't in `themes` or has no values. When the axis - /// isn't currently in `active_theme`, the first value seeds - /// it. When the current value matches the last entry, wraps - /// back to the first. Used by the VariablesPanel axis-chip - /// click to give users a one-click flip — light↔dark, - /// compact↔comfortable, etc. - pub fn cycle_active_axis_value(&mut self, axis: &str) -> bool { - let Some(theme_axis) = self.themes.iter().find(|t| t.name == axis) else { - return false; - }; - if theme_axis.values.is_empty() { - return false; - } - let next = match self.active_theme.get(axis) { - None => theme_axis.values[0].clone(), - Some(current) => { - let idx = theme_axis - .values - .iter() - .position(|v| v == current) - .map(|i| (i + 1) % theme_axis.values.len()) - .unwrap_or(0); - theme_axis.values[idx].clone() - } - }; - self.active_theme.insert(axis.to_string(), next); - true - } - - /// Look up a variable by name. None if unknown. - pub fn find(&self, name: &str) -> Option<&Variable> { - self.variables.iter().find(|v| v.name == name) - } - /// Resolve `$ref` against the table under the active theme. Returns - /// the scalar leaf or None on unknown name / empty Themed. - pub fn resolve(&self, name: &str) -> Option<&VariableScalar> { - self.find(name)?.resolve(&self.active_theme) - } - /// Resolve a `$ref` into a paintable `Color`. Returns None when - /// the variable is unknown, isn't of `Color` kind, or its scalar - /// isn't a parseable hex string (`#rgb`, `#rrggbb`, `#rrggbbaa`). - /// Used by paint-time `$ref` substitution. - pub fn resolve_color(&self, name: &str) -> Option { - let v = self.find(name)?; - if !matches!(v.kind, VariableKind::Color) { - return None; - } - let scalar = v.resolve(&self.active_theme)?; - if let VariableScalar::Str(s) = scalar { - return parse_hex_color(s); - } - None - } - - /// Mutable lookup parallel to `find`. Returns None when no - /// variable with that name exists. Editor surfaces use this to - /// stage an in-progress write back into the live document. - pub fn find_mut(&mut self, name: &str) -> Option<&mut Variable> { - self.variables.iter_mut().find(|v| v.name == name) - } - - /// Write a new hex string into a `Color`-kind variable. Returns - /// `true` when the variable existed AND was Color-kind AND the - /// hex parsed cleanly; `false` otherwise (no mutation). For - /// themed variables this overwrites the entry matching the - /// current `active_theme` (or creates one if absent); for - /// scalar variables it overwrites the single value. - /// - /// The ColorPicker commits through this helper when the - /// VariablesPanel routes a row click into the picker — the - /// model-layer write path is unified across "edit a node's - /// fill" and "edit a variable", so paint sees the change on - /// the next frame regardless of which surface the user touched. - pub fn set_color_hex(&mut self, name: &str, hex: &str) -> bool { - // Validate the hex up front so a malformed input never - // corrupts the stored scalar. - if parse_hex_color(hex).is_none() { - return false; - } - let active = self.active_theme.clone(); - let var = match self.find_mut(name) { - Some(v) if matches!(v.kind, VariableKind::Color) => v, - _ => return false, - }; - let normalized = hex.trim().to_string(); - match &mut var.value { - VariableValue::Scalar(s) => { - *s = VariableScalar::Str(normalized); - true - } - VariableValue::Themed(entries) => { - // Write-routing rules — the resolve walk picks an - // entry; we must update THAT entry when it's a - // subset match for the active theme. When no subset - // entry matches, the user's intent depends on the - // active theme: - // - // - active_theme EMPTY: the user is editing with - // no theme axis selected, so the write targets - // the `theme: None` default entry (the resolve - // fallback). Creates one if absent. - // - // - active_theme NON-EMPTY: the user is editing - // under a specific theme combo. The edit must - // scope to THAT combo. Pushing a new entry keyed - // to active_theme is correct; mutating the - // `theme: None` default would clobber the value - // under every OTHER theme axis too (codex stop- - // gate flag — fallback writes were silently - // reaching across themes). - let subset_idx = entries.iter().position(|e| match &e.theme { - Some(t) => t.iter().all(|(k, v)| active.get(k) == Some(v)), - None => false, - }); - if let Some(i) = subset_idx { - entries[i].value = VariableScalar::Str(normalized); - return true; - } - if active.is_empty() { - // No theme selected — target the default entry. - let default_idx = entries.iter().position(|e| e.theme.is_none()); - if let Some(i) = default_idx { - entries[i].value = VariableScalar::Str(normalized); - } else { - entries.push(ThemedValue { - value: VariableScalar::Str(normalized), - theme: None, - }); - } - return true; - } - // Active theme set + no subset match — append a - // new entry keyed to the active theme at the END - // of the vec (codex stop-gate: front insertion - // shadowed pre-existing themed entries on OTHER - // axes — e.g. an existing {density:compact} entry - // would never resolve under active=dark+compact if - // a {mode:dark} entry was front-inserted because - // resolve's first-match walk picks the front entry - // for ANY active whose keys are a superset). - // - // End-push is safe because Step 1 (subset match) - // already proved no existing entry is a subset of - // `active`. So under the active theme our new - // entry is the unique subset match regardless of - // position; under OTHER actives, every pre-existing - // entry retains its original resolve precedence. - entries.push(ThemedValue { - value: VariableScalar::Str(normalized), - theme: Some(active), - }); - true - } - } - } - - /// Set a scalar variable's value with the same theme-routing - /// discipline as `set_color_hex`, but generic over any - /// `VariableScalar` (Number / Str / Bool). Used by the MCP - /// write tools `set_variable_number` / `set_variable_string` - /// / `set_variable_boolean`. Caller is responsible for - /// matching the scalar's variant to the variable's kind - /// (e.g. don't pass a Str to a Number-kind variable) — - /// kind-mismatch returns `false`. - pub fn set_scalar(&mut self, name: &str, scalar: VariableScalar) -> bool { - let active = self.active_theme.clone(); - let var = match self.find_mut(name) { - Some(v) => v, - None => return false, - }; - // Reject kind/scalar mismatch — a Number variable must - // receive a Num scalar, etc. Color variables are - // FORBIDDEN from this path: they need hex validation - // before mutation, which only `set_color_hex` provides. - // Codex stop-gate flagged that accepting Color+Str here - // would let a misrouted `set_variable_string` call write - // garbage into a Color variable (the tool snapshot - // wouldn't admit the name, but defense in depth says the - // apply layer should reject too). - let kind_matches = match (&var.kind, &scalar) { - (VariableKind::Number, VariableScalar::Num(_)) => true, - (VariableKind::String, VariableScalar::Str(_)) => true, - (VariableKind::Boolean, VariableScalar::Bool(_)) => true, - (VariableKind::Color, _) => false, - _ => false, - }; - if !kind_matches { - return false; - } - match &mut var.value { - VariableValue::Scalar(s) => { - *s = scalar; - true - } - VariableValue::Themed(entries) => { - let subset_idx = entries.iter().position(|e| match &e.theme { - Some(t) => t.iter().all(|(k, v)| active.get(k) == Some(v)), - None => false, - }); - if let Some(i) = subset_idx { - entries[i].value = scalar; - return true; - } - if active.is_empty() { - let default_idx = entries.iter().position(|e| e.theme.is_none()); - if let Some(i) = default_idx { - entries[i].value = scalar; - } else { - entries.push(ThemedValue { - value: scalar, - theme: None, - }); - } - return true; - } - entries.push(ThemedValue { - value: scalar, - theme: Some(active), - }); - true - } - } - } - - /// Create a new theme-agnostic scalar variable. Rejects an - /// empty (post-trim) name or a name that collides with an - /// existing variable. `default` must match `kind`: a Color - /// variable takes a `Str` holding a parseable hex; Number / - /// String / Boolean take the matching scalar variant. - /// Mirrors the TS Variables panel "+ Add" action. - pub fn create_variable( - &mut self, - name: &str, - kind: VariableKind, - default: VariableScalar, - ) -> bool { - let trimmed = name.trim(); - if trimmed.is_empty() { - return false; - } - if self.variables.iter().any(|v| v.name == trimmed) { - return false; - } - let kind_ok = match (&kind, &default) { - // Color variables persist as a hex string; validate it - // up front so a freshly-created token always resolves. - (VariableKind::Color, VariableScalar::Str(s)) => parse_hex_color(s).is_some(), - (VariableKind::Number, VariableScalar::Num(_)) => true, - (VariableKind::String, VariableScalar::Str(_)) => true, - (VariableKind::Boolean, VariableScalar::Bool(_)) => true, - _ => false, - }; - if !kind_ok { - return false; - } - self.variables.push(Variable { - name: trimmed.to_string(), - kind, - value: VariableValue::Scalar(default), - }); - true - } - - /// Delete a variable by name. Also drops any `fill_refs` / - /// `stroke_refs` pointing at it so paint doesn't keep - /// resolving a now-dangling `$ref`. Returns false when the - /// name doesn't resolve. - pub fn delete_variable(&mut self, name: &str) -> bool { - let Some(idx) = self.variables.iter().position(|v| v.name == name) else { - return false; - }; - self.variables.remove(idx); - self.fill_refs.retain(|_, v| v != name); - self.stroke_refs.retain(|_, v| v != name); - true - } - - /// Rename a variable. Rejects an unknown `old` name, an empty - /// (post-trim) `new` name, or a `new` name that collides with - /// a different existing variable. Rewrites every `fill_refs` / - /// `stroke_refs` entry so node `$ref`s follow the rename. - /// `old == new` (after trim) is a no-op success. - pub fn rename_variable(&mut self, old: &str, new: &str) -> bool { - let new_trimmed = new.trim(); - if new_trimmed.is_empty() { - return false; - } - if old == new_trimmed { - return self.variables.iter().any(|v| v.name == old); - } - if self.variables.iter().any(|v| v.name == new_trimmed) { - return false; - } - let Some(var) = self.variables.iter_mut().find(|v| v.name == old) else { - return false; - }; - var.name = new_trimmed.to_string(); - for v in self.fill_refs.values_mut() { - if v == old { - *v = new_trimmed.to_string(); - } - } - for v in self.stroke_refs.values_mut() { - if v == old { - *v = new_trimmed.to_string(); - } - } - true - } -} - -/// Parse `#rgb` / `#rrggbb` / `#rrggbbaa` into a `Color`. Mirrors the -/// TS paint helpers — lenient on case, requires the leading `#`. -fn parse_hex_color(s: &str) -> Option { - let s = s.trim().strip_prefix('#')?; - let (r, g, b, a) = match s.len() { - 3 => { - let r = u8::from_str_radix(&s[0..1].repeat(2), 16).ok()?; - let g = u8::from_str_radix(&s[1..2].repeat(2), 16).ok()?; - let b = u8::from_str_radix(&s[2..3].repeat(2), 16).ok()?; - (r, g, b, 255) - } - 6 => ( - u8::from_str_radix(&s[0..2], 16).ok()?, - u8::from_str_radix(&s[2..4], 16).ok()?, - u8::from_str_radix(&s[4..6], 16).ok()?, - 255, - ), - 8 => ( - u8::from_str_radix(&s[0..2], 16).ok()?, - u8::from_str_radix(&s[2..4], 16).ok()?, - u8::from_str_radix(&s[4..6], 16).ok()?, - u8::from_str_radix(&s[6..8], 16).ok()?, - ), - _ => return None, - }; - Some(crate::Color { - r: r as f32 / 255.0, - g: g as f32 / 255.0, - b: b as f32 / 255.0, - a: a as f32 / 255.0, - }) -} - -#[cfg(test)] -#[path = "scene_vars_tests.rs"] -mod scene_vars_tests; diff --git a/crates/op-editor-ui/src/scene_vars_tests.rs b/crates/op-editor-ui/src/scene_vars_tests.rs deleted file mode 100644 index 3d20ecef1..000000000 --- a/crates/op-editor-ui/src/scene_vars_tests.rs +++ /dev/null @@ -1,613 +0,0 @@ -//! Tests for `scene_vars`. Kept in a sibling file so the types + -//! impl stay under the 800-line cap. - -use super::{ - ThemeAxis, ThemedValue, Variable, VariableKind, VariableScalar, VariableTable, VariableValue, -}; -use std::collections::BTreeMap; - -fn axis(name: &str, value: &str) -> BTreeMap { - let mut m = BTreeMap::new(); - m.insert(name.to_string(), value.to_string()); - m -} - -#[test] -fn scalar_variable_resolves_regardless_of_theme() { - let v = Variable { - name: "color-1".into(), - kind: VariableKind::Color, - value: VariableValue::Scalar(VariableScalar::Str("#ff0000".into())), - }; - let theme = axis("mode", "dark"); - match v.resolve(&theme).unwrap() { - VariableScalar::Str(s) => assert_eq!(s, "#ff0000"), - _ => panic!(), - } -} - -#[test] -fn themed_variable_picks_active_axis() { - let v = Variable { - name: "bg".into(), - kind: VariableKind::Color, - value: VariableValue::Themed(vec![ - ThemedValue { - value: VariableScalar::Str("#ffffff".into()), - theme: Some(axis("mode", "light")), - }, - ThemedValue { - value: VariableScalar::Str("#000000".into()), - theme: Some(axis("mode", "dark")), - }, - ]), - }; - let dark = axis("mode", "dark"); - let light = axis("mode", "light"); - match v.resolve(&dark).unwrap() { - VariableScalar::Str(s) => assert_eq!(s, "#000000"), - _ => panic!(), - } - match v.resolve(&light).unwrap() { - VariableScalar::Str(s) => assert_eq!(s, "#ffffff"), - _ => panic!(), - } -} - -#[test] -fn themed_variable_falls_back_to_default_when_no_match() { - let v = Variable { - name: "accent".into(), - kind: VariableKind::Color, - value: VariableValue::Themed(vec![ - ThemedValue { - value: VariableScalar::Str("#888888".into()), - theme: None, - }, - ThemedValue { - value: VariableScalar::Str("#000000".into()), - theme: Some(axis("mode", "dark")), - }, - ]), - }; - let light = axis("mode", "light"); - match v.resolve(&light).unwrap() { - VariableScalar::Str(s) => assert_eq!(s, "#888888"), - _ => panic!(), - } -} - -#[test] -fn resolve_color_parses_rrggbb_hex() { - let mut tbl = super::VariableTable::default(); - tbl.variables.push(Variable { - name: "accent".into(), - kind: VariableKind::Color, - value: VariableValue::Scalar(VariableScalar::Str("#ff8040".into())), - }); - let c = tbl.resolve_color("accent").unwrap(); - assert!((c.r - 1.0).abs() < 0.01); - assert!((c.g - (128.0 / 255.0)).abs() < 0.01); - assert!((c.b - (64.0 / 255.0)).abs() < 0.01); - assert_eq!(c.a, 1.0); -} - -#[test] -fn resolve_color_picks_themed_active_value() { - let mut tbl = super::VariableTable::default(); - tbl.variables.push(Variable { - name: "bg".into(), - kind: VariableKind::Color, - value: VariableValue::Themed(vec![ - ThemedValue { - value: VariableScalar::Str("#ffffff".into()), - theme: Some(axis("mode", "light")), - }, - ThemedValue { - value: VariableScalar::Str("#000000".into()), - theme: Some(axis("mode", "dark")), - }, - ]), - }); - tbl.active_theme = axis("mode", "dark"); - let c = tbl.resolve_color("bg").unwrap(); - assert_eq!(c.r, 0.0); - assert_eq!(c.g, 0.0); - assert_eq!(c.b, 0.0); -} - -#[test] -fn resolve_color_rejects_non_color_variables() { - let mut tbl = super::VariableTable::default(); - tbl.variables.push(Variable { - name: "spacing".into(), - kind: VariableKind::Number, - value: VariableValue::Scalar(VariableScalar::Num(12.0)), - }); - assert!(tbl.resolve_color("spacing").is_none()); -} - -#[test] -fn fill_for_resolves_registered_node_ref_to_themed_color() { - let mut tbl = super::VariableTable::default(); - tbl.variables.push(Variable { - name: "accent".into(), - kind: VariableKind::Color, - value: VariableValue::Themed(vec![ - ThemedValue { - value: VariableScalar::Str("#ff0000".into()), - theme: Some(axis("mode", "light")), - }, - ThemedValue { - value: VariableScalar::Str("#00ff00".into()), - theme: Some(axis("mode", "dark")), - }, - ]), - }); - let node = op_editor_core::NodeId::new("n42"); - tbl.set_fill_ref(node.clone(), "accent"); - tbl.active_theme = axis("mode", "dark"); - let c = tbl.fill_for(&node).unwrap(); - assert!((c.g - 1.0).abs() < 0.01, "dark mode → green; got {c:?}"); - // Switch theme; same ref resolves differently. - tbl.active_theme = axis("mode", "light"); - let c2 = tbl.fill_for(&node).unwrap(); - assert!((c2.r - 1.0).abs() < 0.01, "light mode → red; got {c2:?}"); -} - -#[test] -fn stroke_color_for_resolves_registered_ref() { - let mut tbl = super::VariableTable::default(); - tbl.variables.push(Variable { - name: "border".into(), - kind: VariableKind::Color, - value: VariableValue::Scalar(VariableScalar::Str("#0000ff".into())), - }); - let node = op_editor_core::NodeId::new("n7"); - tbl.set_stroke_ref(node.clone(), "border"); - let c = tbl.stroke_color_for(&node).unwrap(); - assert!((c.b - 1.0).abs() < 0.01); -} - -#[test] -fn set_active_theme_round_trips_through_axis_picker() { - let mut tbl = super::VariableTable::default(); - tbl.set_active_theme("mode", "dark"); - assert_eq!(tbl.active_theme.get("mode"), Some(&"dark".to_string())); - tbl.set_active_theme("density", "comfortable"); - assert_eq!(tbl.active_theme.len(), 2); - tbl.clear_active_axis("mode"); - assert!(!tbl.active_theme.contains_key("mode")); - assert_eq!(tbl.active_theme.len(), 1); -} - -#[test] -fn fill_for_returns_none_when_no_ref_registered() { - let tbl = super::VariableTable::default(); - assert!(tbl.fill_for(&op_editor_core::NodeId::new("n99")).is_none()); -} - -#[test] -fn resolve_color_rejects_invalid_hex() { - let mut tbl = super::VariableTable::default(); - tbl.variables.push(Variable { - name: "broken".into(), - kind: VariableKind::Color, - value: VariableValue::Scalar(VariableScalar::Str("not-hex".into())), - }); - assert!(tbl.resolve_color("broken").is_none()); -} - -#[test] -fn themed_variable_returns_none_when_empty_and_no_default() { - let v = Variable { - name: "x".into(), - kind: VariableKind::Number, - value: VariableValue::Themed(vec![]), - }; - let theme = BTreeMap::new(); - assert!(v.resolve(&theme).is_none()); -} - -#[test] -fn set_color_hex_writes_scalar_variable() { - let mut tbl = super::VariableTable::default(); - tbl.variables.push(Variable { - name: "color-1".into(), - kind: VariableKind::Color, - value: VariableValue::Scalar(VariableScalar::Str("#ff0000".into())), - }); - assert!(tbl.set_color_hex("color-1", "#00ff00")); - assert_eq!( - tbl.resolve_color("color-1"), - Some(crate::Color { - r: 0.0, - g: 1.0, - b: 0.0, - a: 1.0, - }) - ); -} - -#[test] -fn set_color_hex_rejects_malformed_input() { - let mut tbl = super::VariableTable::default(); - tbl.variables.push(Variable { - name: "color-1".into(), - kind: VariableKind::Color, - value: VariableValue::Scalar(VariableScalar::Str("#ff0000".into())), - }); - assert!(!tbl.set_color_hex("color-1", "not-hex")); - // Scalar must be unchanged after a rejected write. - assert_eq!( - tbl.resolve_color("color-1"), - Some(crate::Color { - r: 1.0, - g: 0.0, - b: 0.0, - a: 1.0, - }) - ); -} - -#[test] -fn set_color_hex_returns_false_for_unknown_variable() { - let mut tbl = super::VariableTable::default(); - assert!(!tbl.set_color_hex("does-not-exist", "#ffffff")); -} - -#[test] -fn set_color_hex_returns_false_for_wrong_kind() { - let mut tbl = super::VariableTable::default(); - tbl.variables.push(Variable { - name: "spacing".into(), - kind: VariableKind::Number, - value: VariableValue::Scalar(VariableScalar::Num(16.0)), - }); - // Number-kind variable: write rejected. - assert!(!tbl.set_color_hex("spacing", "#ffffff")); -} - -#[test] -fn set_color_hex_changes_resolved_value_under_subset_active_theme() { - // Codex stop-gate regression: when the themed entry's theme - // is a SUBSET of the active theme (e.g. entry only specifies - // `mode: dark`; active is `mode: dark, density: compact`), - // `resolve` still picks that entry — so a write must update - // that entry in place, NOT push a new entry at the end that - // `resolve` will never reach. - let mut tbl = super::VariableTable::default(); - tbl.variables.push(Variable { - name: "bg".into(), - kind: VariableKind::Color, - value: VariableValue::Themed(vec![ThemedValue { - value: VariableScalar::Str("#111111".into()), - theme: Some({ - let mut m = BTreeMap::new(); - m.insert("mode".into(), "dark".into()); - m - }), - }]), - }); - tbl.set_active_theme("mode", "dark"); - tbl.set_active_theme("density", "compact"); - // Sanity: resolves through the subset-matching entry. - assert_eq!( - tbl.resolve_color("bg"), - Some(crate::Color { - r: 0x11 as f32 / 255.0, - g: 0x11 as f32 / 255.0, - b: 0x11 as f32 / 255.0, - a: 1.0, - }) - ); - // Write under the wider active theme. The original entry - // (only mode=dark) is the resolved one — the write MUST go - // through it, not append a new entry that resolve never - // reaches. - assert!(tbl.set_color_hex("bg", "#22ff44")); - assert_eq!( - tbl.resolve_color("bg"), - Some(crate::Color { - r: 0x22 as f32 / 255.0, - g: 1.0, - b: 0x44 as f32 / 255.0, - a: 1.0, - }), - "themed write must update the entry resolve picks" - ); -} - -#[test] -fn set_color_hex_under_active_theme_does_not_clobber_default() { - // Codex stop-gate (round 2): when active_theme is non-empty - // and no subset-matching themed entry exists, the write - // must scope to the active theme (push a new entry), NOT - // mutate the `theme: None` default. Mutating the default - // would silently change the value under EVERY OTHER theme - // axis the user hasn't explicitly authored, which is - // never what an editor user clicking a colour swatch in - // dark mode means. - let mut tbl = super::VariableTable::default(); - tbl.variables.push(Variable { - name: "bg".into(), - kind: VariableKind::Color, - value: VariableValue::Themed(vec![ - ThemedValue { - value: VariableScalar::Str("#ffffff".into()), - theme: Some({ - let mut m = BTreeMap::new(); - m.insert("mode".into(), "light".into()); - m - }), - }, - ThemedValue { - value: VariableScalar::Str("#888888".into()), - theme: None, - }, - ]), - }); - tbl.set_active_theme("mode", "dark"); // matches neither - // Pre-write: resolves to the default (#888) under dark. - assert_eq!( - tbl.resolve_color("bg"), - Some(crate::Color { - r: 0x88 as f32 / 255.0, - g: 0x88 as f32 / 255.0, - b: 0x88 as f32 / 255.0, - a: 1.0, - }) - ); - // Write under active=dark must scope to dark only. - assert!(tbl.set_color_hex("bg", "#000000")); - // Under dark — new value reads back. - assert_eq!( - tbl.resolve_color("bg"), - Some(crate::Color { - r: 0.0, - g: 0.0, - b: 0.0, - a: 1.0, - }), - ); - // The default entry must be untouched. Flipping to a - // never-authored axis value (e.g. mode=sepia) should still - // resolve to the original default #888888, NOT the new dark - // value. - tbl.set_active_theme("mode", "sepia"); - assert_eq!( - tbl.resolve_color("bg"), - Some(crate::Color { - r: 0x88 as f32 / 255.0, - g: 0x88 as f32 / 255.0, - b: 0x88 as f32 / 255.0, - a: 1.0, - }), - "default entry must NOT have been clobbered by the dark write" - ); - // Light entry also untouched. - tbl.set_active_theme("mode", "light"); - assert_eq!( - tbl.resolve_color("bg"), - Some(crate::Color { - r: 1.0, - g: 1.0, - b: 1.0, - a: 1.0, - }), - "light entry must NOT have been clobbered" - ); -} - -#[test] -fn set_color_hex_does_not_shadow_existing_entries_on_other_axes() { - // Codex stop-gate (round 3): when the write pushes a new - // themed entry, it must NOT shadow pre-existing entries - // whose theme references a different axis. The pathological - // case is an existing entry keyed to `{density:compact}` - // and a write under `{mode:dark}`. A naive front-insert - // would mean active=`{mode:dark, density:compact}` resolves - // to the new entry instead of the compact entry, silently - // changing the resolved colour for the combined axis. - let mut tbl = super::VariableTable::default(); - tbl.variables.push(Variable { - name: "bg".into(), - kind: VariableKind::Color, - value: VariableValue::Themed(vec![ThemedValue { - value: VariableScalar::Str("#11ccaa".into()), - theme: Some({ - let mut m = BTreeMap::new(); - m.insert("density".into(), "compact".into()); - m - }), - }]), - }); - // Active theme = mode:dark only (no compact axis set, so the - // existing entry doesn't subset-match — the write must push - // a new entry). - tbl.set_active_theme("mode", "dark"); - assert!(tbl.set_color_hex("bg", "#000000")); - // Under mode:dark — the new entry is reachable. - assert_eq!( - tbl.resolve_color("bg"), - Some(crate::Color { - r: 0.0, - g: 0.0, - b: 0.0, - a: 1.0, - }) - ); - // Now flip active to the combined axes — mode:dark + - // density:compact. Under that resolve walk: - // - The pre-existing {density:compact} entry must still - // win (it's earlier in the vec AND its theme is a - // subset of the active combo). - // - The new {mode:dark} entry, end-pushed, is also a - // subset of the active combo, but it's later. First - // subset match wins. - // Pre-fix (front-insert) this would have read #000000 - // instead — that was the codex BLOCK. - tbl.set_active_theme("density", "compact"); - assert_eq!( - tbl.resolve_color("bg"), - Some(crate::Color { - r: 0x11 as f32 / 255.0, - g: 0xcc as f32 / 255.0, - b: 0xaa as f32 / 255.0, - a: 1.0, - }), - "compact entry must not be shadowed by the end-pushed dark entry" - ); -} - -#[test] -fn set_color_hex_empty_active_theme_targets_default_entry() { - // The flip side: when active_theme IS empty (no axis - // selected), writes target the default `theme: None` - // entry — that's what resolve falls back to in this state. - // Editing with no theme selected is the explicit "edit the - // universal value" path. - let mut tbl = super::VariableTable::default(); - tbl.variables.push(Variable { - name: "bg".into(), - kind: VariableKind::Color, - value: VariableValue::Themed(vec![ThemedValue { - value: VariableScalar::Str("#888888".into()), - theme: None, - }]), - }); - // No active theme set. - assert!(tbl.set_color_hex("bg", "#aabbcc")); - assert_eq!( - tbl.resolve_color("bg"), - Some(crate::Color { - r: 0xaa as f32 / 255.0, - g: 0xbb as f32 / 255.0, - b: 0xcc as f32 / 255.0, - a: 1.0, - }) - ); -} - -#[test] -fn set_color_hex_writes_themed_entry_matching_active_axis() { - let mut tbl = super::VariableTable::default(); - tbl.variables.push(Variable { - name: "bg".into(), - kind: VariableKind::Color, - value: VariableValue::Themed(vec![ - ThemedValue { - value: VariableScalar::Str("#ffffff".into()), - theme: Some({ - let mut m = BTreeMap::new(); - m.insert("mode".into(), "light".into()); - m - }), - }, - ThemedValue { - value: VariableScalar::Str("#000000".into()), - theme: Some({ - let mut m = BTreeMap::new(); - m.insert("mode".into(), "dark".into()); - m - }), - }, - ]), - }); - tbl.set_active_theme("mode", "dark"); - // Resolves to dark first. - assert_eq!( - tbl.resolve_color("bg"), - Some(crate::Color { - r: 0.0, - g: 0.0, - b: 0.0, - a: 1.0, - }) - ); - // Write under the active theme → updates only the dark entry. - assert!(tbl.set_color_hex("bg", "#3344ff")); - assert_eq!( - tbl.resolve_color("bg"), - Some(crate::Color { - r: 0x33 as f32 / 255.0, - g: 0x44 as f32 / 255.0, - b: 1.0, - a: 1.0, - }) - ); - // Flip to light — original light entry untouched. - tbl.set_active_theme("mode", "light"); - assert_eq!( - tbl.resolve_color("bg"), - Some(crate::Color { - r: 1.0, - g: 1.0, - b: 1.0, - a: 1.0, - }) - ); -} - -#[test] -fn cycle_active_axis_seeds_first_value_when_absent() { - // Axis defined in `themes` but not yet in active_theme → - // cycle plants the first value. - let mut tbl = VariableTable::default(); - tbl.themes.push(ThemeAxis { - name: "mode".into(), - values: vec!["light".into(), "dark".into()], - }); - assert!(tbl.cycle_active_axis_value("mode")); - assert_eq!(tbl.active_theme.get("mode"), Some(&"light".to_string())); -} - -#[test] -fn cycle_active_axis_advances_to_next_value() { - let mut tbl = VariableTable::default(); - tbl.themes.push(ThemeAxis { - name: "mode".into(), - values: vec!["light".into(), "dark".into(), "sepia".into()], - }); - tbl.set_active_theme("mode", "light"); - // light → dark → sepia → light (wrap) - assert!(tbl.cycle_active_axis_value("mode")); - assert_eq!(tbl.active_theme.get("mode"), Some(&"dark".to_string())); - assert!(tbl.cycle_active_axis_value("mode")); - assert_eq!(tbl.active_theme.get("mode"), Some(&"sepia".to_string())); - assert!(tbl.cycle_active_axis_value("mode")); - assert_eq!(tbl.active_theme.get("mode"), Some(&"light".to_string())); -} - -#[test] -fn cycle_active_axis_returns_false_for_unknown_axis() { - let mut tbl = VariableTable::default(); - assert!(!tbl.cycle_active_axis_value("does-not-exist")); - assert!(tbl.active_theme.is_empty()); -} - -#[test] -fn cycle_active_axis_returns_false_for_empty_values() { - let mut tbl = VariableTable::default(); - tbl.themes.push(ThemeAxis { - name: "empty".into(), - values: Vec::new(), - }); - assert!(!tbl.cycle_active_axis_value("empty")); - assert!(!tbl.active_theme.contains_key("empty")); -} - -#[test] -fn cycle_active_axis_falls_back_to_first_when_current_unknown() { - // active_theme carries a value that isn't in themes.values - // (e.g. an axis whose options changed since the file loaded). - // Cycle wraps to the first known value. - let mut tbl = VariableTable::default(); - tbl.themes.push(ThemeAxis { - name: "mode".into(), - values: vec!["light".into(), "dark".into()], - }); - tbl.set_active_theme("mode", "ghost"); // not in values - assert!(tbl.cycle_active_axis_value("mode")); - assert_eq!(tbl.active_theme.get("mode"), Some(&"light".to_string())); -} diff --git a/crates/op-editor-ui/src/widgets/canvas_path_overlay.rs b/crates/op-editor-ui/src/widgets/canvas_path_overlay.rs index 9f021e70b..2ce6e2b57 100644 --- a/crates/op-editor-ui/src/widgets/canvas_path_overlay.rs +++ b/crates/op-editor-ui/src/widgets/canvas_path_overlay.rs @@ -17,7 +17,7 @@ use crate::layout_scene::{SceneAnchor, SceneNode}; use crate::theme::Theme; use crate::widgets::canvas_viewport::path_handle_positions; -use crate::widgets::canvas_viewport_paint::cubic_point; +use jian_scene::path_geometry::cubic_point; use crate::widgets::PaintCx; use crate::{Color, Point2D, Rect}; use op_editor_core::Viewport; diff --git a/crates/op-editor-ui/src/widgets/canvas_viewport_paint.rs b/crates/op-editor-ui/src/widgets/canvas_viewport_paint.rs index a994e2791..6cddfc69e 100644 --- a/crates/op-editor-ui/src/widgets/canvas_viewport_paint.rs +++ b/crates/op-editor-ui/src/widgets/canvas_viewport_paint.rs @@ -22,6 +22,7 @@ use crate::widgets::canvas_viewport_text::paint_text_node; use crate::widgets::canvas_viewport_widget::paint_widget_visual; use crate::widgets::PaintCx; use crate::{Point2D, Rect}; +use jian_scene::path_geometry::flatten_path_points; use std::collections::HashMap; /// Paint every `Effect::DropShadow` on `node` as a blurred shape @@ -135,49 +136,6 @@ fn paint_ellipse(cx: &mut PaintCx<'_>, node: &SceneNode, world_rect: Rect, zoom: } } -/// One point on the cubic Bezier `p0→p3` (control points `p1`,`p2`). -pub(crate) fn cubic_point(p0: Point2D, p1: Point2D, p2: Point2D, p3: Point2D, t: f32) -> Point2D { - let u = 1.0 - t; - let (w0, w1, w2, w3) = (u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t); - Point2D::new( - w0 * p0.x + w1 * p1.x + w2 * p2.x + w3 * p3.x, - w0 * p0.y + w1 * p1.y + w2 * p2.y + w3 * p3.y, - ) -} - -/// One flattened segment `a → b` appended onto `out` — a cubic when -/// either endpoint carries a handle, else a straight line. -fn flatten_segment( - a: &crate::layout_scene::SceneAnchor, - b: &crate::layout_scene::SceneAnchor, - out: &mut Vec, -) { - let (p0, p3) = (a.pos, b.pos); - let p1 = a.handle_out.unwrap_or(p0); - let p2 = b.handle_in.unwrap_or(p3); - if p1 == p0 && p2 == p3 { - out.push(p3); // straight segment - } else { - for i in 1..=16 { - out.push(cubic_point(p0, p1, p2, p3, i as f32 / 16.0)); - } - } -} - -pub(crate) enum PathPoints<'a> { - Borrowed(&'a [Point2D]), - Owned(Vec), -} - -impl<'a> PathPoints<'a> { - pub(crate) fn as_slice(&self) -> &[Point2D] { - match self { - Self::Borrowed(points) => points, - Self::Owned(points) => points.as_slice(), - } - } -} - const STACK_WORLD_PATH_POINTS: usize = 64; // The large stack variant is intentional: hot path-overlay painting @@ -231,42 +189,13 @@ pub(crate) fn world_path_points( ) } -/// Flatten a Path scene node into doc-space points, borrowing the -/// original point slice for the common handle-free open-path case. -pub(crate) fn flatten_path_points(node: &SceneNode) -> PathPoints<'_> { - let anchors = &node.path_anchors; - let has_handle = anchors - .iter() - .any(|a| a.handle_in.is_some() || a.handle_out.is_some()); - if anchors.len() < 2 || !has_handle { - if !node.path_closed { - return PathPoints::Borrowed(&node.points); - } - let mut out = node.points.clone(); - // Closed handle-free path — link the polyline back to its - // start so the closing edge is drawn. - if node.path_closed && out.len() > 2 { - out.push(out[0]); - } - return PathPoints::Owned(out); - } - let mut out = Vec::with_capacity(anchors.len() * 16 + 16); - out.push(anchors[0].pos); - for pair in anchors.windows(2) { - flatten_segment(&pair[0], &pair[1], &mut out); - } - if node.path_closed { - flatten_segment(&anchors[anchors.len() - 1], &anchors[0], &mut out); - } - PathPoints::Owned(out) -} - /// Flatten a Path scene node into a doc-space polyline — cubic /// segments whose endpoints carry handles are tessellated; a /// handle-free path falls back to the straight `points` polyline. /// A closed path appends the last-anchor → first-anchor segment. #[cfg(test)] pub(crate) fn flatten_path(node: &SceneNode) -> Vec { + use jian_scene::path_geometry::PathPoints; match flatten_path_points(node) { PathPoints::Borrowed(points) => points.to_vec(), PathPoints::Owned(points) => points, diff --git a/crates/op-editor-ui/src/widgets/canvas_viewport_paint_tests.rs b/crates/op-editor-ui/src/widgets/canvas_viewport_paint_tests.rs index ba9dddb01..125b2e75b 100644 --- a/crates/op-editor-ui/src/widgets/canvas_viewport_paint_tests.rs +++ b/crates/op-editor-ui/src/widgets/canvas_viewport_paint_tests.rs @@ -509,9 +509,8 @@ mod text_tests { mod path_tests { use crate::layout_scene::{NodeKind, SceneAnchor, SceneNode, ScenePointType}; - use crate::widgets::canvas_viewport_paint::{ - flatten_path, flatten_path_points, world_path_points, PathPoints, WorldPathPoints, - }; + use crate::widgets::canvas_viewport_paint::{flatten_path, world_path_points, WorldPathPoints}; + use jian_scene::path_geometry::{flatten_path_points, PathPoints}; use crate::{Point2D, Rect}; fn anchor(x: f32, y: f32, hout: Option) -> SceneAnchor {