M2.9: add editable Shaper groups and shape orientation steps
This commit is contained in:
1 parent
cfe8eaafaf
commit
7e82dbc05b
26 files changed
+1575
-93
No files matched your search
+4
-4
@@ -52,7 +52,7 @@ Ordered by how much each gap blocks someone from switching. Sizes are one-agent
|
||||
|
||||
1. **Interaction fidelity:** go tool by tool and panel by panel against `plan/illustrator/05-tools.md`,
|
||||
`06-panels.md` and `09-shortcuts.md`, covering modifier keys, cursors, the Properties panel per context and
|
||||
isolation mode. The Shaper’s rough-square recognition is corrected; Shaper Groups remain pending. 60–90 h.
|
||||
isolation mode. The Shaper’s rough-square recognition and shape orientation steps are corrected; Shaper Groups now retain editable originals for region merges/punches and separate fill/stroke edits; their construction-mode widget and gesture refinements remain. 60–90 h.
|
||||
2. **Photoshop-style raster effects and the Effect Gallery:** about 55 filters (Artistic, Brush Strokes, Distort,
|
||||
Pixelate, Sketch, Stylize, Texture, Video) on the raster pipeline that drop shadows already use, applied at
|
||||
Document Raster Effects Settings resolution. Parallelizes well across agents. 28–42 h.
|
||||
@@ -118,7 +118,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
|
||||
- Cmd/Ctrl+A follows the keyboard: in a panel, dialog or search field it selects the field's text, while the Type tool edits it selects all of that text (also `select.all` over MCP), and only otherwise every object. On macOS the menu bar's Select All, Cut, Copy and Paste (keys or clicks) act on a focused field too, and elsewhere the in-window menus' do: the field that had the keyboard when the menu opened gets them, with its selection, and the keyboard back (#516). Not yet: Select All across threaded frames (one frame's text is selected).
|
||||
- On Windows and Linux the app bar is the window's title bar (its own minimize, maximize and close buttons, drag to move, edges to resize), with the dragon app icon as the brand mark; dialogs and menus size to their content. As on a native menu bar, once one menu is open, moving the pointer onto another title opens that one.
|
||||
- **Tools:**
|
||||
- **Shaper:** uneven squares and rectangles become live rectangles even when drawing begins mid-edge. Recognition uses uniformly spaced samples and closed-loop corners, fits rectangle edges/angles rather than requiring a nearly perfect bounding-box fill, and includes the pointer-release position; circles, ellipses, polygons, lines and scribble deletion remain covered. Shaper Groups (merge/punch) are still pending.
|
||||
- **Shaper:** uneven squares and rectangles become live rectangles even when drawing begins mid-edge. Recognition uses uniformly spaced samples and closed-loop corners, fits rectangle edges/angles rather than requiring a nearly perfect bounding-box fill, and includes the pointer-release position. Rectangles/squares and ellipses snap to 45° steps, hexagons to 90°, and triangles point up or down; lines keep their free angle even with Shift. Shaper Groups retain the live originals, merge regions with the gesture-origin fill, punch fills independently from strokes, remove boundaries on exiting scribbles and trim protruding stroke pieces. Source moves/resizes re-evaluate the result in the same undo step; double-click enters construction editing. Native save/reopen, face recoloring, Release and Expand are covered. The construction-mode widget and finer gesture heuristics remain.
|
||||
- **Selection:** Selection, Direct/Group Selection, Magic Wand, Lasso. A Shift-drag marquee toggles what it reaches: objects with the Selection tool, anchors with Direct and Group Selection (#483); the Lasso adds with Shift and subtracts with Alt. Dragging a path segment with Direct Selection bends it when it's curved (its anchors stay) and moves its two anchors when it's straight, one undo step; the fill, Shift or Alt still pick the whole path (Alt-drag copies it).
|
||||
- **Saved selections:** Select ▸ Save Selection… asks for a name and keeps the selection in the document (saved with the file, undoable, up to 25); each one is listed at the bottom of the Select menu and a click selects its objects again; Edit Selection… lists them to rename or delete, applied together on OK. A file's saved selections are capped and checked against its objects when it opens. Not yet: Update Selection and the Layers panel's saved-selection menu.
|
||||
- **Drawing:** Pen, Curvature, anchor tools, Pencil, Paintbrush, Blob Brush, Smooth, Path Eraser, Join. Direction handles dragged with Direct Selection or the Anchor Point tool snap to smart guides, and Shift keeps them at 45° steps from their anchor. Every point a drawing tool places snaps to Smart Guides through one snapper (#506): the Pen's and Curvature tool's anchors, the Pencil's ends, the shape tools' start and dragged corner or radius (Rectangle, Rounded Rectangle, Ellipse, Polygon, Star, Line Segment, Arc, Spiral, the grids), the Type tool's click and frame, a new artboard's corners.
|
||||
@@ -172,7 +172,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
|
||||
|---|---|---|---|
|
||||
| M0 | Skeleton + vertical slice | ✅ done | — |
|
||||
| M1 | Selection, transform, layers, MCP | ✅ mostly done (transform reference point snaps to anchors/centres; rotated objects keep a rotated bounding box and their angle; Free Transform, Scale and Reflect along a rotated box pending) | 4–6 |
|
||||
| M2 | Drawing tools + smart guides | ✅ mostly done (Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper rough-shape recognition, the remaining Pen modifier nuances; Shaper Groups pending) | 15–20 |
|
||||
| M2 | Drawing tools + smart guides | ✅ mostly done (Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper rough-shape recognition and orientation steps, the remaining Pen modifier nuances; Shaper Groups with editable sources and separate fill/stroke edits landed; construction widget/refinements pending) | 12–18 |
|
||||
| M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork; gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool, Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries. Left: freeform and mixed spot/process gradients export as stops or process colours to SVG/PDF; confirm the Scale Strokes & Effects default | 4–8 |
|
||||
| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out, EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, Asset Export, clipboard flavours, File Info. Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, polish | 3–6 |
|
||||
| M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 |
|
||||
@@ -204,7 +204,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale)
|
||||
| Area | Weight | Done | Missing (main items) | One agent (h) |
|
||||
|---|---:|---:|---|---:|
|
||||
| Selection, transform & align tools | 6 | 85% | Free Transform/Scale/Reflect along a rotated box, Start Global Edit, transform nuances (the rotated persistent bounding box is done) | 4–6 |
|
||||
| Drawing tools | 7 | 85% | rough-square Shaper recognition corrected; remaining: Shaper Groups (merge/punch overlapping shapes), pencil and the remaining pen modifier nuances, the freehand Tool Options' Keep selected, Alt to the Smooth tool, Blob Brush Merge Only with Selection and brush angle/roundness, Touch Type, Live Corners on corners between curved sides (and Relative rounding) | 10–15 |
|
||||
| Drawing tools | 7 | 85% | rough-square Shaper recognition and orientation steps corrected; editable Shaper Groups landed; remaining: Shaper construction widget/gesture refinements, pencil and the remaining pen modifier nuances, the freehand Tool Options' Keep selected, Alt to the Smooth tool, Blob Brush Merge Only with Selection and brush angle/roundness, Touch Type, Live Corners on corners between curved sides (and Relative rounding) | 8–13 |
|
||||
| Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 86% | Live Paint gap options, Shape Builder gap detection, options (Tool Options dialog, clicking a stroke in merge mode) and Shift-drag marquee | 3–6 |
|
||||
| Colour, swatches, gradients, patterns, mesh, recolor | 7 | 96% | freeform and mixed spot/process gradients in SVG/PDF (exported as stops or process colours), pattern fills in Expand | 2–4 |
|
||||
| Strokes, brushes, width profiles | 5 | 88% | brush options depth, brush libraries (generated in code), brushes driven by pen tilt, bearing and barrel rotation (6D art pens) | 12–20 |
|
||||
|
||||
+13
-3
@@ -44,6 +44,8 @@ impl Hit {
|
||||
/// Options for hit testing.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct HitOptions {
|
||||
/// The isolated construction container, whose retained Shaper sources are hittable.
|
||||
pub scope: Option<NodeId>,
|
||||
/// Tolerance in document units (screen tolerance / zoom).
|
||||
pub tol: f64,
|
||||
/// Outline mode: only outlines are hittable.
|
||||
@@ -58,7 +60,7 @@ pub struct HitOptions {
|
||||
|
||||
impl Default for HitOptions {
|
||||
fn default() -> Self {
|
||||
Self { tol: 3.0, outline: false, path_only: false, type_path_only: false }
|
||||
Self { tol: 3.0, outline: false, path_only: false, type_path_only: false, scope: None }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +72,7 @@ pub fn hit_test(doc: &Document, p: Point, opt: HitOptions) -> Option<Hit> {
|
||||
/// The objects under `p` that [`Hit::top_object`] picks in `scope` (isolation mode), topmost
|
||||
/// first: what clicks there select, one below the other (Cmd/Ctrl-click selects behind).
|
||||
pub fn objects_at(doc: &Document, p: Point, opt: HitOptions, scope: Option<NodeId>) -> Vec<NodeId> {
|
||||
let opt = HitOptions { scope: scope.or(opt.scope), ..opt };
|
||||
let mut tops: Vec<NodeId> = vec![];
|
||||
// Each round leaves out the objects found so far (and everything inside them).
|
||||
while let Some(top) = hit_test_skipping(doc, p, opt, &|id| tops.contains(&id)).map(|h| h.top_object(scope)) {
|
||||
@@ -131,13 +134,20 @@ fn hit_children(parent: &Node, p: Point, opt: HitOptions, skip: &dyn Fn(NodeId)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
for c in children.iter().rev() {
|
||||
for (i, c) in children.iter().enumerate().rev() {
|
||||
if parent.shaper.is_some() {
|
||||
let construction = children.first().is_some_and(|source| Some(source.id) == opt.scope);
|
||||
if (i == 0) != construction {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Hidden, locked and template sublayers block clicks like top-level layers do.
|
||||
if !c.visible || c.locked || c.is_template() || skip(c.id) {
|
||||
continue;
|
||||
}
|
||||
// (An envelope's content sits where it was, not where the envelope draws it.)
|
||||
if !edits_contents(c)
|
||||
if !(edits_contents(c)
|
||||
|| c.shaper.is_some() && c.children().and_then(|children| children.first()).is_some_and(|source| Some(source.id) == opt.scope))
|
||||
&& let Some(b) = c.reach_bounds()
|
||||
&& !b.inflate(opt.tol, opt.tol).contains(p)
|
||||
{
|
||||
|
||||
@@ -33,6 +33,7 @@ mod reach;
|
||||
pub mod recolor;
|
||||
pub mod selection;
|
||||
pub mod setup;
|
||||
pub mod shaper;
|
||||
pub mod slices;
|
||||
pub mod style_libs;
|
||||
pub mod swatches;
|
||||
|
||||
+11
-2
@@ -472,6 +472,9 @@ pub struct Node {
|
||||
/// Graph object: the group's children are generated from this spec (Object → Graph).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub graph: Option<Box<crate::graph::GraphSpec>>,
|
||||
/// Editable Shaper composition; the original art is retained in its source child.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub shaper: Option<Box<crate::shaper::ShaperSpec>>,
|
||||
pub kind: NodeKind,
|
||||
/// The [`crate::GraphicStyle::id`] last applied to this object. It stays linked while it keeps
|
||||
/// that style's look: editing its appearance or transparency breaks the link.
|
||||
@@ -536,6 +539,7 @@ impl Node {
|
||||
trace: None,
|
||||
wrap: None,
|
||||
graph: None,
|
||||
shaper: None,
|
||||
kind,
|
||||
graphic_style: None,
|
||||
attrs: None,
|
||||
@@ -707,6 +711,7 @@ impl Node {
|
||||
}
|
||||
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Compound { children, .. } => children
|
||||
.iter()
|
||||
.skip(usize::from(self.shaper.is_some()))
|
||||
.filter(|c| c.visible || !matches!(self.kind, NodeKind::Layer { .. }))
|
||||
.fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.geometric_bounds())),
|
||||
NodeKind::Text(t) => self.projected(t.bounds()),
|
||||
@@ -738,7 +743,11 @@ impl Node {
|
||||
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => {
|
||||
// The container's own strokes paint around its members.
|
||||
let o = self.appearance.outset();
|
||||
children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.visual_bounds())).map(|b| b.inflate(o, o))
|
||||
children
|
||||
.iter()
|
||||
.skip(usize::from(self.shaper.is_some()))
|
||||
.fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.visual_bounds()))
|
||||
.map(|b| b.inflate(o, o))
|
||||
}
|
||||
NodeKind::Blend { children, spec } => {
|
||||
let b = children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.visual_bounds()));
|
||||
@@ -965,7 +974,7 @@ impl Node {
|
||||
}
|
||||
}
|
||||
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => {
|
||||
for c in children.iter().filter(|c| c.visible) {
|
||||
for c in children.iter().skip(usize::from(self.shaper.is_some())).filter(|c| c.visible) {
|
||||
c.push_clip_shapes(text, out);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
//! Persistent Shaper edits over retained source paths. Source indices (not document ids) keep
|
||||
//! recipes valid when a group is copied. Selector anchors use source-relative bounding boxes,
|
||||
//! so the intended face or stroke follows source art when it moves or resizes.
|
||||
use crate::Appearance;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use vectorcraft_geom::Point;
|
||||
|
||||
pub const SOURCES: &str = "Shaper Sources";
|
||||
pub const FILL_PREFIX: &str = "Shaper Fill ";
|
||||
pub const STROKE_PREFIX: &str = "Shaper Stroke ";
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ShaperSpec {
|
||||
pub faces: Vec<FaceEdit>,
|
||||
pub edges: Vec<EdgeEdit>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct FaceSelector {
|
||||
pub sources: Vec<usize>,
|
||||
pub anchor: Point,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct FaceEdit {
|
||||
pub selector: FaceSelector,
|
||||
pub erase: bool,
|
||||
pub erase_edges: bool,
|
||||
pub paint_source: Option<usize>,
|
||||
pub merge: Option<u64>,
|
||||
pub paint: Option<Appearance>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct EdgeEdit {
|
||||
pub source: usize,
|
||||
pub sides: [Vec<usize>; 2],
|
||||
pub anchor: Point,
|
||||
pub erase: bool,
|
||||
pub paint: Option<Appearance>,
|
||||
}
|
||||
@@ -9,8 +9,16 @@ use super::*;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!("shape.rectangle", "Rectangle", [], None, "{x, y, width, height, radius?: pt} → {id}", has_doc, rectangle),
|
||||
cmd!("shape.ellipse", "Ellipse", [], None, "{x, y, width, height} → {id}", has_doc, ellipse),
|
||||
cmd!(
|
||||
"shape.rectangle",
|
||||
"Rectangle",
|
||||
[],
|
||||
None,
|
||||
"{x, y, width, height, radius?: pt, rotation?: deg (about centre)} → {id}",
|
||||
has_doc,
|
||||
rectangle
|
||||
),
|
||||
cmd!("shape.ellipse", "Ellipse", [], None, "{x, y, width, height, rotation?: deg (about centre)} → {id}", has_doc, ellipse),
|
||||
cmd!("shape.polygon", "Polygon", [], None, "{cx, cy, radius, sides=6, rotation?: deg} → {id}", has_doc, polygon),
|
||||
cmd!("shape.star", "Star", [], None, "{cx, cy, radius1, radius2, points=5, rotation?: deg} → {id}", has_doc, star),
|
||||
cmd!(
|
||||
@@ -163,6 +171,17 @@ fn rect_of(p: &Value, cmd: &str) -> Result<Rect> {
|
||||
Ok(Rect::new(x, y, x + w, y + h).abs())
|
||||
}
|
||||
|
||||
fn rect_transform(r: Rect, p: &Value, cmd: &str) -> Result<Affine> {
|
||||
let rotation = if p.get("rotation").is_some() { f64_req(p, "rotation", cmd)? } else { 0.0 };
|
||||
if !rotation.is_finite() {
|
||||
return Err(bad(cmd, "rotation must be finite"));
|
||||
}
|
||||
if rotation == 0.0 {
|
||||
return Ok(Affine::translate(r.origin().to_vec2()));
|
||||
}
|
||||
Ok(Affine::translate(r.center().to_vec2()) * Affine::rotate(rotation.to_radians()) * Affine::translate((-r.width() / 2.0, -r.height() / 2.0)))
|
||||
}
|
||||
|
||||
fn rectangle(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let r = rect_of(p, "shape.rectangle")?;
|
||||
let radius = f64_or(p, "radius", 0.0).max(0.0);
|
||||
@@ -171,7 +190,7 @@ fn rectangle(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
h: r.height(),
|
||||
radii: [radius; 4],
|
||||
kinds: Default::default(),
|
||||
xf: Affine::translate(r.origin().to_vec2()),
|
||||
xf: rect_transform(r, p, "shape.rectangle")?,
|
||||
};
|
||||
let label = if radius > 0.0 { "Rounded Rectangle" } else { "Rectangle" };
|
||||
add_art(s, label, path_kind(live.to_path(), Some(live)), None)
|
||||
@@ -179,7 +198,7 @@ fn rectangle(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn ellipse(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let r = rect_of(p, "shape.ellipse")?;
|
||||
let live = LiveShape::Ellipse { w: r.width(), h: r.height(), pie: (0.0, 360.0), xf: Affine::translate(r.origin().to_vec2()) };
|
||||
let live = LiveShape::Ellipse { w: r.width(), h: r.height(), pie: (0.0, 360.0), xf: rect_transform(r, p, "shape.ellipse")? };
|
||||
add_art(s, "Ellipse", path_kind(live.to_path(), Some(live)), None)
|
||||
}
|
||||
|
||||
|
||||
@@ -59,6 +59,7 @@ pub mod rasterfx;
|
||||
mod recolor;
|
||||
pub mod recovery;
|
||||
mod select;
|
||||
pub(crate) mod shaper;
|
||||
pub(crate) mod slices;
|
||||
mod stroke;
|
||||
mod style;
|
||||
@@ -231,6 +232,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(docinfo::specs());
|
||||
v.extend(panelcmds::specs());
|
||||
v.extend(buildcmds::specs());
|
||||
v.extend(shaper::specs());
|
||||
v.extend(brushsym::specs());
|
||||
v.extend(patterncmds::specs());
|
||||
v.extend(prefscmds::specs());
|
||||
|
||||
@@ -722,7 +722,8 @@ fn isolate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if st.doc.node(id).is_none_or(|n| !n.is_container()) {
|
||||
return Err(bad("object.isolate", "only groups and layers can be isolated"));
|
||||
}
|
||||
st.isolation = Some(id);
|
||||
let isolation = st.doc.node(id).filter(|n| n.shaper.is_some()).and_then(|n| n.children()).and_then(|c| c.first()).map_or(id, |n| n.id);
|
||||
st.isolation = Some(isolation);
|
||||
st.selection.clear();
|
||||
st.revision += 1;
|
||||
ok()
|
||||
@@ -732,7 +733,8 @@ fn exit_isolation(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let st = s.doc_mut()?;
|
||||
if let Some(i) = st.isolation.take() {
|
||||
super::distortcmds::finish_edit_text(st, i);
|
||||
st.selection.set([i]);
|
||||
let target = st.doc.parent_of(i).filter(|p| st.doc.node(*p).is_some_and(|n| n.shaper.is_some())).unwrap_or(i);
|
||||
st.selection.set([target]);
|
||||
}
|
||||
st.revision += 1;
|
||||
ok()
|
||||
|
||||
@@ -0,0 +1,771 @@
|
||||
//! Shaper compositions are groups of generated fills/strokes with transparent, editable source
|
||||
//! art retained underneath. Recipes select topology regions; source edits re-evaluate them in
|
||||
//! the same undo transaction. Rendering and exporters only see ordinary groups and paths.
|
||||
use super::*;
|
||||
use serde_json::{Value, json};
|
||||
use std::{collections::BTreeMap, sync::Arc};
|
||||
use vectorcraft_doc::shaper::{EdgeEdit, FILL_PREFIX, FaceEdit, FaceSelector, SOURCES, STROKE_PREFIX, ShaperSpec};
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Document, Node, NodeId, NodeKind};
|
||||
use vectorcraft_geom::{FillRule, PathData, Point, Rect, Shape as _};
|
||||
use vectorcraft_pathops as po;
|
||||
use vectorcraft_tools::builder::{self as b, BuilderMap, Hit};
|
||||
|
||||
const MAX_SOURCES: usize = 64;
|
||||
const MAX_POINTS: usize = 4096;
|
||||
const MAX_EDITS: usize = 8192;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"shaper.scribble",
|
||||
"Shaper",
|
||||
[],
|
||||
None,
|
||||
"{points: [[x,y]…], ids?: [id…] (default: nearby art), tolerance?: pt (4)} interior scribbles remove fills; scribbles through several regions merge with the origin fill; ending outside removes fills and boundary strokes; outside scribbles erase stroke pieces. Originals remain editable → {id, regions, strokes}",
|
||||
has_doc,
|
||||
scribble
|
||||
),
|
||||
cmd!(
|
||||
"shaper.select",
|
||||
"Select Shaper Face",
|
||||
[],
|
||||
None,
|
||||
"{point: [x,y], source?: bool} click selects a group, then a face; source enters construction isolation and selects an original → {id}",
|
||||
has_doc,
|
||||
select
|
||||
),
|
||||
cmd!(
|
||||
"shaper.release",
|
||||
"Release Shaper Group",
|
||||
["Object", "Shaper"],
|
||||
None,
|
||||
"{id? (default: selection)} restore the original paths → {ids}",
|
||||
has_doc,
|
||||
release
|
||||
),
|
||||
cmd!(
|
||||
"shaper.expand",
|
||||
"Expand Shaper Group",
|
||||
["Object", "Shaper"],
|
||||
None,
|
||||
"{id? (default: selection)} keep the visible result as ordinary paths → {id}",
|
||||
has_doc,
|
||||
expand
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
fn source_group(n: &Node) -> Option<&Node> {
|
||||
n.shaper.as_ref()?;
|
||||
n.children()?.first().map(AsRef::as_ref).filter(|g| g.name.as_deref() == Some(SOURCES))
|
||||
}
|
||||
fn sources(n: &Node) -> Vec<Arc<Node>> {
|
||||
source_group(n).and_then(Node::children).cloned().unwrap_or_default()
|
||||
}
|
||||
fn finite(p: Point) -> bool {
|
||||
p.x.is_finite() && p.y.is_finite() && p.x.abs() <= 1e9 && p.y.abs() <= 1e9
|
||||
}
|
||||
|
||||
fn map_of(src: &[Arc<Node>]) -> Result<BuilderMap> {
|
||||
if src.is_empty() || src.len() > MAX_SOURCES {
|
||||
return Err(bad("shaper.scribble", "Shaper needs 1–64 source paths"));
|
||||
}
|
||||
let mut count = 0usize;
|
||||
let mut shapes = vec![];
|
||||
for (i, n) in src.iter().enumerate() {
|
||||
let (mut path, rule) = b::node_outline(n).ok_or_else(|| bad("shaper.scribble", "use paths or compound paths"))?;
|
||||
count = count.saturating_add(path.subpaths.iter().map(|s| s.segment_count()).sum::<usize>());
|
||||
if count > 4096 || path.subpaths.iter().flat_map(|s| &s.anchors).any(|a| !finite(a.p) || !finite(a.h_in) || !finite(a.h_out)) {
|
||||
return Err(bad("shaper.scribble", "source paths are too large for Shaper"));
|
||||
}
|
||||
if !n.visible {
|
||||
path = PathData::default();
|
||||
} else if !n.appearance.fill_paint().is_none() {
|
||||
for sub in path.subpaths.iter_mut().filter(|s| !s.closed) {
|
||||
sub.closed = po::encloses_area(sub);
|
||||
}
|
||||
}
|
||||
shapes.push(po::Shape::new(path, rule, i as u64));
|
||||
}
|
||||
let arrangement = po::shape_builder(&shapes, true);
|
||||
let bounded = |pieces: Vec<po::Shape>| pieces.into_iter().filter_map(|p| p.path.bounds().map(|b| (p, b))).collect();
|
||||
let regions = arrangement
|
||||
.regions
|
||||
.into_iter()
|
||||
.filter_map(|r| {
|
||||
let bounds = r.path.bounds()?;
|
||||
let path = r.path.to_bezpath();
|
||||
Some((r, path, bounds))
|
||||
})
|
||||
.collect();
|
||||
let map = BuilderMap { shapes, regions, edges: bounded(arrangement.edges), lines: bounded(arrangement.lines) };
|
||||
if map.regions.is_empty() && map.lines.is_empty() && map.edges.is_empty() {
|
||||
return Err(bad("shaper.scribble", "these paths are too degenerate for Shaper"));
|
||||
}
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
fn relative(src: &[Arc<Node>], i: usize, p: Point) -> Point {
|
||||
let b = src.get(i).and_then(|n| n.geometric_bounds()).unwrap_or(Rect::ZERO);
|
||||
Point::new((p.x - b.x0) / b.width().max(1e-9), (p.y - b.y0) / b.height().max(1e-9))
|
||||
}
|
||||
fn absolute(src: &[Arc<Node>], i: usize, p: Point) -> Point {
|
||||
let b = src.get(i).and_then(|n| n.geometric_bounds()).unwrap_or(Rect::ZERO);
|
||||
Point::new(b.x0 + p.x * b.width(), b.y0 + p.y * b.height())
|
||||
}
|
||||
fn face_selector(map: &BuilderMap, src: &[Arc<Node>], i: usize) -> Option<FaceSelector> {
|
||||
let (r, _, bounds) = map.regions.get(i)?;
|
||||
let source = *r.sources.first()?;
|
||||
let point = po::interior_point(&r.path).unwrap_or(bounds.center());
|
||||
Some(FaceSelector { sources: r.sources.clone(), anchor: relative(src, source, point) })
|
||||
}
|
||||
fn selected_face(map: &BuilderMap, src: &[Arc<Node>], selector: &FaceSelector) -> Option<usize> {
|
||||
let point = absolute(src, *selector.sources.first()?, selector.anchor);
|
||||
map.regions
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, (r, _, _))| r.sources == selector.sources)
|
||||
.min_by(|(_, a), (_, b)| {
|
||||
let distance = |r: &b::Face| if r.1.winding(point) != 0 { 0.0 } else { r.2.center().distance(point) };
|
||||
distance(a).total_cmp(&distance(b))
|
||||
})
|
||||
.map(|(i, _)| i)
|
||||
}
|
||||
fn piece_point(path: &PathData) -> Option<Point> {
|
||||
use vectorcraft_geom::ParamCurve as _;
|
||||
let sub = path.subpaths.first()?;
|
||||
(sub.segment_count() > 0).then(|| sub.segment(sub.segment_count() / 2).eval(0.5))
|
||||
}
|
||||
fn pieces(map: &BuilderMap) -> Vec<&po::Shape> {
|
||||
map.edges.iter().chain(&map.lines).map(|(p, _)| p).collect()
|
||||
}
|
||||
fn side_sources(map: &BuilderMap, p: &po::Shape) -> [Vec<usize>; 2] {
|
||||
let mut sides = map.sides(p).map(|i| i.and_then(|i| map.regions.get(i)).map_or(vec![], |r| r.0.sources.clone()));
|
||||
sides.sort();
|
||||
sides
|
||||
}
|
||||
fn edge_selector(map: &BuilderMap, src: &[Arc<Node>], piece: &po::Shape) -> Option<EdgeEdit> {
|
||||
let source = usize::try_from(piece.key).ok()?;
|
||||
Some(EdgeEdit { source, sides: side_sources(map, piece), anchor: relative(src, source, piece_point(&piece.path)?), erase: true, paint: None })
|
||||
}
|
||||
fn selected_edge(map: &BuilderMap, src: &[Arc<Node>], edit: &EdgeEdit) -> Option<usize> {
|
||||
let point = absolute(src, edit.source, edit.anchor);
|
||||
pieces(map)
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, p)| p.key == edit.source as u64 && side_sources(map, p) == edit.sides)
|
||||
.filter_map(|(i, p)| p.path.nearest(point).map(|(_, _, _, _, distance)| (i, distance)))
|
||||
.min_by(|a, b| a.1.total_cmp(&b.1))
|
||||
.map(|p| p.0)
|
||||
}
|
||||
fn face_edits<'a>(map: &BuilderMap, src: &[Arc<Node>], spec: &'a ShaperSpec) -> BTreeMap<usize, &'a FaceEdit> {
|
||||
spec.faces.iter().filter_map(|e| selected_face(map, src, &e.selector).map(|i| (i, e))).collect()
|
||||
}
|
||||
fn fill_source(src: &[Arc<Node>], r: &po::Region) -> Option<usize> {
|
||||
r.sources.iter().rev().copied().find(|i| src.get(*i).is_some_and(|s| s.visible && !s.appearance.fill_paint().is_none()))
|
||||
}
|
||||
/// Regions separated only by covered strokes are one visible face. A visible line
|
||||
/// through a circle still separates two faces, even though their fills have the same source.
|
||||
fn visible_components(map: &BuilderMap, src: &[Arc<Node>], edits: &BTreeMap<usize, &FaceEdit>) -> Vec<usize> {
|
||||
fn root(parents: &[usize], mut i: usize) -> usize {
|
||||
for _ in 0..parents.len() {
|
||||
let Some(&next) = parents.get(i) else { return i };
|
||||
if next == i {
|
||||
return i;
|
||||
}
|
||||
i = next;
|
||||
}
|
||||
i
|
||||
}
|
||||
let owner = |i: usize| {
|
||||
let edit = edits.get(&i);
|
||||
if edit.is_some_and(|e| e.erase || e.paint.as_ref().is_some_and(|a| a.fill_paint().is_none())) {
|
||||
return None;
|
||||
}
|
||||
edit.and_then(|e| e.paint_source).or_else(|| map.regions.get(i).and_then(|r| fill_source(src, &r.0)))
|
||||
};
|
||||
let mut parents: Vec<usize> = (0..map.regions.len()).collect();
|
||||
for piece in pieces(map) {
|
||||
let [Some(a), Some(b)] = map.sides(piece) else { continue };
|
||||
let (Some(left), Some(right)) = (owner(a), owner(b)) else { continue };
|
||||
let merge = |i| edits.get(&i).and_then(|e| e.merge);
|
||||
let merged = merge(a).is_some() && merge(a) == merge(b);
|
||||
let covered = left as u64 > piece.key && right as u64 > piece.key;
|
||||
let unstroked = src.get(piece.key as usize).is_none_or(|n| !n.visible || n.appearance.stroke_paint().is_none());
|
||||
if (left == right || merged) && (merged || covered || unstroked) {
|
||||
let (a, b) = (root(&parents, a), root(&parents, b));
|
||||
if let Some(parent) = parents.get_mut(a) {
|
||||
*parent = b;
|
||||
}
|
||||
}
|
||||
}
|
||||
(0..parents.len()).map(|i| root(&parents, i)).collect()
|
||||
}
|
||||
|
||||
fn fill_style(mut a: Appearance) -> Appearance {
|
||||
a.items.retain(AppearanceItem::is_fill);
|
||||
a
|
||||
}
|
||||
fn stroke_style(mut a: Appearance) -> Appearance {
|
||||
a.items.retain(|i| !i.is_fill());
|
||||
a
|
||||
}
|
||||
|
||||
/// Join compatible strokes at two-way junctions. Branches stay separate; a line
|
||||
/// crossing a circle must not turn into the circle's outline at the intersection.
|
||||
fn join_strokes(nodes: Vec<Arc<Node>>) -> Vec<Arc<Node>> {
|
||||
const TOL: f64 = 1e-5;
|
||||
type End = (usize, bool);
|
||||
let mut buckets: BTreeMap<(i64, i64), Vec<(End, Point)>> = BTreeMap::new();
|
||||
let key = |p: Point| finite(p).then(|| ((p.x / TOL).floor() as i64, (p.y / TOL).floor() as i64));
|
||||
for (i, n) in nodes.iter().enumerate() {
|
||||
let Some(sp) = n.path_data().filter(|p| p.subpaths.len() == 1).and_then(|p| p.subpaths.first()).filter(|p| !p.closed) else { continue };
|
||||
for (last, a) in [(false, sp.anchors.first()), (true, sp.anchors.last())] {
|
||||
if let Some(a) = a
|
||||
&& let Some(key) = key(a.p)
|
||||
{
|
||||
buckets.entry(key).or_default().push(((i, last), a.p));
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut connections: BTreeMap<End, End> = BTreeMap::new();
|
||||
for entries in buckets.values() {
|
||||
for &(end, p) in entries {
|
||||
let Some((x, y)) = key(p) else { continue };
|
||||
let mut near = vec![];
|
||||
for dx in -1..=1 {
|
||||
for dy in -1..=1 {
|
||||
if let Some(entries) = buckets.get(&(x + dx, y + dy)) {
|
||||
near.extend(entries.iter().filter(|(_, q)| p.distance(*q) <= TOL).map(|(end, _)| *end));
|
||||
}
|
||||
}
|
||||
}
|
||||
if near.len() != 2 {
|
||||
continue;
|
||||
}
|
||||
let Some(other) = near.into_iter().find(|e| *e != end) else { continue };
|
||||
let Some((a, b)) = nodes.get(end.0).zip(nodes.get(other.0)) else { continue };
|
||||
if a.appearance == b.appearance && a.opacity == b.opacity && a.blend == b.blend {
|
||||
connections.insert(end, other);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
let mut out = vec![];
|
||||
// Open chains first, then closed loops. This avoids beginning in a chain's middle.
|
||||
let starts = nodes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.flat_map(|(i, _)| [(i, false), (i, true)])
|
||||
.filter(|e| !connections.contains_key(e))
|
||||
.chain(nodes.iter().enumerate().map(|(i, _)| (i, false)));
|
||||
for start in starts {
|
||||
if seen.contains(&start.0) {
|
||||
continue;
|
||||
}
|
||||
if let Some(node) = nodes.get(start.0).filter(|n| n.path_data().is_none_or(|p| p.subpaths.len() != 1)) {
|
||||
seen.insert(start.0);
|
||||
out.push(node.clone());
|
||||
continue;
|
||||
}
|
||||
let mut end = start;
|
||||
let mut prototype = start.0;
|
||||
let mut path = vectorcraft_geom::SubPath::default();
|
||||
loop {
|
||||
if !seen.insert(end.0) {
|
||||
break;
|
||||
}
|
||||
prototype = prototype.min(end.0);
|
||||
let Some(mut next) = nodes.get(end.0).and_then(|n| n.path_data()).and_then(|p| p.subpaths.first()).cloned() else { break };
|
||||
if end.1 {
|
||||
next.reverse();
|
||||
}
|
||||
if path.anchors.is_empty() {
|
||||
path = next;
|
||||
} else {
|
||||
if let Some((a, b)) = path.anchors.last_mut().zip(next.anchors.first()) {
|
||||
*a = vectorcraft_geom::Anchor::with_handles(a.p, a.h_in, b.h_out);
|
||||
}
|
||||
path.anchors.extend(next.anchors.into_iter().skip(1));
|
||||
}
|
||||
let Some(&next) = connections.get(&(end.0, !end.1)) else { break };
|
||||
if next == start {
|
||||
if let Some(last) = path.anchors.pop()
|
||||
&& let Some(first) = path.anchors.first_mut()
|
||||
{
|
||||
*first = vectorcraft_geom::Anchor::with_handles(first.p, last.h_in, first.h_out);
|
||||
}
|
||||
path.closed = true;
|
||||
break;
|
||||
}
|
||||
end = next;
|
||||
}
|
||||
if let Some(mut node) = nodes.get(prototype).cloned() {
|
||||
if let Some(p) = Arc::make_mut(&mut node).path_data_mut() {
|
||||
*p = PathData::new(vec![path]);
|
||||
}
|
||||
out.push(node);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn generated(d: &mut Document, src: &[Arc<Node>], spec: &ShaperSpec, map: &BuilderMap) -> Result<Vec<Arc<Node>>> {
|
||||
if spec.faces.len() + spec.edges.len() > MAX_EDITS {
|
||||
return Err(bad("shaper.scribble", "too many Shaper edits"));
|
||||
}
|
||||
let edits = face_edits(map, src, spec);
|
||||
let mut fills: BTreeMap<(Option<u64>, usize), Vec<usize>> = BTreeMap::new();
|
||||
for (i, (r, _, _)) in map.regions.iter().enumerate() {
|
||||
let edit = edits.get(&i);
|
||||
if edit.is_some_and(|e| e.erase) {
|
||||
continue;
|
||||
}
|
||||
if edit.and_then(|e| e.paint_source).or_else(|| fill_source(src, r)).is_none() {
|
||||
continue;
|
||||
}
|
||||
let merge = edit.and_then(|e| e.merge);
|
||||
fills.entry((merge, if merge.is_some() { 0 } else { i })).or_default().push(i);
|
||||
}
|
||||
let mut out = vec![];
|
||||
for indices in fills.values() {
|
||||
let Some(&i) = indices.first() else { continue };
|
||||
let Some((region, _, _)) = map.regions.get(i) else { continue };
|
||||
let edit = edits.get(&i);
|
||||
let Some(source) = edit.and_then(|e| e.paint_source).or_else(|| fill_source(src, region)).and_then(|i| src.get(i)) else { continue };
|
||||
let path = po::unite_all(&indices.iter().filter_map(|i| map.regions.get(*i).map(|r| (&r.0.path, FillRule::NonZero))).collect::<Vec<_>>());
|
||||
let style = edit.and_then(|e| e.paint.clone()).unwrap_or_else(|| source.appearance.clone());
|
||||
let mut n = Node::path(d.alloc_id(), path, fill_style(style));
|
||||
n.opacity = source.opacity;
|
||||
n.blend = source.blend;
|
||||
n.name = Some(format!("{FILL_PREFIX}{i}"));
|
||||
out.push(Arc::new(n));
|
||||
}
|
||||
let edge_edits: BTreeMap<usize, &EdgeEdit> = spec.edges.iter().filter_map(|e| selected_edge(map, src, e).map(|i| (i, e))).collect();
|
||||
let mut strokes = vec![];
|
||||
for (i, piece) in pieces(map).into_iter().enumerate() {
|
||||
let sides = map.sides(piece);
|
||||
let merge = |r: Option<usize>| r.and_then(|r| edits.get(&r)).and_then(|e| e.merge);
|
||||
let merged_inside = merge(sides[0]).is_some() && merge(sides[0]) == merge(sides[1]);
|
||||
// An exiting scribble removes the exposed stroke around the deleted area.
|
||||
// Its shared boundary with surviving art still closes that art's contour.
|
||||
let erased_boundary =
|
||||
sides.iter().any(Option::is_some) && sides.iter().all(|r| r.is_none_or(|r| edits.get(&r).is_some_and(|e| e.erase && e.erase_edges)));
|
||||
let occluded = sides
|
||||
.iter()
|
||||
.all(|r| r.and_then(|r| map.regions.get(r)).and_then(|r| fill_source(src, &r.0)).is_some_and(|source| source as u64 > piece.key));
|
||||
if merged_inside || erased_boundary || occluded || edge_edits.get(&i).is_some_and(|e| e.erase) {
|
||||
continue;
|
||||
}
|
||||
let Some(source) = src.get(piece.key as usize) else { continue };
|
||||
let style = edge_edits.get(&i).and_then(|e| e.paint.clone()).unwrap_or_else(|| source.appearance.clone());
|
||||
if style.stroke_paint().is_none() {
|
||||
continue;
|
||||
}
|
||||
let mut n = Node::path(d.alloc_id(), piece.path.clone(), stroke_style(style));
|
||||
n.opacity = source.opacity;
|
||||
n.blend = source.blend;
|
||||
n.name = Some(format!("{STROKE_PREFIX}{i}"));
|
||||
strokes.push(Arc::new(n));
|
||||
}
|
||||
out.extend(join_strokes(strokes));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Re-evaluate only groups whose original art changed. Errors reach Session::edit's rollback.
|
||||
pub(crate) fn refresh(before: &Document, d: &mut Document) -> Result<()> {
|
||||
let mut ids = vec![];
|
||||
d.walk(|n| {
|
||||
if n.shaper.is_some() {
|
||||
ids.push(n.id)
|
||||
}
|
||||
});
|
||||
for id in ids {
|
||||
let Some(current) = d.node(id).cloned() else { continue };
|
||||
let Some(old) = before.node(id) else { continue };
|
||||
let src = sources(¤t);
|
||||
let source_changed = sources(old) != src;
|
||||
let output_changed = current.children().is_some_and(|children| {
|
||||
children
|
||||
.iter()
|
||||
.skip(1)
|
||||
.any(|n| old.children().and_then(|c| c.iter().find(|o| o.id == n.id)).is_some_and(|o| o.appearance != n.appearance))
|
||||
});
|
||||
if !source_changed && !output_changed {
|
||||
continue;
|
||||
}
|
||||
if src.is_empty() {
|
||||
d.remove(id)?;
|
||||
continue;
|
||||
}
|
||||
let map = map_of(&src)?;
|
||||
let mut spec = current.shaper.as_deref().cloned().unwrap_or_default();
|
||||
let previous = sources(old);
|
||||
if previous.iter().map(|s| s.id).ne(src.iter().map(|s| s.id)) {
|
||||
// Source insertion, deletion or reordering updates the index-based recipes.
|
||||
let index = |i: usize| previous.get(i).and_then(|old| src.iter().position(|n| n.id == old.id));
|
||||
spec.faces.retain_mut(|e| {
|
||||
let Some(indices) = e.selector.sources.iter().map(|i| index(*i)).collect::<Option<Vec<_>>>() else { return false };
|
||||
let paint_source = match e.paint_source {
|
||||
Some(i) => {
|
||||
let Some(i) = index(i) else { return false };
|
||||
Some(i)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
e.selector.sources = indices;
|
||||
e.selector.sources.sort();
|
||||
e.paint_source = paint_source;
|
||||
true
|
||||
});
|
||||
spec.edges.retain_mut(|e| {
|
||||
let Some(source) = index(e.source) else { return false };
|
||||
for side in &mut e.sides {
|
||||
let Some(indices) = side.iter().map(|i| index(*i)).collect::<Option<Vec<_>>>() else { return false };
|
||||
*side = indices;
|
||||
side.sort();
|
||||
}
|
||||
e.sides.sort();
|
||||
e.source = source;
|
||||
true
|
||||
});
|
||||
}
|
||||
if output_changed {
|
||||
for n in current.children().into_iter().flatten().skip(1) {
|
||||
let Some(o) = old.children().and_then(|c| c.iter().find(|o| o.id == n.id)) else { continue };
|
||||
if o.appearance == n.appearance {
|
||||
continue;
|
||||
}
|
||||
if let Some(i) = n.name.as_deref().and_then(|s| s.strip_prefix(FILL_PREFIX)).and_then(|s| s.parse::<usize>().ok()) {
|
||||
let existing = face_edits(&map, &src, &spec);
|
||||
let merge = existing.get(&i).and_then(|e| e.merge);
|
||||
let components = visible_components(&map, &src, &existing);
|
||||
let related: Vec<usize> = if let Some(m) = merge {
|
||||
existing.iter().filter(|(_, e)| e.merge == Some(m)).map(|(i, _)| *i).collect()
|
||||
} else {
|
||||
components.iter().enumerate().filter(|(_, c)| Some(*c) == components.get(i)).map(|(i, _)| i).collect()
|
||||
};
|
||||
for i in related {
|
||||
let Some(selector) = face_selector(&map, &src, i) else { continue };
|
||||
if let Some(e) = spec.faces.iter_mut().rev().find(|e| e.selector == selector) {
|
||||
e.paint = Some(n.appearance.clone());
|
||||
} else {
|
||||
spec.faces.push(FaceEdit {
|
||||
selector,
|
||||
erase: false,
|
||||
erase_edges: false,
|
||||
paint_source: None,
|
||||
merge,
|
||||
paint: Some(n.appearance.clone()),
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if n.name.as_deref().is_some_and(|s| s.starts_with(STROKE_PREFIX)) {
|
||||
// One joined output may contain pieces from several original paths.
|
||||
for piece in pieces(&map) {
|
||||
let contained = piece
|
||||
.path
|
||||
.subpaths
|
||||
.iter()
|
||||
.flat_map(|s| &s.anchors)
|
||||
.all(|a| n.path_data().and_then(|p| p.nearest(a.p)).is_some_and(|p| p.4 < 1e-5))
|
||||
&& piece_point(&piece.path).is_some_and(|point| n.path_data().and_then(|p| p.nearest(point)).is_some_and(|p| p.4 < 1e-5));
|
||||
if contained && let Some(mut e) = edge_selector(&map, &src, piece) {
|
||||
e.erase = false;
|
||||
e.paint = Some(n.appearance.clone());
|
||||
spec.edges.push(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut children = current.children().and_then(|c| c.first()).cloned().into_iter().collect::<Vec<_>>();
|
||||
let old_ids: BTreeMap<&str, NodeId> =
|
||||
current.children().into_iter().flatten().skip(1).filter_map(|n| n.name.as_deref().map(|name| (name, n.id))).collect();
|
||||
for mut n in generated(d, &src, &spec, &map)? {
|
||||
if let Some(id) = n.name.as_deref().and_then(|name| old_ids.get(name)) {
|
||||
Arc::make_mut(&mut n).id = *id;
|
||||
}
|
||||
children.push(n);
|
||||
}
|
||||
let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?;
|
||||
n.shaper = Some(Box::new(spec));
|
||||
if let Some(ch) = n.children_mut() {
|
||||
*ch = children;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn candidates(s: &Session, p: &Value, points: &[Point], tolerance: f64) -> Result<Vec<NodeId>> {
|
||||
let st = s.doc()?;
|
||||
let area = points.iter().fold(Rect::new(f64::MAX, f64::MAX, f64::MIN, f64::MIN), |b, p| b.union_pt(*p)).inflate(tolerance, tolerance);
|
||||
let supplied = p.get("ids").and_then(Value::as_array).map(|v| v.iter().filter_map(Value::as_u64).map(NodeId).collect::<Vec<_>>());
|
||||
let expand = supplied.is_none() && st.selection.objects.len() < 2;
|
||||
let ids = supplied.unwrap_or_else(|| if st.selection.objects.len() > 1 { st.selection.objects.clone() } else { st.doc.selectable_art() });
|
||||
let eligible: Vec<NodeId> = ids
|
||||
.into_iter()
|
||||
.filter(|id| {
|
||||
st.doc.is_editable(*id)
|
||||
&& st.doc.node(*id).is_some_and(|n| {
|
||||
!matches!(n.kind, NodeKind::Path { guide: true, .. } | NodeKind::Path { clipping: true, .. })
|
||||
&& (n.shaper.is_some() || b::node_outline(n).is_some())
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let bounds = |id| st.doc.node(id).and_then(Node::geometric_bounds).map(|b| b.inflate(tolerance, tolerance));
|
||||
let overlaps = |a: Rect, b: Rect| a.intersect(b).area() > 0.0;
|
||||
let mut ids: Vec<NodeId> = eligible.iter().copied().filter(|id| bounds(*id).is_some_and(|b| overlaps(b, area))).collect();
|
||||
if expand {
|
||||
// A protruding line must still be split at the shape it passes through, even when
|
||||
// the scribble itself never enters that shape. Include the connected neighbourhood.
|
||||
loop {
|
||||
let next: Vec<NodeId> = eligible
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|id| {
|
||||
!ids.contains(id)
|
||||
&& bounds(*id).is_some_and(|bb| {
|
||||
ids.iter()
|
||||
.any(|other| st.doc.parent_of(*other) == st.doc.parent_of(*id) && bounds(*other).is_some_and(|b| overlaps(bb, b)))
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
if next.is_empty() {
|
||||
break;
|
||||
}
|
||||
ids.extend(next);
|
||||
if ids.len() > MAX_SOURCES {
|
||||
return Err(bad("shaper.scribble", "too many overlapping paths for Shaper"));
|
||||
}
|
||||
}
|
||||
}
|
||||
let ids = b::sorted_roots(&st.doc, &ids);
|
||||
let parent = ids.first().and_then(|id| st.doc.position(*id)).map(|p| p.0);
|
||||
if ids.iter().any(|id| st.doc.position(*id).map(|p| p.0) != parent) {
|
||||
return Err(bad("shaper.scribble", "overlapping Shaper paths must be in the same layer or group"));
|
||||
}
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
fn scribble(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "shaper.scribble";
|
||||
let input = p
|
||||
.get("points")
|
||||
.and_then(Value::as_array)
|
||||
.filter(|a| !a.is_empty() && a.len() <= MAX_POINTS)
|
||||
.ok_or_else(|| bad(C, "points must contain 1–4096 coordinates"))?;
|
||||
let pts = input
|
||||
.iter()
|
||||
.map(|v| point_param(&json!({"p": v}), "p").filter(|p| finite(*p)).ok_or_else(|| bad(C, "invalid point")))
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
let tolerance = f64_or(p, "tolerance", 4.0);
|
||||
if !tolerance.is_finite() || !(0.01..=1000.0).contains(&tolerance) {
|
||||
return Err(bad(C, "invalid tolerance"));
|
||||
}
|
||||
let step = (tolerance / 2.0).max(0.5);
|
||||
let sample_count = pts.windows(2).map(|w| (w[0].distance(w[1]) / step).ceil()).sum::<f64>();
|
||||
if sample_count > 65_536.0 {
|
||||
return Err(bad(C, "scribble is too long"));
|
||||
}
|
||||
let points = b::sample_polyline(&pts, step);
|
||||
let roots = candidates(s, p, &points, tolerance)?;
|
||||
if roots.is_empty() {
|
||||
return Ok(json!({"id": null, "regions": 0, "strokes": 0}));
|
||||
}
|
||||
let mut src = vec![];
|
||||
let mut spec = ShaperSpec::default();
|
||||
let mut next_merge = 1u64;
|
||||
for id in &roots {
|
||||
let n = s.doc()?.doc.node(*id).ok_or(EngineError::NoNode(*id))?;
|
||||
if let Some(previous) = n.shaper.as_deref() {
|
||||
let offset = src.len();
|
||||
let merge_offset = next_merge;
|
||||
for mut e in previous.faces.clone() {
|
||||
for i in &mut e.selector.sources {
|
||||
*i = i.saturating_add(offset);
|
||||
}
|
||||
e.paint_source = e.paint_source.map(|i| i.saturating_add(offset));
|
||||
e.merge = e.merge.map(|i| i.saturating_add(merge_offset));
|
||||
next_merge = next_merge.max(e.merge.unwrap_or(0).saturating_add(1));
|
||||
spec.faces.push(e);
|
||||
}
|
||||
for mut e in previous.edges.clone() {
|
||||
e.source = e.source.saturating_add(offset);
|
||||
for side in &mut e.sides {
|
||||
for i in side {
|
||||
*i = i.saturating_add(offset);
|
||||
}
|
||||
}
|
||||
spec.edges.push(e);
|
||||
}
|
||||
src.extend(sources(n));
|
||||
} else {
|
||||
src.push(Arc::new(n.clone()));
|
||||
}
|
||||
}
|
||||
let map = map_of(&src)?;
|
||||
let edits = face_edits(&map, &src, &spec);
|
||||
let filled = |i: usize| edits.get(&i).is_none_or(|e| !e.erase) && map.regions.get(i).and_then(|r| fill_source(&src, &r.0)).is_some();
|
||||
let components = visible_components(&map, &src, &edits);
|
||||
let faces: std::collections::BTreeSet<usize> =
|
||||
points.iter().filter_map(|p| map.region_at(*p)).filter(|i| filled(*i)).filter_map(|i| components.get(i).copied()).collect();
|
||||
let touched: Vec<usize> = components.iter().enumerate().filter(|(_, c)| faces.contains(c)).map(|(i, _)| i).collect();
|
||||
let first = points.first().copied().unwrap_or(Point::ZERO);
|
||||
let last = points.last().copied().unwrap_or(first);
|
||||
let origin = map.region_at(first).filter(|i| filled(*i));
|
||||
let exits = map.region_at(last).is_none();
|
||||
let merge = origin.is_some() && faces.len() > 1 && !exits;
|
||||
let paint_source =
|
||||
origin.and_then(|i| edits.get(&i).and_then(|e| e.paint_source).or_else(|| map.regions.get(i).and_then(|r| fill_source(&src, &r.0))));
|
||||
let origin_paint = origin.and_then(|i| edits.get(&i).and_then(|e| e.paint.clone()));
|
||||
for &i in &touched {
|
||||
if let Some(selector) = face_selector(&map, &src, i) {
|
||||
spec.faces.push(FaceEdit {
|
||||
selector,
|
||||
erase: !merge,
|
||||
erase_edges: exits,
|
||||
paint_source,
|
||||
merge: merge.then_some(next_merge),
|
||||
paint: origin_paint.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
let mut strokes = 0;
|
||||
if touched.is_empty() {
|
||||
let mut piece_ids = vec![];
|
||||
for point in &points {
|
||||
let index = match map.hit(*point, Some(tolerance)) {
|
||||
Some(Hit::Line(i)) => Some(map.edges.len() + i),
|
||||
Some(Hit::Edge(i)) => Some(i),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(i) = index
|
||||
&& !piece_ids.contains(&i)
|
||||
{
|
||||
piece_ids.push(i);
|
||||
}
|
||||
}
|
||||
for i in piece_ids {
|
||||
if let Some(e) = pieces(&map).get(i).and_then(|p| edge_selector(&map, &src, p)) {
|
||||
spec.edges.push(e);
|
||||
strokes += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if touched.is_empty() && strokes == 0 {
|
||||
return Ok(json!({"id": null, "regions": 0, "strokes": 0}));
|
||||
}
|
||||
let id = s.edit("Shaper", |d, sel| {
|
||||
let top = *roots.last().ok_or_else(|| bad(C, "no source art"))?;
|
||||
let (parent, index, _) = d.position(top).ok_or(EngineError::NoNode(top))?;
|
||||
let earlier = roots.iter().filter(|id| **id != top && d.position(**id).is_some_and(|p| p.1 < index)).count();
|
||||
let source_id = d.alloc_id();
|
||||
let mut source = Node::group(source_id, src.clone());
|
||||
source.name = Some(SOURCES.into());
|
||||
source.opacity = 0.0;
|
||||
let mut children = vec![Arc::new(source)];
|
||||
children.extend(generated(d, &src, &spec, &map)?);
|
||||
let id = d.alloc_id();
|
||||
let mut group = Node::group(id, children);
|
||||
group.name = Some("Shaper Group".into());
|
||||
group.shaper = Some(Box::new(spec));
|
||||
for id in &roots {
|
||||
d.remove(*id)?;
|
||||
}
|
||||
d.insert(parent, index.saturating_sub(earlier), group)?;
|
||||
sel.set([id]);
|
||||
Ok(id)
|
||||
})?;
|
||||
s.doc_mut()?.isolation = None;
|
||||
Ok(json!({"id": id.0, "regions": touched.len(), "strokes": strokes}))
|
||||
}
|
||||
|
||||
fn group_at(d: &Document, point: Point, construction: bool) -> Option<NodeId> {
|
||||
let mut groups = vec![];
|
||||
d.walk(|n| {
|
||||
if n.shaper.is_some()
|
||||
&& d.is_editable(n.id)
|
||||
&& (if construction { source_group(n).and_then(Node::geometric_bounds) } else { n.geometric_bounds() })
|
||||
.is_some_and(|b| b.inflate(6.0, 6.0).contains(point))
|
||||
{
|
||||
groups.push(n.id)
|
||||
}
|
||||
});
|
||||
groups.last().copied()
|
||||
}
|
||||
fn select(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let point = point_param(p, "point").filter(|p| finite(*p)).ok_or_else(|| bad("shaper.select", "missing point"))?;
|
||||
let st = s.doc()?;
|
||||
let source = bool_or(p, "source", false);
|
||||
let Some(group) = group_at(&st.doc, point, source) else { return Ok(json!({"id": null})) };
|
||||
let node = st.doc.node(group).ok_or(EngineError::NoNode(group))?;
|
||||
let target = if source {
|
||||
sources(node)
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|n| {
|
||||
b::node_outline(n)
|
||||
.is_some_and(|(path, _)| path.to_bezpath().winding(point) != 0 || path.nearest(point).is_some_and(|(_, _, _, _, d)| d < 6.0))
|
||||
})
|
||||
.map(|n| n.id)
|
||||
.unwrap_or(group)
|
||||
} else if st.selection.contains(group) {
|
||||
node.children()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.skip(1)
|
||||
.find(|n| {
|
||||
n.name.as_deref().is_some_and(|n| n.starts_with(FILL_PREFIX)) && n.path_data().is_some_and(|p| p.to_bezpath().winding(point) != 0)
|
||||
})
|
||||
.map_or(group, |n| n.id)
|
||||
} else {
|
||||
group
|
||||
};
|
||||
let isolation = source.then(|| source_group(node).map(|n| n.id)).flatten();
|
||||
s.select(|_, sel| sel.set([target]))?;
|
||||
if let Some(id) = isolation {
|
||||
s.doc_mut()?.isolation = Some(id);
|
||||
}
|
||||
Ok(json!({"id": target.0}))
|
||||
}
|
||||
fn target(s: &Session, p: &Value) -> Result<NodeId> {
|
||||
let id = id_param(p, "id")
|
||||
.or_else(|| s.active().and_then(|d| d.selection.objects.first().copied()))
|
||||
.ok_or_else(|| bad("shaper.release", "select a Shaper group"))?;
|
||||
s.doc()?.doc.node(id).filter(|n| n.shaper.is_some()).ok_or_else(|| bad("shaper.release", "select a Shaper group"))?;
|
||||
Ok(id)
|
||||
}
|
||||
fn release(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = target(s, p)?;
|
||||
let ids = s.edit("Release Shaper Group", |d, sel| {
|
||||
let original = d.node(id).map(sources).ok_or(EngineError::NoNode(id))?;
|
||||
let (parent, index, _) = d.position(id).ok_or(EngineError::NoNode(id))?;
|
||||
d.remove(id)?;
|
||||
let mut ids = vec![];
|
||||
for (i, n) in original.into_iter().enumerate() {
|
||||
ids.push(d.insert(parent, index + i, (*n).clone())?);
|
||||
}
|
||||
sel.set(ids.iter().copied());
|
||||
Ok(ids)
|
||||
})?;
|
||||
s.doc_mut()?.isolation = None;
|
||||
Ok(json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()}))
|
||||
}
|
||||
fn expand(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = target(s, p)?;
|
||||
s.edit("Expand Shaper Group", |d, sel| {
|
||||
let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?;
|
||||
n.shaper = None;
|
||||
n.name = None;
|
||||
if let Some(children) = n.children_mut()
|
||||
&& children.first().is_some_and(|c| c.name.as_deref() == Some(SOURCES))
|
||||
{
|
||||
children.remove(0);
|
||||
}
|
||||
sel.set([id]);
|
||||
Ok(())
|
||||
})?;
|
||||
s.doc_mut()?.isolation = None;
|
||||
Ok(json!({"id": id.0}))
|
||||
}
|
||||
+25
-20
@@ -1100,27 +1100,30 @@ impl Session {
|
||||
let before_sel = st.selection.clone();
|
||||
let doc = Arc::make_mut(&mut st.doc);
|
||||
let result = match f(doc, &mut st.selection) {
|
||||
Ok(v) => {
|
||||
// Text Wrap: area type follows its wrap objects; then threads re-flow.
|
||||
cmd::textwrap::refresh(Arc::make_mut(&mut st.doc));
|
||||
// Opacity-mask editing: the mask follows its art on the editing layer.
|
||||
if st.doc.mask_edit.is_some() {
|
||||
cmd::maskedit::sync(Arc::make_mut(&mut st.doc));
|
||||
Ok(v) => match cmd::shaper::refresh(&before, Arc::make_mut(&mut st.doc)) {
|
||||
Err(e) => Err(e),
|
||||
Ok(()) => {
|
||||
// Text Wrap: area type follows its wrap objects; then threads re-flow.
|
||||
cmd::textwrap::refresh(Arc::make_mut(&mut st.doc));
|
||||
// Opacity-mask editing: the mask follows its art on the editing layer.
|
||||
if st.doc.mask_edit.is_some() {
|
||||
cmd::maskedit::sync(Arc::make_mut(&mut st.doc));
|
||||
}
|
||||
// Threaded text re-flows when any of its frames changed.
|
||||
if !st.doc.text_threads.is_empty() {
|
||||
cmd::threads::reflow(&before, Arc::make_mut(&mut st.doc));
|
||||
}
|
||||
// Asset Export: assets let go of deleted art.
|
||||
if !st.doc.assets.is_empty() {
|
||||
Arc::make_mut(&mut st.doc).prune_assets();
|
||||
}
|
||||
if doc_sane(&st.doc, &st.selection) {
|
||||
Ok(v)
|
||||
} else {
|
||||
Err(EngineError::Other("result would exceed the canvas (coordinates out of range)".into()))
|
||||
}
|
||||
}
|
||||
// Threaded text re-flows when any of its frames changed.
|
||||
if !st.doc.text_threads.is_empty() {
|
||||
cmd::threads::reflow(&before, Arc::make_mut(&mut st.doc));
|
||||
}
|
||||
// Asset Export: assets let go of deleted art.
|
||||
if !st.doc.assets.is_empty() {
|
||||
Arc::make_mut(&mut st.doc).prune_assets();
|
||||
}
|
||||
if doc_sane(&st.doc, &st.selection) {
|
||||
Ok(v)
|
||||
} else {
|
||||
Err(EngineError::Other("result would exceed the canvas (coordinates out of range)".into()))
|
||||
}
|
||||
}
|
||||
},
|
||||
Err(e) => Err(e),
|
||||
};
|
||||
match result {
|
||||
@@ -1491,6 +1494,8 @@ mod tests_saveoptions;
|
||||
#[cfg(test)]
|
||||
mod tests_scalestrokes;
|
||||
#[cfg(test)]
|
||||
mod tests_shaper;
|
||||
#[cfg(test)]
|
||||
mod tests_slices;
|
||||
#[cfg(test)]
|
||||
mod tests_smartguides;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
use vectorcraft_geom::Point;
|
||||
use vectorcraft_tools::{PointerEvent, PointerKind, ToolKey};
|
||||
|
||||
fn session() -> Session {
|
||||
@@ -695,7 +696,7 @@ fn snap_to_pixel_rounds_drawing_and_moves() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shaper_turns_rough_strokes_into_live_shapes_and_scribbles_delete() {
|
||||
fn shaper_turns_rough_strokes_into_live_shapes_and_scribbles_punch_fills() {
|
||||
let mut s = session();
|
||||
let v = ViewInfo { smart_guides: false, ..Default::default() };
|
||||
s.select_tool("shaper", v).unwrap();
|
||||
@@ -725,10 +726,14 @@ fn shaper_turns_rough_strokes_into_live_shapes_and_scribbles_delete() {
|
||||
"{:?}",
|
||||
n.kind_label()
|
||||
);
|
||||
// A zig-zag over it deletes it.
|
||||
// A zig-zag inside it clears its fill while retaining the editable original and stroke.
|
||||
let zig: Vec<(f64, f64)> = (0..30).map(|i| (150.0 + (i % 2) as f64 * 80.0, 120.0 + i as f64 * 2.0)).collect();
|
||||
stroke(&mut s, &zig);
|
||||
assert!(s.doc().unwrap().doc.node(id).is_none());
|
||||
assert_eq!(s.doc().unwrap().doc.node(id).unwrap(), &n);
|
||||
let group = s.doc().unwrap().doc.node(s.doc().unwrap().selection.objects[0]).unwrap();
|
||||
assert!(group.shaper.is_some());
|
||||
assert!(group.children().unwrap().iter().skip(1).all(|n| n.appearance.fill_paint().is_none()));
|
||||
assert!(group.children().unwrap().iter().skip(1).any(|n| !n.appearance.stroke_paint().is_none()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -759,6 +764,50 @@ fn shaper_square_started_mid_edge_closes_on_pointer_release_and_undoes_once() {
|
||||
assert!(s.doc().unwrap().doc.node(id).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shaper_creates_upright_or_inverted_live_triangles_with_one_undo_step() {
|
||||
let v = ViewInfo { smart_guides: false, ..Default::default() };
|
||||
for direction in [0.0_f64, 180.0] {
|
||||
let mut s = session();
|
||||
s.select_tool("shaper", v).unwrap();
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
let corners: Vec<Point> = (0..3)
|
||||
.map(|i| {
|
||||
let angle = (direction + 7.0 - 90.0).to_radians() + std::f64::consts::TAU * i as f64 / 3.0;
|
||||
Point::new(200.0 + 70.0 * angle.cos(), 200.0 + 70.0 * angle.sin())
|
||||
})
|
||||
.collect();
|
||||
let mut pts = vec![];
|
||||
for i in 0..3 {
|
||||
for j in 0..20 {
|
||||
pts.push(corners[i] + (corners[(i + 1) % 3] - corners[i]) * (j as f64 / 20.0));
|
||||
}
|
||||
}
|
||||
// Begin midway along an edge; the release closes the final segment.
|
||||
pts.rotate_left(7);
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, pts[0].x, pts[0].y), v).unwrap();
|
||||
for p in &pts[1..] {
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, p.x, p.y), v).unwrap();
|
||||
}
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, pts[0].x, pts[0].y), v).unwrap();
|
||||
let id = *s.doc().unwrap().selection.objects.first().expect("recognized triangle");
|
||||
let node = s.doc().unwrap().doc.node(id).unwrap().clone();
|
||||
let vectorcraft_doc::NodeKind::Path { live: Some(vectorcraft_doc::LiveShape::Polygon { sides: 3, xf, .. }), .. } = &node.kind else {
|
||||
panic!("expected live triangle, got {:?}", node.kind_label());
|
||||
};
|
||||
let [a, b, c, d, _, _] = xf.as_coeffs();
|
||||
let (sin, cos) = direction.to_radians().sin_cos();
|
||||
for (actual, expected) in [(a, cos), (b, sin), (c, -sin), (d, cos)] {
|
||||
assert!((actual - expected).abs() < 1e-12, "direction {direction}, transform {xf:?}");
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(s.doc().unwrap().doc.node(id).is_none());
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.node(id).unwrap(), &node);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_panicking_command_rolls_back_instead_of_crashing() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::shaper::{FILL_PREFIX, SOURCES, STROKE_PREFIX};
|
||||
use vectorcraft_doc::{LiveShape, Node, NodeKind};
|
||||
use vectorcraft_geom::{Point, Shape as _};
|
||||
use vectorcraft_tools::{Mods, PointerEvent, PointerKind};
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 500, "height": 500})).unwrap();
|
||||
s
|
||||
}
|
||||
fn rectangle(s: &mut Session, x: f64, fill: &str) -> NodeId {
|
||||
s.execute("paint.setFill", &json!({"color": fill, "ids": []})).unwrap();
|
||||
let id = s.execute("shape.rectangle", &json!({"x":x,"y":0,"width":100,"height":100})).unwrap()["id"].as_u64().unwrap();
|
||||
NodeId(id)
|
||||
}
|
||||
fn scribble(s: &mut Session, pts: &[[f64; 2]]) -> NodeId {
|
||||
NodeId(s.execute("shaper.scribble", &json!({"points":pts})).unwrap()["id"].as_u64().unwrap())
|
||||
}
|
||||
fn node(s: &Session, id: NodeId) -> &Node {
|
||||
s.doc().unwrap().doc.node(id).unwrap()
|
||||
}
|
||||
fn fill(s: &Session, id: NodeId, x: f64, y: f64) -> Option<String> {
|
||||
node(s, id)
|
||||
.children()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|n| n.name.as_deref() != Some(SOURCES))
|
||||
.rev()
|
||||
.find(|n| {
|
||||
n.name.as_deref().is_some_and(|n| n.starts_with(FILL_PREFIX))
|
||||
&& n.path_data().is_some_and(|p| p.to_bezpath().winding(Point::new(x, y)) != 0)
|
||||
})
|
||||
.map(|n| n.appearance.fill_paint().label())
|
||||
}
|
||||
fn stroke(s: &Session, id: NodeId, x: f64, y: f64) -> bool {
|
||||
node(s, id).children().unwrap().iter().filter(|n| n.name.as_deref() != Some(SOURCES)).any(|n| {
|
||||
n.name.as_deref().is_some_and(|n| n.starts_with(STROKE_PREFIX))
|
||||
&& !n.appearance.stroke_paint().is_none()
|
||||
&& n.path_data().and_then(|p| p.nearest(Point::new(x, y))).is_some_and(|(_, _, _, _, d)| d < 0.1)
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shaper_merges_with_origin_color_keeps_live_originals_and_undoes_once() {
|
||||
for reverse in [false, true] {
|
||||
let mut s = session();
|
||||
let a = rectangle(&mut s, 0.0, "#ff0000");
|
||||
let b = rectangle(&mut s, 50.0, "#0000ff");
|
||||
let originals = [node(&s, a).clone(), node(&s, b).clone()];
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
let mut pts = vec![[25.0, 20.0], [75.0, 35.0], [30.0, 50.0], [125.0, 65.0], [75.0, 80.0], [125.0, 85.0]];
|
||||
if reverse {
|
||||
pts.reverse();
|
||||
}
|
||||
let g = scribble(&mut s, &pts);
|
||||
let color = if reverse { "#0000ff" } else { "#ff0000" };
|
||||
for x in [25.0, 75.0, 125.0] {
|
||||
assert_eq!(fill(&s, g, x, 50.0).as_deref(), Some(color));
|
||||
}
|
||||
for n in &originals {
|
||||
assert_eq!(node(&s, n.id), n);
|
||||
}
|
||||
assert_eq!(node(&s, g).children().unwrap()[0].name.as_deref(), Some(SOURCES));
|
||||
assert!(!stroke(&s, g, 50.0, 50.0));
|
||||
assert!(stroke(&s, g, 0.0, 50.0));
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(s.doc().unwrap().doc.node(g).is_none());
|
||||
for n in &originals {
|
||||
assert_eq!(node(&s, n.id), n);
|
||||
}
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(fill(&s, g, 75.0, 50.0).as_deref(), Some(color));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shaper_interior_punch_removes_the_whole_visible_front_face() {
|
||||
let mut s = session();
|
||||
let a = rectangle(&mut s, 0.0, "#ff0000");
|
||||
let b = rectangle(&mut s, 50.0, "#0000ff");
|
||||
let originals = [node(&s, a).clone(), node(&s, b).clone()];
|
||||
let g = scribble(&mut s, &[[65.0, 20.0], [85.0, 40.0], [65.0, 60.0], [85.0, 80.0]]);
|
||||
assert_eq!(fill(&s, g, 75.0, 50.0), None);
|
||||
assert_eq!(fill(&s, g, 25.0, 50.0).as_deref(), Some("#ff0000"));
|
||||
assert_eq!(fill(&s, g, 125.0, 50.0), None);
|
||||
assert!(stroke(&s, g, 50.0, 50.0) && stroke(&s, g, 150.0, 50.0));
|
||||
for n in &originals {
|
||||
assert_eq!(node(&s, n.id), n);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shaper_exiting_scribble_erases_only_touched_fill_and_boundary_strokes() {
|
||||
let mut s = session();
|
||||
rectangle(&mut s, 0.0, "#ff0000");
|
||||
rectangle(&mut s, 50.0, "#0000ff");
|
||||
let g = scribble(&mut s, &[[110.0, 20.0], [140.0, 35.0], [110.0, 50.0], [140.0, 65.0], [200.0, 70.0]]);
|
||||
assert_eq!(fill(&s, g, 125.0, 50.0), None);
|
||||
assert!(!stroke(&s, g, 150.0, 50.0));
|
||||
assert_eq!(fill(&s, g, 25.0, 50.0).as_deref(), Some("#ff0000"));
|
||||
assert_eq!(fill(&s, g, 75.0, 50.0), None);
|
||||
assert!(stroke(&s, g, 0.0, 50.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shaper_erases_only_protruding_stroke_pieces_keeps_fill_and_original_line() {
|
||||
let mut s = session();
|
||||
s.execute("paint.setFill", &json!({"color":"#ffff00"})).unwrap();
|
||||
let circle = NodeId(s.execute("shape.ellipse", &json!({"x":100,"y":100,"width":100,"height":100})).unwrap()["id"].as_u64().unwrap());
|
||||
let line = NodeId(s.execute("shape.line", &json!({"x1":50,"y1":150,"x2":250,"y2":150})).unwrap()["id"].as_u64().unwrap());
|
||||
let original_line = node(&s, line).clone();
|
||||
let g = scribble(&mut s, &[[60.0, 140.0], [70.0, 160.0], [80.0, 140.0], [90.0, 160.0]]);
|
||||
assert!(!stroke(&s, g, 75.0, 150.0));
|
||||
assert!(stroke(&s, g, 150.0, 150.0));
|
||||
assert!(stroke(&s, g, 225.0, 150.0));
|
||||
assert_eq!(fill(&s, g, 150.0, 130.0).as_deref(), Some("#ffff00"));
|
||||
assert_eq!(node(&s, line), &original_line);
|
||||
assert!(matches!(node(&s, circle).kind, NodeKind::Path { live: Some(LiveShape::Ellipse { .. }), .. }));
|
||||
let g = scribble(&mut s, &[[210.0, 140.0], [220.0, 160.0], [230.0, 140.0], [240.0, 160.0]]);
|
||||
assert!(!stroke(&s, g, 225.0, 150.0));
|
||||
assert!(stroke(&s, g, 150.0, 150.0));
|
||||
assert_eq!(node(&s, line), &original_line);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shaper_source_move_resize_native_roundtrip_and_release_preserve_live_art() {
|
||||
let mut s = session();
|
||||
let a = rectangle(&mut s, 0.0, "#ff0000");
|
||||
let b = rectangle(&mut s, 50.0, "#0000ff");
|
||||
let g = scribble(&mut s, &[[25.0, 20.0], [75.0, 35.0], [30.0, 50.0], [125.0, 65.0]]);
|
||||
s.execute("object.move", &json!({"ids":[a.0],"dx":20,"dy":10})).unwrap();
|
||||
assert_eq!(fill(&s, g, 30.0, 15.0).as_deref(), Some("#ff0000"));
|
||||
assert_eq!(node(&s, a).geometric_bounds().unwrap().x0, 20.0);
|
||||
s.execute("select.set", &json!({"ids":[a.0]})).unwrap();
|
||||
s.execute("object.setBounds", &json!({"width":120.0,"height":110.0,"reference":0})).unwrap();
|
||||
assert!(matches!(node(&s, a).kind, NodeKind::Path { live: Some(LiveShape::Rectangle { .. }), .. }));
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
let back = vectorcraft_format::load(&vectorcraft_format::save(doc, false)).unwrap();
|
||||
assert_eq!(back.layers, doc.layers);
|
||||
let originals = [node(&s, a).clone(), node(&s, b).clone()];
|
||||
s.execute("shaper.release", &json!({"id":g.0})).unwrap();
|
||||
assert!(s.doc().unwrap().doc.node(g).is_none());
|
||||
for n in &originals {
|
||||
assert_eq!(node(&s, n.id), n);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shaper_face_paint_survives_source_edits_and_expand_discards_only_sources() {
|
||||
let mut s = session();
|
||||
let a = rectangle(&mut s, 0.0, "#ff0000");
|
||||
rectangle(&mut s, 50.0, "#0000ff");
|
||||
let g = scribble(&mut s, &[[25.0, 20.0], [75.0, 35.0], [30.0, 50.0], [125.0, 65.0]]);
|
||||
s.execute("shaper.select", &json!({"point":[75,50]})).unwrap();
|
||||
s.execute("paint.setFill", &json!({"color":"#00ff00"})).unwrap();
|
||||
s.execute("object.move", &json!({"ids":[a.0],"dx":5,"dy":0})).unwrap();
|
||||
assert_eq!(fill(&s, g, 75.0, 50.0).as_deref(), Some("#00ff00"));
|
||||
s.execute("shaper.expand", &json!({"id":g.0})).unwrap();
|
||||
assert!(node(&s, g).shaper.is_none());
|
||||
assert!(s.doc().unwrap().doc.node(a).is_none());
|
||||
assert_eq!(fill(&s, g, 75.0, 50.0).as_deref(), Some("#00ff00"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shaper_double_click_selects_an_original_for_construction_editing() {
|
||||
let mut s = session();
|
||||
let a = rectangle(&mut s, 0.0, "#ff0000");
|
||||
rectangle(&mut s, 50.0, "#0000ff");
|
||||
let g = scribble(&mut s, &[[25.0, 20.0], [75.0, 35.0], [30.0, 50.0], [125.0, 65.0]]);
|
||||
let v = ViewInfo { smart_guides: false, ..Default::default() };
|
||||
s.select_tool("shaper", v).unwrap();
|
||||
let requests = s.pointer(&PointerEvent::new(PointerKind::DoubleClick, 25.0, 50.0), v).unwrap();
|
||||
assert!(requests.iter().any(|r| matches!(r, UiRequest::SwitchTool(tool) if tool == "selection")));
|
||||
s.select_tool("selection", v).unwrap();
|
||||
assert_eq!(s.tool_id(), "selection");
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![a]);
|
||||
assert_eq!(s.doc().unwrap().isolation, Some(node(&s, g).children().unwrap()[0].id));
|
||||
assert!(s.doc().unwrap().doc.is_editable(a));
|
||||
s.execute("object.move", &json!({"dx":10,"dy":0})).unwrap();
|
||||
assert_eq!(fill(&s, g, 15.0, 50.0).as_deref(), Some("#ff0000"));
|
||||
s.execute("object.exitIsolation", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![g]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shaper_lines_keep_free_angles_with_or_without_shift() {
|
||||
for shift in [false, true] {
|
||||
let mut s = session();
|
||||
let v = ViewInfo { smart_guides: false, ..Default::default() };
|
||||
s.select_tool("shaper", v).unwrap();
|
||||
for (kind, x, y) in [(PointerKind::Down, 30.0, 40.0), (PointerKind::Drag, 130.0, 70.0), (PointerKind::Up, 230.0, 100.0)] {
|
||||
s.pointer(&PointerEvent::new(kind, x, y).with_mods(Mods { shift, ..Default::default() }), v).unwrap();
|
||||
}
|
||||
let n = node(&s, s.doc().unwrap().selection.objects[0]);
|
||||
assert!(matches!(n.kind,NodeKind::Path{live:Some(LiveShape::Line{a,b}),..} if a==Point::new(30.0,40.0) && b==Point::new(230.0,100.0)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shaper_rejects_oversized_or_invalid_gestures_without_changing_art() {
|
||||
let mut s = session();
|
||||
rectangle(&mut s, 0.0, "#ff0000");
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let history = s.doc().unwrap().history.undo.len();
|
||||
for params in [json!({"points":vec![[1.0,1.0];4097]}), json!({"points":[[1,1],[2,2]],"tolerance":0}), json!({"points":[[1e100,0]]})] {
|
||||
assert!(s.execute("shaper.scribble", ¶ms).is_err());
|
||||
assert_eq!(s.doc().unwrap().doc.layers, before.layers);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), history);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shaper_tool_trims_both_diagonal_ends_and_only_construction_can_pick_them() {
|
||||
use vectorcraft_doc::hit::{HitOptions, hit_test};
|
||||
let mut s = session();
|
||||
s.execute("paint.setFill", &json!({"color":"#ffff00"})).unwrap();
|
||||
s.execute("shape.ellipse", &json!({"x":100,"y":100,"width":100,"height":100})).unwrap();
|
||||
let rotate = |x: f64, y: f64| {
|
||||
let (x, y) = (x - 150.0, y - 150.0);
|
||||
Point::new(150.0 + (x + y) / 2.0f64.sqrt(), 150.0 + (y - x) / 2.0f64.sqrt())
|
||||
};
|
||||
let (a, b) = (rotate(50.0, 150.0), rotate(250.0, 150.0));
|
||||
let line = NodeId(s.execute("shape.line", &json!({"x1":a.x,"y1":a.y,"x2":b.x,"y2":b.y})).unwrap()["id"].as_u64().unwrap());
|
||||
let original = node(&s, line).clone();
|
||||
let v = ViewInfo { smart_guides: false, ..Default::default() };
|
||||
s.select_tool("shaper", v).unwrap();
|
||||
for offset in [0.0, 150.0] {
|
||||
let points = [(60.0, 140.0), (70.0, 160.0), (80.0, 140.0), (90.0, 160.0)];
|
||||
for (i, (x, y)) in points.into_iter().enumerate() {
|
||||
let p = rotate(x + offset, y);
|
||||
let kind = if i == 0 {
|
||||
PointerKind::Down
|
||||
} else if i == 3 {
|
||||
PointerKind::Up
|
||||
} else {
|
||||
PointerKind::Drag
|
||||
};
|
||||
s.pointer(&PointerEvent::new(kind, p.x, p.y), v).unwrap();
|
||||
}
|
||||
}
|
||||
let g = s.doc().unwrap().selection.objects[0];
|
||||
assert!(node(&s, g).shaper.is_some());
|
||||
assert_eq!(node(&s, line), &original);
|
||||
assert!(stroke(&s, g, 150.0, 150.0));
|
||||
assert_eq!(fill(&s, g, 150.0, 130.0).as_deref(), Some("#ffff00"));
|
||||
let sources = node(&s, g).children().unwrap()[0].id;
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
for x in [75.0, 225.0] {
|
||||
let p = rotate(x, 150.0);
|
||||
assert!(!stroke(&s, g, p.x, p.y));
|
||||
assert!(!node(&s, g).geometric_bounds().unwrap().contains(p));
|
||||
assert!(!node(&s, g).visual_bounds().unwrap().contains(p));
|
||||
assert!(hit_test(doc, p, HitOptions { tol: 0.5, ..Default::default() }).is_none());
|
||||
assert_eq!(hit_test(doc, p, HitOptions { tol: 0.5, scope: Some(sources), ..Default::default() }).unwrap().leaf, line);
|
||||
}
|
||||
let p = rotate(75.0, 150.0);
|
||||
s.execute("shaper.select", &json!({"point":[p.x,p.y],"source":true})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![line]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shaper_three_circles_erase_a_visible_fill_and_keep_the_complete_shared_curve() {
|
||||
for exits in [false, true] {
|
||||
let mut s = session();
|
||||
let mut originals = vec![];
|
||||
for (x, y, fill) in [(100.0, 20.0, "#ff0000"), (20.0, 130.0, "#ffffff"), (170.0, 130.0, "#ffff00")] {
|
||||
s.execute("paint.setFill", &json!({"color":fill,"ids":[]})).unwrap();
|
||||
let id = NodeId(s.execute("shape.ellipse", &json!({"x":x,"y":y,"width":200,"height":200})).unwrap()["id"].as_u64().unwrap());
|
||||
originals.push(node(&s, id).clone());
|
||||
}
|
||||
let mut points = vec![[290.0, 210.0], [330.0, 225.0], [290.0, 240.0], [330.0, 255.0]];
|
||||
if exits {
|
||||
points.push([410.0, 260.0]);
|
||||
}
|
||||
let g = scribble(&mut s, &points);
|
||||
for (x, y) in [(330.0, 230.0), (190.0, 230.0), (250.0, 180.0)] {
|
||||
assert_eq!(fill(&s, g, x, y), None, "all of the visible yellow face is erased, including covered intersections");
|
||||
}
|
||||
assert_eq!(stroke(&s, g, 370.0, 230.0), !exits);
|
||||
for angle in [145.0_f64, 160.0, 180.0, 200.0, 220.0, 240.0, 260.0, 280.0] {
|
||||
let (sin, cos) = angle.to_radians().sin_cos();
|
||||
assert!(stroke(&s, g, 270.0 + 100.0 * cos, 230.0 + 100.0 * sin), "shared curve remains complete at {angle}°, exiting {exits}");
|
||||
}
|
||||
if exits {
|
||||
// The two new corners must be actual path joins, rather than meeting butt caps.
|
||||
for corner in [Point::new(298.975, 134.288), Point::new(195.0, 296.144)] {
|
||||
assert!(
|
||||
node(&s, g)
|
||||
.children()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|n| n.name.as_deref().is_some_and(|s| s.starts_with(STROKE_PREFIX)))
|
||||
.flat_map(|n| n.path_data().unwrap().subpaths.iter())
|
||||
.flat_map(|sp| &sp.anchors)
|
||||
.any(|a| a.p.distance(corner) < 0.1 && a.has_in() && a.has_out()),
|
||||
"joined corner at {corner:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
assert_eq!(fill(&s, g, 110.0, 230.0).as_deref(), Some("#ffffff"));
|
||||
assert_eq!(fill(&s, g, 200.0, 80.0).as_deref(), Some("#ff0000"));
|
||||
for n in originals {
|
||||
assert_eq!(node(&s, n.id), &n);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shaper_visible_dividing_stroke_keeps_fill_erasure_on_one_side() {
|
||||
let mut s = session();
|
||||
s.execute("paint.setFill", &json!({"color":"#ffff00","ids":[]})).unwrap();
|
||||
s.execute("shape.ellipse", &json!({"x":100,"y":100,"width":100,"height":100})).unwrap();
|
||||
s.execute("shape.line", &json!({"x1":100,"y1":150,"x2":200,"y2":150})).unwrap();
|
||||
let g = scribble(&mut s, &[[140.0, 125.0], [160.0, 135.0], [140.0, 140.0], [160.0, 145.0]]);
|
||||
assert_eq!(fill(&s, g, 150.0, 130.0), None);
|
||||
assert_eq!(fill(&s, g, 150.0, 170.0).as_deref(), Some("#ffff00"));
|
||||
assert!(stroke(&s, g, 150.0, 150.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shaper_joined_outline_keeps_its_stroke_paint_after_source_edits() {
|
||||
let mut s = session();
|
||||
let mut ids = vec![];
|
||||
for (x, color) in [(0, "#ff0000"), (50, "#ffff00")] {
|
||||
s.execute("paint.setFill", &json!({"color":color,"ids":[]})).unwrap();
|
||||
ids.push(NodeId(s.execute("shape.ellipse", &json!({"x":x,"y":0,"width":100,"height":100})).unwrap()["id"].as_u64().unwrap()));
|
||||
}
|
||||
let g = scribble(&mut s, &[[20.0, 40.0], [70.0, 45.0], [30.0, 50.0], [125.0, 55.0], [75.0, 60.0], [125.0, 65.0]]);
|
||||
let outlines: Vec<_> =
|
||||
node(&s, g).children().unwrap().iter().filter(|n| n.name.as_deref().is_some_and(|s| s.starts_with(STROKE_PREFIX))).collect();
|
||||
assert_eq!(outlines.len(), 1);
|
||||
assert!(outlines[0].path_data().unwrap().subpaths[0].closed);
|
||||
let outline = outlines[0].id;
|
||||
s.execute("paint.setStroke", &json!({"color":"#00ff00","ids":[outline.0]})).unwrap();
|
||||
s.execute("object.move", &json!({"ids":[ids[0].0],"dx":10,"dy":0})).unwrap();
|
||||
let outlines: Vec<_> =
|
||||
node(&s, g).children().unwrap().iter().filter(|n| n.name.as_deref().is_some_and(|s| s.starts_with(STROKE_PREFIX))).collect();
|
||||
assert_eq!(outlines.len(), 1);
|
||||
assert_eq!(outlines[0].appearance.stroke_paint().label(), "#00ff00");
|
||||
assert!(outlines[0].path_data().unwrap().subpaths[0].closed);
|
||||
for id in ids {
|
||||
assert_ne!(node(&s, id).appearance.stroke_paint().label(), "#00ff00");
|
||||
}
|
||||
}
|
||||
+184
-32
@@ -5,7 +5,7 @@
|
||||
//! its bounding box. Samples are spaced by distance so pointer speed does not bias the fit.
|
||||
//! A zig-zag stroke with several reversals is a scribble (the Shaper deletes what it covers).
|
||||
|
||||
use crate::{Point, Rect, Vec2};
|
||||
use crate::{Affine, Point, Rect, Vec2};
|
||||
|
||||
/// What the Shaper recognised.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -14,10 +14,16 @@ pub enum Recognized {
|
||||
a: Point,
|
||||
b: Point,
|
||||
},
|
||||
/// Axis-aligned rectangle (squares snap when the sides are within 10%).
|
||||
Rectangle(Rect),
|
||||
/// Axis-aligned ellipse in `rect` (circles snap when the axes are within 10%).
|
||||
Ellipse(Rect),
|
||||
/// Rectangle at a 45° step (squares snap when the sides are within 10%).
|
||||
Rectangle {
|
||||
rect: Rect,
|
||||
rotation: f64,
|
||||
},
|
||||
/// Ellipse at a 45° step (circles snap when the axes are within 10%).
|
||||
Ellipse {
|
||||
rect: Rect,
|
||||
rotation: f64,
|
||||
},
|
||||
/// Regular polygon (triangle, pentagon, hexagon…): centre, radius, side count and the rotation
|
||||
/// (degrees) of the first vertex from straight up.
|
||||
Polygon {
|
||||
@@ -163,11 +169,59 @@ fn right_angles(corners: &[Point], size: f64) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
fn axis_aligned(corners: &[Point]) -> bool {
|
||||
corners.iter().zip(corners.iter().cycle().skip(1)).all(|(&a, &b)| {
|
||||
let edge = b - a;
|
||||
edge.x.abs().min(edge.y.abs()) < 0.4 * edge.hypot()
|
||||
})
|
||||
/// Fit all vertices, so neither the starting point nor drawing direction picks the orientation.
|
||||
fn polygon_rotation(corners: &[Point], step: f64) -> f64 {
|
||||
let Some(&origin) = corners.first() else { return 0.0 };
|
||||
let n = corners.len() as f64;
|
||||
let center = origin + corners.iter().fold(Vec2::ZERO, |sum, p| sum + (*p - origin) / n);
|
||||
let period = 360.0 / n;
|
||||
let error = |rotation: f64| {
|
||||
corners
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let angle = (*p - center).atan2().to_degrees() + 90.0;
|
||||
let delta = (angle - rotation).rem_euclid(period);
|
||||
delta.min(period - delta).powi(2)
|
||||
})
|
||||
.sum::<f64>()
|
||||
};
|
||||
[0.0, 90.0, 180.0, 270.0].into_iter().filter(|r| r % step == 0.0).min_by(|a, b| error(*a).total_cmp(&error(*b))).unwrap_or(0.0)
|
||||
}
|
||||
|
||||
fn rectangle_rotation(corners: &[Point]) -> f64 {
|
||||
let (sin, cos) = corners.iter().zip(corners.iter().cycle().skip(1)).fold((0.0, 0.0), |(sin, cos), (&a, &b)| {
|
||||
let angle = (b - a).atan2() * 4.0;
|
||||
(sin + angle.sin(), cos + angle.cos())
|
||||
});
|
||||
(sin.atan2(cos).to_degrees() / 4.0 / 45.0).round().mul_add(45.0, 0.0).rem_euclid(180.0)
|
||||
}
|
||||
|
||||
fn ellipse_rotation(pts: &[Point]) -> f64 {
|
||||
let Some(&origin) = pts.first() else { return 0.0 };
|
||||
let n = pts.len() as f64;
|
||||
let center = origin + pts.iter().fold(Vec2::ZERO, |sum, p| sum + (*p - origin) / n);
|
||||
let (xx, yy, xy) = pts.iter().fold((0.0, 0.0, 0.0), |(xx, yy, xy), p| {
|
||||
let v = *p - center;
|
||||
(xx + v.x * v.x, yy + v.y * v.y, xy + v.x * v.y)
|
||||
});
|
||||
// A near-circle has no useful orientation; keep it upright rather than following noise.
|
||||
if (xx - yy).hypot(2.0 * xy) < 0.1 * (xx + yy) {
|
||||
return 0.0;
|
||||
}
|
||||
((2.0 * xy).atan2(xx - yy).to_degrees() / 2.0 / 45.0).round().mul_add(45.0, 0.0).rem_euclid(180.0)
|
||||
}
|
||||
|
||||
/// Dimensions in the fitted frame, but the centre stays in document coordinates.
|
||||
fn oriented_bounds(pts: &[Point], rotation: f64) -> Rect {
|
||||
if rotation == 0.0 {
|
||||
return bbox(pts);
|
||||
}
|
||||
let origin = pts.first().copied().unwrap_or(Point::ZERO);
|
||||
let rotate = Affine::rotate(rotation.to_radians());
|
||||
let inverse = Affine::rotate(-rotation.to_radians());
|
||||
let local: Vec<Point> = pts.iter().map(|p| inverse * (*p - origin).to_point()).collect();
|
||||
let b = bbox(&local);
|
||||
Rect::from_center_size(origin + (rotate * b.center()).to_vec2(), (b.width(), b.height()))
|
||||
}
|
||||
|
||||
/// Number of sharp direction reversals (turns of more than 120°) along the stroke.
|
||||
@@ -195,13 +249,21 @@ pub fn recognize(pts: &[Point]) -> Option<Recognized> {
|
||||
if !len.is_finite() || len <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let original = pts;
|
||||
let samples = resample(pts, len);
|
||||
let pts = samples.as_slice();
|
||||
let (&first, &last) = (pts.first()?, pts.last()?);
|
||||
let chord = first.distance(last);
|
||||
// Scribble: several sharp reversals packed into a small area relative to the stroke length.
|
||||
if reversals(pts, size * 0.08) >= 3 && len > 3.0 * size {
|
||||
return Some(Recognized::Scribble(b));
|
||||
if reversals(pts, size * 0.08) >= 2 && len > 1.5 * size {
|
||||
// An equilateral triangle turns 120° at its vertices: drawing noise can make all
|
||||
// three turns just exceed the reversal threshold. Its closed, straight edges and
|
||||
// substantial area distinguish it from a deletion scribble.
|
||||
let corners = closed_corners(pts, size * 0.12);
|
||||
let triangle = chord <= 0.25 * size && corners.len() == 3 && area(pts) > 0.2 * size * size && is_polygonal(pts, &corners, size, 0.055);
|
||||
if !triangle {
|
||||
return Some(Recognized::Scribble(b));
|
||||
}
|
||||
}
|
||||
// Line: every point near the chord.
|
||||
let max_dev = pts.iter().map(|p| dist_to_segment(*p, first, last)).fold(0.0, f64::max);
|
||||
@@ -230,23 +292,21 @@ pub fn recognize(pts: &[Point]) -> Option<Recognized> {
|
||||
match corners.len() {
|
||||
3 => {
|
||||
let radius = corners.iter().map(|p| p.distance(center)).sum::<f64>() / 3.0;
|
||||
// Rotation of the vertex nearest straight up (y grows downward).
|
||||
let top = corners.iter().copied().min_by(|a, b| a.y.total_cmp(&b.y)).unwrap_or(center);
|
||||
let rot = (top - center).atan2().to_degrees() + 90.0;
|
||||
Some(Recognized::Polygon { center, radius, sides: 3, rotation: rot })
|
||||
Some(Recognized::Polygon { center, radius, sides: 3, rotation: polygon_rotation(&corners, 180.0) })
|
||||
}
|
||||
4 if is_rectangle(pts, &corners, size) && axis_aligned(&corners) => Some(Recognized::Rectangle(square(b))),
|
||||
// A diamond: a square standing on a corner.
|
||||
4 if (0.4..0.62).contains(&fill) && is_rectangle(pts, &corners, size) => {
|
||||
let radius = corners.iter().map(|p| p.distance(center)).sum::<f64>() / 4.0;
|
||||
Some(Recognized::Polygon { center, radius, sides: 4, rotation: 0.0 })
|
||||
4 if is_rectangle(pts, &corners, size) => {
|
||||
let rotation = rectangle_rotation(&corners);
|
||||
Some(Recognized::Rectangle { rect: square(oriented_bounds(original, rotation)), rotation })
|
||||
}
|
||||
n @ 5..=8 if fill > 0.6 && fill < 0.85 && is_polygonal(pts, &corners, size, 0.02) => {
|
||||
let radius = corners.iter().map(|p| p.distance(center)).sum::<f64>() / n as f64;
|
||||
Some(Recognized::Polygon { center, radius, sides: n as u32, rotation: 0.0 })
|
||||
Some(Recognized::Polygon { center, radius, sides: n as u32, rotation: if n == 6 { polygon_rotation(&corners, 90.0) } else { 0.0 } })
|
||||
}
|
||||
_ => {
|
||||
let rotation = ellipse_rotation(pts);
|
||||
let rect = oriented_bounds(original, rotation);
|
||||
(area(pts) / (rect.width() * rect.height()).max(1e-9) > 0.6).then(|| Recognized::Ellipse { rect: square(rect), rotation })
|
||||
}
|
||||
_ if fill > 0.6 => Some(Recognized::Ellipse(square(b))),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -296,10 +356,10 @@ mod tests {
|
||||
#[test]
|
||||
fn rough_rectangle_and_square() {
|
||||
let r = wobbly(&[Point::new(0.0, 0.0), Point::new(200.0, 0.0), Point::new(200.0, 100.0), Point::new(0.0, 100.0)], 20, 2.0);
|
||||
let Some(Recognized::Rectangle(b)) = recognize(&r) else { panic!("{:?}", recognize(&r)) };
|
||||
let Some(Recognized::Rectangle { rect: b, .. }) = recognize(&r) else { panic!("{:?}", recognize(&r)) };
|
||||
assert!((b.width() - 200.0).abs() < 8.0 && (b.height() - 100.0).abs() < 8.0);
|
||||
let s = wobbly(&[Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 95.0), Point::new(0.0, 95.0)], 20, 1.0);
|
||||
let Some(Recognized::Rectangle(b)) = recognize(&s) else { panic!() };
|
||||
let Some(Recognized::Rectangle { rect: b, .. }) = recognize(&s) else { panic!() };
|
||||
assert!((b.width() - b.height()).abs() < 1e-9, "near-squares snap to squares");
|
||||
}
|
||||
|
||||
@@ -317,7 +377,11 @@ mod tests {
|
||||
stroke.rotate_left(start);
|
||||
let mut closed = stroke.clone();
|
||||
closed.push(stroke[0] + Vec2::new(2.0, -1.0));
|
||||
assert!(matches!(recognize(&closed), Some(Recognized::Rectangle(_))), "wobble {wobble}, start {start}: {:?}", recognize(&closed));
|
||||
assert!(
|
||||
matches!(recognize(&closed), Some(Recognized::Rectangle { .. })),
|
||||
"wobble {wobble}, start {start}: {:?}",
|
||||
recognize(&closed)
|
||||
);
|
||||
stroke.rotate_right(start);
|
||||
}
|
||||
}
|
||||
@@ -327,10 +391,10 @@ mod tests {
|
||||
#[test]
|
||||
fn ellipse_circle_triangle_hexagon_line() {
|
||||
assert!(
|
||||
matches!(recognize(&circle(Point::new(100.0, 100.0), 80.0, 40.0, 72)), Some(Recognized::Ellipse(r)) if r.width() > 150.0 && r.height() < 100.0)
|
||||
matches!(recognize(&circle(Point::new(100.0, 100.0), 80.0, 40.0, 72)), Some(Recognized::Ellipse { rect: r, .. }) if r.width() > 150.0 && r.height() < 100.0)
|
||||
);
|
||||
assert!(
|
||||
matches!(recognize(&circle(Point::new(100.0, 100.0), 50.0, 48.0, 72)), Some(Recognized::Ellipse(r)) if (r.width() - r.height()).abs() < 1e-9)
|
||||
matches!(recognize(&circle(Point::new(100.0, 100.0), 50.0, 48.0, 72)), Some(Recognized::Ellipse { rect: r, .. }) if (r.width() - r.height()).abs() < 1e-9)
|
||||
);
|
||||
let tri = wobbly(&[Point::new(100.0, 0.0), Point::new(200.0, 170.0), Point::new(0.0, 170.0)], 25, 1.5);
|
||||
assert!(matches!(recognize(&tri), Some(Recognized::Polygon { sides: 3, rotation, .. }) if rotation.abs() < 5.0), "{:?}", recognize(&tri));
|
||||
@@ -354,7 +418,7 @@ mod tests {
|
||||
stroke.rotate_left(start);
|
||||
let mut closed = stroke.clone();
|
||||
closed.push(stroke[0]);
|
||||
assert!(matches!(recognize(&closed), Some(Recognized::Ellipse(_))), "ellipse {rx}/{ry}, seam {start}: {:?}", recognize(&closed));
|
||||
assert!(matches!(recognize(&closed), Some(Recognized::Ellipse { .. })), "ellipse {rx}/{ry}, seam {start}: {:?}", recognize(&closed));
|
||||
stroke.rotate_right(start);
|
||||
}
|
||||
}
|
||||
@@ -381,17 +445,105 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rough_triangles_snap_upright_or_upside_down() {
|
||||
for direction in [0.0_f64, 180.0] {
|
||||
for tilt in [-12.0_f64, -3.0, 4.0, 11.0] {
|
||||
let corners: Vec<Point> = (0..3)
|
||||
.map(|i| {
|
||||
let a = (direction + tilt - 90.0).to_radians() + std::f64::consts::TAU * i as f64 / 3.0;
|
||||
Point::new(200.0 + 80.0 * a.cos(), 300.0 + 80.0 * a.sin())
|
||||
})
|
||||
.collect();
|
||||
for reversed in [false, true] {
|
||||
let mut stroke = wobbly(&corners, 24, 1.5);
|
||||
stroke.pop();
|
||||
if reversed {
|
||||
stroke.reverse();
|
||||
}
|
||||
for seam in [0, 9, 28, 51] {
|
||||
stroke.rotate_left(seam);
|
||||
let mut closed = stroke.clone();
|
||||
closed.push(stroke[0]);
|
||||
let Some(Recognized::Polygon { sides: 3, rotation, .. }) = recognize(&closed) else {
|
||||
panic!("direction {direction}, tilt {tilt}, seam {seam}: {:?}", recognize(&closed));
|
||||
};
|
||||
assert_eq!(rotation, direction, "tilt {tilt}, seam {seam}, reversed {reversed}");
|
||||
stroke.rotate_right(seam);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recognized_lines_keep_their_free_angle() {
|
||||
for (dx, dy) in [(120.0, 7.0), (-130.0, -9.0), (8.0, 90.0), (-6.0, -140.0), (60.0, 50.0), (-50.0, -60.0)] {
|
||||
let first = Point::new(200.0, 300.0);
|
||||
let last = first + Vec2::new(dx, dy);
|
||||
let stroke: Vec<Point> = (0..=30).map(|i| first + (last - first) * (i as f64 / 30.0)).collect();
|
||||
assert_eq!(recognize(&stroke), Some(Recognized::Line { a: first, b: last }));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tilted_rectangles_ellipses_and_hexagons_snap_to_their_angle_steps() {
|
||||
for direction in [0.0_f64, 45.0, 90.0, 135.0] {
|
||||
for tilt in [-7.0_f64, 7.0] {
|
||||
let xf = Affine::translate((200.0, 300.0)) * Affine::rotate((direction + tilt).to_radians());
|
||||
let corners: Vec<Point> =
|
||||
[(-90.0, -40.0), (90.0, -40.0), (90.0, 40.0), (-90.0, 40.0)].into_iter().map(|p| xf * Point::from(p)).collect();
|
||||
let stroke = wobbly(&corners, 24, 1.0);
|
||||
let Some(Recognized::Rectangle { rect, rotation }) = recognize(&stroke) else {
|
||||
panic!("rectangle {direction}: {:?}", recognize(&stroke))
|
||||
};
|
||||
assert_eq!(rotation % 45.0, 0.0);
|
||||
assert!(rect.center().distance(Point::new(200.0, 300.0)) < 4.0);
|
||||
assert!((rect.width().max(rect.height()) - 180.0).abs() < 15.0);
|
||||
let ellipse: Vec<Point> = circle(Point::ZERO, 90.0, 40.0, 192).into_iter().map(|p| xf * p).collect();
|
||||
let Some(Recognized::Ellipse { rect, rotation }) = recognize(&ellipse) else {
|
||||
panic!("ellipse {direction}: {:?}", recognize(&ellipse))
|
||||
};
|
||||
assert_eq!(rotation, direction);
|
||||
assert!((rect.width() - 180.0).abs() < 4.0 && (rect.height() - 80.0).abs() < 4.0);
|
||||
}
|
||||
}
|
||||
for direction in [0.0_f64, 90.0] {
|
||||
for tilt in [-7.0_f64, 7.0] {
|
||||
let corners: Vec<Point> = (0..6)
|
||||
.map(|i| {
|
||||
let a = (direction + tilt - 90.0).to_radians() + std::f64::consts::TAU * i as f64 / 6.0;
|
||||
Point::new(200.0 + 70.0 * a.cos(), 300.0 + 70.0 * a.sin())
|
||||
})
|
||||
.collect();
|
||||
let stroke = wobbly(&corners, 24, 0.5);
|
||||
let Some(Recognized::Polygon { sides: 6, rotation, .. }) = recognize(&stroke) else { panic!("hexagon: {:?}", recognize(&stroke)) };
|
||||
assert_eq!(rotation, direction);
|
||||
}
|
||||
}
|
||||
// Even sideways input can only produce an upright or inverted triangle.
|
||||
for direction in [83.0_f64, 97.0, 263.0, 277.0] {
|
||||
let corners: Vec<Point> = (0..3)
|
||||
.map(|i| {
|
||||
let a = (direction - 90.0).to_radians() + std::f64::consts::TAU * i as f64 / 3.0;
|
||||
Point::new(200.0 + 70.0 * a.cos(), 300.0 + 70.0 * a.sin())
|
||||
})
|
||||
.collect();
|
||||
assert!(matches!(recognize(&wobbly(&corners, 24, 0.5)), Some(Recognized::Polygon { sides: 3, rotation: 0.0 | 180.0, .. })));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn square_fit_ignores_sampling_density_and_document_offset() {
|
||||
let mut stroke = wobbly(&[Point::new(0.0, 0.0), Point::new(100.0, 8.0), Point::new(94.0, 100.0), Point::new(10.0, 91.0)], 24, 4.0);
|
||||
for scale in [0.25, 1.0, 20.0] {
|
||||
let shifted: Vec<Point> = stroke.iter().map(|p| Point::new(1e9 + p.x * scale, -1e9 + p.y * scale)).collect();
|
||||
assert!(matches!(recognize(&shifted), Some(Recognized::Rectangle(_))), "scale {scale}: {:?}", recognize(&shifted));
|
||||
assert!(matches!(recognize(&shifted), Some(Recognized::Rectangle { .. })), "scale {scale}: {:?}", recognize(&shifted));
|
||||
}
|
||||
let dense: Vec<Point> = stroke.iter().enumerate().flat_map(|(i, p)| std::iter::repeat_n(*p, if i < 24 { 100 } else { 1 })).collect();
|
||||
assert_eq!(recognize(&stroke), recognize(&dense));
|
||||
stroke.extend(std::iter::repeat_n(*stroke.last().unwrap(), 10_000));
|
||||
assert!(matches!(recognize(&stroke), Some(Recognized::Rectangle(_))));
|
||||
assert!(matches!(recognize(&stroke), Some(Recognized::Rectangle { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -105,6 +105,12 @@ pub fn leaf_shapes<'a>(n: &'a Node, out: &mut Vec<&'a Node>) {
|
||||
if !n.visible {
|
||||
return;
|
||||
}
|
||||
if n.shaper.is_some() {
|
||||
for c in n.children().into_iter().flatten().skip(1) {
|
||||
leaf_shapes(c, out);
|
||||
}
|
||||
return;
|
||||
}
|
||||
match &n.kind {
|
||||
NodeKind::Path { guide: true, .. } | NodeKind::Path { clipping: true, .. } => {}
|
||||
NodeKind::Path { .. } | NodeKind::Compound { .. } => out.push(n),
|
||||
@@ -256,7 +262,7 @@ impl BuilderMap {
|
||||
}
|
||||
|
||||
/// The regions just either side of the middle of `piece` (None: outside every region).
|
||||
fn sides(&self, piece: &po::Shape) -> [Option<usize>; 2] {
|
||||
pub fn sides(&self, piece: &po::Shape) -> [Option<usize>; 2] {
|
||||
let Some(sp) = piece.path.subpaths.first() else { return [None; 2] };
|
||||
let n = sp.segment_count();
|
||||
if n == 0 {
|
||||
|
||||
+15
-20
@@ -238,7 +238,7 @@ impl Tool for ShaperTool {
|
||||
vec![]
|
||||
}
|
||||
PointerKind::Drag => {
|
||||
if !self.points.is_empty() && self.points.last().is_none_or(|l| l.distance(ev.pos) >= cx.tol(1.5)) {
|
||||
if !self.points.is_empty() && self.points.len() < 4095 && self.points.last().is_none_or(|l| l.distance(ev.pos) >= cx.tol(1.5)) {
|
||||
self.points.push(ev.pos);
|
||||
}
|
||||
vec![]
|
||||
@@ -249,36 +249,31 @@ impl Tool for ShaperTool {
|
||||
self.points.push(ev.pos);
|
||||
}
|
||||
let pts = std::mem::take(&mut self.points);
|
||||
let r = |x: vectorcraft_geom::Rect| json!({ "x": x.x0, "y": x.y0, "width": x.width(), "height": x.height() });
|
||||
if pts.first().is_some_and(|first| pts.iter().all(|p| p.distance(*first) < cx.tol(3.0))) {
|
||||
return vec![Action::Exec("shaper.select".into(), json!({"point": [ev.pos.x, ev.pos.y]}))];
|
||||
}
|
||||
let r = |x: vectorcraft_geom::Rect, rotation: f64| json!({ "x": x.x0, "y": x.y0, "width": x.width(), "height": x.height(), "rotation": rotation });
|
||||
match recognize(&pts) {
|
||||
Some(Recognized::Line { a, b }) => vec![Action::Exec("shape.line".into(), json!({ "x1": a.x, "y1": a.y, "x2": b.x, "y2": b.y }))],
|
||||
Some(Recognized::Rectangle(x)) => vec![Action::Exec("shape.rectangle".into(), r(x))],
|
||||
Some(Recognized::Ellipse(x)) => vec![Action::Exec("shape.ellipse".into(), r(x))],
|
||||
Some(Recognized::Rectangle { rect, rotation }) => vec![Action::Exec("shape.rectangle".into(), r(rect, rotation))],
|
||||
Some(Recognized::Ellipse { rect, rotation }) => vec![Action::Exec("shape.ellipse".into(), r(rect, rotation))],
|
||||
Some(Recognized::Polygon { center, radius, sides, rotation }) => {
|
||||
vec![Action::Exec(
|
||||
"shape.polygon".into(),
|
||||
json!({ "cx": center.x, "cy": center.y, "radius": radius, "sides": sides, "rotation": rotation }),
|
||||
)]
|
||||
}
|
||||
Some(Recognized::Scribble(_)) => {
|
||||
// Delete the topmost objects the scribble crosses.
|
||||
let mut ids: Vec<u64> = vec![];
|
||||
for p in &pts {
|
||||
if let Some(h) = vectorcraft_doc::hit::hit_test(cx.doc, *p, cx.hit_options()) {
|
||||
let id = h.top_object(cx.isolation).0;
|
||||
if !ids.contains(&id) {
|
||||
ids.push(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
if ids.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
vec![Action::Exec("select.set".into(), json!({ "ids": ids })), Action::Exec("edit.clear".into(), json!({ "ids": ids }))]
|
||||
}
|
||||
Some(Recognized::Scribble(_)) => vec![Action::Exec(
|
||||
"shaper.scribble".into(),
|
||||
json!({"points": pts.iter().map(|p| [p.x, p.y]).collect::<Vec<_>>(), "tolerance": cx.tol(4.0)}),
|
||||
)],
|
||||
None => vec![],
|
||||
}
|
||||
}
|
||||
PointerKind::DoubleClick => vec![
|
||||
Action::Exec("shaper.select".into(), json!({"point": [ev.pos.x, ev.pos.y], "source": true})),
|
||||
Action::SwitchTool("selection".into()),
|
||||
],
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -368,6 +368,7 @@ impl ToolContext<'_> {
|
||||
outline: self.outline,
|
||||
path_only: self.path_only,
|
||||
type_path_only: self.type_path_only,
|
||||
scope: self.isolation,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -364,6 +364,13 @@ impl Tool for SelectionTool {
|
||||
|
||||
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
|
||||
let mut o = pathtype::overlays(cx);
|
||||
if let Some(source) = cx.isolation.and_then(|id| cx.doc.node(id)).filter(|n| n.name.as_deref() == Some(vectorcraft_doc::shaper::SOURCES)) {
|
||||
for n in source.children().into_iter().flatten() {
|
||||
if let Some(path) = n.path_data() {
|
||||
o.push(Overlay::Path { path: path.to_bezpath(), color: [0x80, 0x80, 0x80], width: 1.0, dashed: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
match &self.state {
|
||||
State::Marquee { start, cur, .. } => o.push(Overlay::Marquee(Rect::from_points(*start, *cur))),
|
||||
State::Corner(c) => o.extend(c.overlays(cx)),
|
||||
|
||||
@@ -1072,7 +1072,8 @@ fn too_many_anchors<'a>(nodes: impl IntoIterator<Item = &'a Node>) -> bool {
|
||||
None => return true,
|
||||
}
|
||||
}
|
||||
!matches!(n.kind, NodeKind::Envelope { .. }) && n.children().into_iter().flatten().any(|c| over(c, left))
|
||||
!matches!(n.kind, NodeKind::Envelope { .. })
|
||||
&& n.children().into_iter().flatten().skip(usize::from(n.shaper.is_some())).any(|c| over(c, left))
|
||||
}
|
||||
let mut left = OVERLAY_MAX_ANCHORS;
|
||||
nodes.into_iter().any(|n| over(n, &mut left))
|
||||
@@ -1085,7 +1086,7 @@ fn walk_drawn<'a>(n: &'a Node, f: &mut impl FnMut(&'a Node)) {
|
||||
if matches!(n.kind, NodeKind::Envelope { .. }) {
|
||||
return;
|
||||
}
|
||||
for c in n.children().into_iter().flatten() {
|
||||
for c in n.children().into_iter().flatten().skip(usize::from(n.shaper.is_some())) {
|
||||
walk_drawn(c, f);
|
||||
}
|
||||
}
|
||||
@@ -1100,6 +1101,7 @@ fn hit_at(app: &VectorcraftApp, p: Point, zoom: f64) -> Option<vectorcraft_doc::
|
||||
outline: app.ui.view.outline,
|
||||
path_only: prefs.object_selection_by_path_only,
|
||||
type_path_only: prefs.type_selection_by_path_only,
|
||||
scope: app.session.active().and_then(|st| st.isolation),
|
||||
};
|
||||
vectorcraft_doc::hit::hit_test(&app.session.active()?.doc, p, opt)
|
||||
}
|
||||
@@ -1837,6 +1839,24 @@ fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use vectorcraft_engine::Session;
|
||||
use vectorcraft_geom::Shape as _;
|
||||
|
||||
#[test]
|
||||
fn shaper_selection_highlights_only_the_visible_result() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
s.execute("shape.ellipse", &json!({"x":100,"y":100,"width":100,"height":100})).unwrap();
|
||||
let line = vectorcraft_doc::NodeId(s.execute("shape.line", &json!({"x1":50,"y1":150,"x2":250,"y2":150})).unwrap()["id"].as_u64().unwrap());
|
||||
for x in [0.0, 150.0] {
|
||||
s.execute("shaper.scribble", &json!({"points":[[60.0+x,140],[70.0+x,160],[80.0+x,140],[90.0+x,160]]})).unwrap();
|
||||
}
|
||||
let st = s.active().unwrap();
|
||||
let g = st.doc.node(st.selection.objects[0]).unwrap();
|
||||
assert!(g.shaper.is_some());
|
||||
let bounds = node_outline(g).bounding_box();
|
||||
assert!((bounds.x0 - 100.0).abs() < 0.01 && (bounds.x1 - 200.0).abs() < 0.01, "{bounds:?}");
|
||||
assert_eq!(node_outline(st.doc.node(line).unwrap()).bounding_box().x0, 50.0, "an isolated original still shows its full outline");
|
||||
}
|
||||
|
||||
/// One headless canvas frame on an 800 × 600 window.
|
||||
fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec<egui::Event>) {
|
||||
|
||||
@@ -634,3 +634,7 @@
|
||||
Clipboard Handling Práce se schránkou
|
||||
Appearance of Black Vzhled černé
|
||||
Devices Zařízení
|
||||
Shaper Shaper
|
||||
Expand Shaper Group Rozšířit skupinu Shaper
|
||||
Release Shaper Group Uvolnit skupinu Shaper
|
||||
Select Shaper Face Vybrat oblast Shaper
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3598,3 +3598,7 @@ axis Both Ambos
|
||||
Off: an editable copy of its art, embedded in this document. Desactivado: una copia editable de su ilustración, incrustada en este documento.
|
||||
Images: link to the file instead of only embedding it. Imágenes: vincular al archivo en lugar de solo incrustarlo.
|
||||
VectorCraft documents: one locked object, updated when the file changes. Documentos de VectorCraft: un objeto bloqueado que se actualiza cuando cambia el archivo.
|
||||
Shaper Shaper
|
||||
Expand Shaper Group Expandir grupo Shaper
|
||||
Release Shaper Group Soltar grupo Shaper
|
||||
Select Shaper Face Seleccionar cara Shaper
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3598,3 +3598,7 @@ axis Both Entrambi
|
||||
Off: an editable copy of its art, embedded in this document. Disattivato: una copia modificabile della sua grafica, incorporata in questo documento.
|
||||
Images: link to the file instead of only embedding it. Immagini: collega il file invece di incorporarlo soltanto.
|
||||
VectorCraft documents: one locked object, updated when the file changes. Documenti VectorCraft: un unico oggetto bloccato, aggiornato quando il file cambia.
|
||||
Shaper Shaper
|
||||
Expand Shaper Group Espandi gruppo Shaper
|
||||
Release Shaper Group Rilascia gruppo Shaper
|
||||
Select Shaper Face Seleziona faccia Shaper
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3060,3 +3060,7 @@ theme Appearance 外観
|
||||
Off: an editable copy of its art, embedded in this document. オフ:アートの編集可能なコピーをこのドキュメントに埋め込みます。
|
||||
Images: link to the file instead of only embedding it. 画像:埋め込むだけでなく、ファイルにリンクします。
|
||||
VectorCraft documents: one locked object, updated when the file changes. VectorCraft ドキュメント:ロックされた 1 つのオブジェクト。ファイルの変更時に更新されます。
|
||||
Shaper Shaper
|
||||
Expand Shaper Group Shaper グループを拡張
|
||||
Release Shaper Group Shaper グループを解除
|
||||
Select Shaper Face Shaper の面を選択
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -2648,3 +2648,7 @@
|
||||
Off: an editable copy of its art, embedded in this document. Desativado: uma cópia editável da arte, incorporada neste documento.
|
||||
Images: link to the file instead of only embedding it. Imagens: vincular ao arquivo em vez de apenas incorporá-lo.
|
||||
VectorCraft documents: one locked object, updated when the file changes. Documentos do VectorCraft: um objeto bloqueado, atualizado quando o arquivo muda.
|
||||
Shaper Formador
|
||||
Expand Shaper Group Expandir Grupo Formador
|
||||
Release Shaper Group Liberar Grupo Formador
|
||||
Select Shaper Face Selecionar Face do Formador
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -3315,3 +3315,6 @@ axis Both 两者
|
||||
Off: an editable copy of its art, embedded in this document. 关闭:将其图稿的可编辑副本嵌入此文档。
|
||||
Images: link to the file instead of only embedding it. 图像:链接到文件,而不只是嵌入。
|
||||
VectorCraft documents: one locked object, updated when the file changes. VectorCraft 文档:一个锁定对象,文件更改时随之更新。
|
||||
Expand Shaper Group 扩展 Shaper 组
|
||||
Release Shaper Group 释放 Shaper 组
|
||||
Select Shaper Face 选择 Shaper 区域
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3090,3 +3090,7 @@ axis Both 兩者
|
||||
Off: an editable copy of its art, embedded in this document. 關閉:將其圖稿的可編輯副本內嵌在此文件中。
|
||||
Images: link to the file instead of only embedding it. 影像:連結至檔案,而不只是嵌入。
|
||||
VectorCraft documents: one locked object, updated when the file changes. VectorCraft 文件:單一鎖定物件,檔案變更時隨之更新。
|
||||
Shaper Shaper
|
||||
Expand Shaper Group 展開 Shaper 群組
|
||||
Release Shaper Group 釋放 Shaper 群組
|
||||
Select Shaper Face 選取 Shaper 區域
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -0,0 +1,18 @@
|
||||
# Shaper
|
||||
|
||||
Sketch a basic shape with the Shaper tool (Shift+N). Rectangles, squares and ellipses snap to 45° rotation steps; hexagons to 90°. Triangles point up or down; sideways sketches snap to one of those orientations. Lines keep the angle you draw, including while Shift is held.
|
||||
|
||||
Scribble over overlapping paths to make an editable Shaper Group:
|
||||
|
||||
- Start in a filled region and cross into other regions to merge the regions touched. The result uses the starting region’s fill.
|
||||
- Scribble within a region to remove its fill and keep its stroke. Hidden overlap boundaries are part of the same visible face; visible dividing strokes create separate faces.
|
||||
- End the scribble outside the artwork to remove the touched fills and exposed boundary strokes. Shared edges along surviving filled areas remain, closing their contours. Compatible stroke pieces join at the new corners, so the selected stroke’s join style applies there.
|
||||
- Scribble over an unfilled stroke segment to remove that piece. A line passing through a shape is split at the intersections, so its protruding ends can be removed separately.
|
||||
|
||||
Click once to select the group; click again to select its visible fill and change its color. Double-click with Shaper to enter construction editing and select an original shape. The Selection tool then moves or resizes it normally, and the visible composition updates. Escape leaves construction editing. The original live shapes and edit recipes survive native save/reopen. Outside construction editing, selection highlights and bounds show only the visible result.
|
||||
|
||||
Object › Shaper › Release restores the original paths. Expand keeps the visible result as ordinary paths. Each scribble and source edit records one undo step. Sources in a composition must share a parent layer/group; Shaper limits a composition to 64 source paths and 4,096 segments.
|
||||
|
||||
The construction-mode arrow widget and finer gesture heuristics remain to be implemented.
|
||||
|
||||
Behavior reference: [public Shaper documentation](https://helpx.adobe.com/illustrator/using/building-new-shapes-using-shape.html). Rotation steps and independent fill/stroke cases follow the contributor’s supplied requirements. No reference images or product assets are included.
|
||||
Reference in new issue
Block a user