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.
This commit is contained in:
parent
d76ba4528d
commit
71fa9baa5b
|
|
@ -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"] }
|
||||
|
|
|
|||
|
|
@ -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<usize> = 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<SceneGradientStop>,
|
||||
},
|
||||
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<SceneGradientStop>,
|
||||
},
|
||||
}
|
||||
|
||||
/// 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<ScenePage>,
|
||||
/// 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<Rect> {
|
||||
let page = self.active_page()?;
|
||||
let mut acc: Option<Rect> = 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<SceneNode>,
|
||||
}
|
||||
|
||||
/// 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<Point2D>,
|
||||
pub handle_out: Option<Point2D>,
|
||||
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<Color>,
|
||||
/// 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<Color>,
|
||||
/// 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<SceneGradient>,
|
||||
/// Resolved stroke (colour + width). `$ref` stroke colours are
|
||||
/// resolved at build time. `None` = no stroke.
|
||||
pub stroke: Option<SceneStroke>,
|
||||
/// 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<String>,
|
||||
/// 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<SceneTextRun>,
|
||||
/// 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<Point2D>,
|
||||
/// 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<SceneAnchor>,
|
||||
/// 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<String>,
|
||||
/// Ellipse arc start angle in degrees. `None` = full ellipse.
|
||||
pub arc_start_angle: Option<f32>,
|
||||
/// Ellipse arc sweep angle in degrees. `None` = full ellipse.
|
||||
pub arc_sweep_angle: Option<f32>,
|
||||
/// Ellipse donut-hole radius (0.0..=1.0 fraction). `None` / 0 =
|
||||
/// solid.
|
||||
pub arc_inner_radius: Option<f32>,
|
||||
/// 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<String>,
|
||||
/// 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<Effect>,
|
||||
/// 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<SceneWidget>,
|
||||
/// Child render nodes, in paint order.
|
||||
pub children: Vec<SceneNode>,
|
||||
}
|
||||
|
||||
/// 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<bool>,
|
||||
/// Numeric value for slider / progress / number_input.
|
||||
pub value_num: Option<f32>,
|
||||
/// String value for select / radio_group / tabs / text inputs.
|
||||
pub value_str: Option<String>,
|
||||
/// Placeholder text for inputs / select.
|
||||
pub placeholder: Option<String>,
|
||||
/// 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<String>,
|
||||
pub trailing_icon: Option<String>,
|
||||
/// Adjacent label (checkbox).
|
||||
pub label: Option<String>,
|
||||
/// Range minimum (slider / number_input). `None` = 0.
|
||||
pub min: Option<f32>,
|
||||
/// Range maximum (slider / progress / number_input). `None` = 100.
|
||||
pub max: Option<f32>,
|
||||
/// Range step. `None` = 1.
|
||||
pub step: Option<f32>,
|
||||
/// `(value, label)` rows for select / radio_group / tabs.
|
||||
pub options: Vec<SceneWidgetOption>,
|
||||
}
|
||||
|
||||
/// 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<String>, 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<Point2D> {
|
||||
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;
|
||||
|
|
@ -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<String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
// 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<Point2D> {
|
||||
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<SceneNode>) -> 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()]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<BTreeMap<String, String>>,
|
||||
}
|
||||
|
||||
/// 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<ThemedValue>),
|
||||
}
|
||||
|
||||
/// 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<String, String>,
|
||||
) -> 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<String>,
|
||||
}
|
||||
|
||||
/// 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<Variable>,
|
||||
pub themes: Vec<ThemeAxis>,
|
||||
/// Current selection per axis. e.g. {"mode": "dark"}.
|
||||
pub active_theme: BTreeMap<String, String>,
|
||||
/// 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<op_editor_core::NodeId, String>,
|
||||
/// 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<op_editor_core::NodeId, String>,
|
||||
}
|
||||
|
||||
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<String>) {
|
||||
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<crate::Color> {
|
||||
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<String>) {
|
||||
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<crate::Color> {
|
||||
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<String>, value: impl Into<String>) {
|
||||
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<crate::Color> {
|
||||
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<crate::Color> {
|
||||
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;
|
||||
|
|
@ -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<String, String> {
|
||||
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()));
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<Point2D>,
|
||||
) {
|
||||
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<Point2D>),
|
||||
}
|
||||
|
||||
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<Point2D> {
|
||||
use jian_scene::path_geometry::PathPoints;
|
||||
match flatten_path_points(node) {
|
||||
PathPoints::Borrowed(points) => points.to_vec(),
|
||||
PathPoints::Owned(points) => points,
|
||||
|
|
|
|||
|
|
@ -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<Point2D>) -> SceneAnchor {
|
||||
|
|
|
|||
Loading…
Reference in a new issue