M0: workspace skeleton — geom, color, doc, render, text, pathops, svg, tools, engine

Engine-first Cargo workspace for DrawCraft (clean-room Illustrator):
- geom: anchor-based path model, shapes, hit testing, snapping
- color: colours, paints, gradients, swatches, blend modes, harmonies
- doc: persistent Arc document tree, appearance stacks, text model, selection
- render: vello_cpu renderer (appearance, gradients, clip groups, blend modes)
- text: font DB, harfrust shaping, point/area/path layout
- pathops: curve booleans (linesweeper), Pathfinder, offset, simplify
- svg: import (usvg) and export
- tools: selection/direct/pen/shape tools emitting commands
- engine: Session, history with interactions, ~110 commands

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-09-30 05:53:29 -04:00
co-authored by Claude Opus 5.5
parent fa1689bfd8
commit bfee80a22c
102 changed files with 17872 additions and 0 deletions
+5
View File
@@ -1,2 +1,7 @@
*~
plan/
/target
/dist
/corpus
/log
.DS_Store
Generated
+1270
View File
File diff suppressed because it is too large Load Diff
+71
View File
@@ -0,0 +1,71 @@
[workspace]
resolver = "3"
members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/drawcraft", "apps/drawcraft-cli", "xtask"]
[workspace.package]
version = "0.1.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
repository = "https://github.com/storytold/drawcraft"
[workspace.dependencies]
# internal
drawcraft-geom = { path = "crates/geom" }
drawcraft-color = { path = "crates/color" }
drawcraft-doc = { path = "crates/doc" }
drawcraft-pathops = { path = "crates/pathops" }
drawcraft-text = { path = "crates/text" }
drawcraft-effects = { path = "crates/effects" }
drawcraft-render = { path = "crates/render" }
drawcraft-svg = { path = "crates/svg" }
drawcraft-pdf = { path = "crates/pdf" }
drawcraft-format = { path = "crates/format" }
drawcraft-tools = { path = "crates/tools" }
drawcraft-engine = { path = "crates/engine" }
drawcraft-ui-egui = { path = "crates/ui-egui" }
drawcraft-mcp = { path = "crates/mcp" }
drawcraft-testkit = { path = "crates/testkit" }
# foundation
kurbo = { version = "0.13", features = ["serde"] }
peniko = "0.6"
thiserror = "2"
serde = { version = "1", features = ["derive", "rc"] }
serde_json = "1"
log = "0.4"
proptest = "1"
# rendering / formats
vello_cpu = "0.2"
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif"] }
usvg = "0.48"
krilla = "0.8"
skrifa = "0.47"
harfrust = "0.13"
linesweeper = "0.4"
# ui
egui = "0.36"
egui_extras = { version = "0.36", default-features = false, features = ["svg"] }
eframe = { version = "0.36", default-features = false }
egui_kittest = "0.36"
rfd = "0.17"
[workspace.lints.rust]
unsafe_code = "deny"
[workspace.lints.clippy]
dbg_macro = "warn"
todo = "warn"
[profile.dev.package."*"]
opt-level = 2
[profile.dev]
opt-level = 1
[profile.release]
lto = "thin"
codegen-units = 1
+11
View File
@@ -0,0 +1,11 @@
[package]
name = "drawcraft-cli"
version.workspace = true
edition.workspace = true
license.workspace = true
publish = false
[lints]
workspace = true
[dependencies]
+1
View File
@@ -0,0 +1 @@
fn main() {}
+11
View File
@@ -0,0 +1,11 @@
[package]
name = "drawcraft-web"
version.workspace = true
edition.workspace = true
license.workspace = true
publish = false
[lints]
workspace = true
[dependencies]
+1
View File
@@ -0,0 +1 @@
fn main() {}
+11
View File
@@ -0,0 +1,11 @@
[package]
name = "drawcraft"
version.workspace = true
edition.workspace = true
license.workspace = true
publish = false
[lints]
workspace = true
[dependencies]
+1
View File
@@ -0,0 +1 @@
fn main() {}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+92
View File
@@ -0,0 +1,92 @@
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION AND CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2010-2024 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source'. All Rights Reserved. Source is a trademark of Adobe in the United States and/or other countries.
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2014 - 2023 Adobe (http://www.adobe.com/), with Reserved Font Name ‘Source’. All Rights Reserved. Source is a trademark of Adobe in the United States and/or other countries.
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "drawcraft-color"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "DrawCraft color"
[lints]
workspace = true
[dependencies]
kurbo = { workspace = true }
serde = { workspace = true }
thiserror = { workspace = true }
[dev-dependencies]
proptest = { workspace = true }
serde_json = { workspace = true }
+100
View File
@@ -0,0 +1,100 @@
//! Blend modes (the 16 modes of Illustrator's Transparency panel, same as PDF's).
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum BlendMode {
#[default]
Normal,
Darken,
Multiply,
ColorBurn,
Lighten,
Screen,
ColorDodge,
Overlay,
SoftLight,
HardLight,
Difference,
Exclusion,
Hue,
Saturation,
Color,
Luminosity,
}
impl BlendMode {
/// In Transparency-panel order (grouped as Illustrator groups them).
pub const ALL: [BlendMode; 16] = [
BlendMode::Normal,
BlendMode::Darken,
BlendMode::Multiply,
BlendMode::ColorBurn,
BlendMode::Lighten,
BlendMode::Screen,
BlendMode::ColorDodge,
BlendMode::Overlay,
BlendMode::SoftLight,
BlendMode::HardLight,
BlendMode::Difference,
BlendMode::Exclusion,
BlendMode::Hue,
BlendMode::Saturation,
BlendMode::Color,
BlendMode::Luminosity,
];
pub fn label(self) -> &'static str {
match self {
BlendMode::Normal => "Normal",
BlendMode::Darken => "Darken",
BlendMode::Multiply => "Multiply",
BlendMode::ColorBurn => "Color Burn",
BlendMode::Lighten => "Lighten",
BlendMode::Screen => "Screen",
BlendMode::ColorDodge => "Color Dodge",
BlendMode::Overlay => "Overlay",
BlendMode::SoftLight => "Soft Light",
BlendMode::HardLight => "Hard Light",
BlendMode::Difference => "Difference",
BlendMode::Exclusion => "Exclusion",
BlendMode::Hue => "Hue",
BlendMode::Saturation => "Saturation",
BlendMode::Color => "Color",
BlendMode::Luminosity => "Luminosity",
}
}
/// Index of the separator group (the panel draws a divider between groups).
pub fn group(self) -> u8 {
match self {
BlendMode::Normal => 0,
BlendMode::Darken | BlendMode::Multiply | BlendMode::ColorBurn => 1,
BlendMode::Lighten | BlendMode::Screen | BlendMode::ColorDodge => 2,
BlendMode::Overlay | BlendMode::SoftLight | BlendMode::HardLight => 3,
BlendMode::Difference | BlendMode::Exclusion => 4,
_ => 5,
}
}
/// Parse a label or identifier, case/space-insensitively.
pub fn parse(s: &str) -> Option<Self> {
let norm = |x: &str| x.to_ascii_lowercase().replace([' ', '_', '-'], "");
let want = norm(s);
Self::ALL.into_iter().find(|m| norm(m.label()) == want)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_labels() {
assert_eq!(BlendMode::parse("color burn"), Some(BlendMode::ColorBurn));
assert_eq!(BlendMode::parse("ColorBurn"), Some(BlendMode::ColorBurn));
assert_eq!(BlendMode::parse("soft_light"), Some(BlendMode::SoftLight));
assert_eq!(BlendMode::parse("nope"), None);
for m in BlendMode::ALL {
assert_eq!(BlendMode::parse(m.label()), Some(m));
}
}
}
+224
View File
@@ -0,0 +1,224 @@
//! Gradients: definitions (swatch-able) and their placement on an object.
use kurbo::{Affine, Point, Rect, Vec2};
use serde::{Deserialize, Serialize};
use crate::Color;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum GradientKind {
#[default]
Linear,
Radial,
/// Freeform gradients (points/lines) — rendered via a mesh approximation.
Freeform,
}
impl GradientKind {
pub fn label(self) -> &'static str {
match self {
GradientKind::Linear => "Linear",
GradientKind::Radial => "Radial",
GradientKind::Freeform => "Freeform",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct GradientStop {
/// 0..=1 along the gradient.
pub offset: f32,
pub color: Color,
#[serde(default = "one")]
pub opacity: f32,
/// Midpoint to the next stop, 0.13..=0.87 (Illustrator's diamond), default 0.5.
#[serde(default = "half")]
pub midpoint: f32,
}
fn one() -> f32 {
1.0
}
fn half() -> f32 {
0.5
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Gradient {
pub kind: GradientKind,
pub stops: Vec<GradientStop>,
}
impl Default for Gradient {
/// Illustrator's default "White, Black" gradient.
fn default() -> Self {
Self {
kind: GradientKind::Linear,
stops: vec![
GradientStop { offset: 0.0, color: Color::WHITE, opacity: 1.0, midpoint: 0.5 },
GradientStop { offset: 1.0, color: Color::BLACK, opacity: 1.0, midpoint: 0.5 },
],
}
}
}
impl Gradient {
/// Colour and opacity at `t` (honours midpoints).
pub fn sample(&self, t: f32) -> (Color, f32) {
let stops = &self.stops;
if stops.is_empty() {
return (Color::BLACK, 1.0);
}
if t <= stops[0].offset {
return (stops[0].color, stops[0].opacity);
}
for w in stops.windows(2) {
let (a, b) = (&w[0], &w[1]);
if t <= b.offset {
let span = (b.offset - a.offset).max(1e-6);
let u = (t - a.offset) / span;
// Map through the midpoint: u=mid → 0.5.
let m = a.midpoint.clamp(0.01, 0.99);
let v = if u < m { 0.5 * u / m } else { 0.5 + 0.5 * (u - m) / (1.0 - m) };
return (a.color.lerp(&b.color, v), a.opacity + (b.opacity - a.opacity) * v);
}
}
let l = stops.last().unwrap();
(l.color, l.opacity)
}
/// Stops expanded so that midpoints are represented as explicit stops (for renderers without midpoints).
pub fn expanded_stops(&self) -> Vec<(f32, Color, f32)> {
let mut out = Vec::new();
for (i, s) in self.stops.iter().enumerate() {
out.push((s.offset, s.color, s.opacity));
if let Some(n) = self.stops.get(i + 1)
&& (s.midpoint - 0.5).abs() > 1e-3
{
let t = s.offset + (n.offset - s.offset) * s.midpoint;
let (c, o) = self.sample(t);
out.push((t, c, o));
}
}
out
}
pub fn reverse(&mut self) {
self.stops.reverse();
for s in &mut self.stops {
s.offset = 1.0 - s.offset;
}
}
pub fn sort(&mut self) {
self.stops.sort_by(|a, b| a.offset.total_cmp(&b.offset));
}
}
/// Where the gradient sits on an object, in document coordinates.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct GradientGeom {
pub start: Point,
pub end: Point,
/// Radial aspect ratio (height / width), 1 = circle.
#[serde(default = "one64")]
pub aspect: f64,
}
fn one64() -> f64 {
1.0
}
impl GradientGeom {
/// Default placement for a bounding box: linear spans left→right through the centre at `angle_deg`,
/// radial is centred with radius = half the larger dimension.
pub fn fit(kind: GradientKind, b: Rect, angle_deg: f64) -> Self {
let c = b.center();
match kind {
GradientKind::Radial => {
let r = b.width().max(b.height()) / 2.0;
Self { start: c, end: c + Vec2::new(r, 0.0), aspect: 1.0 }
}
_ => {
let a = angle_deg.to_radians();
let d = Vec2::new(a.cos(), -a.sin());
// Project the box corners onto the direction to cover the whole box.
let half = (b.width() * d.x.abs() + b.height() * d.y.abs()) / 2.0;
Self { start: c - d * half, end: c + d * half, aspect: 1.0 }
}
}
}
pub fn transform(&mut self, a: Affine) {
self.start = a * self.start;
self.end = a * self.end;
}
pub fn angle_deg(&self) -> f64 {
let v = self.end - self.start;
(-v.y).atan2(v.x).to_degrees()
}
pub fn length(&self) -> f64 {
(self.end - self.start).hypot()
}
}
/// A gradient applied to a fill or stroke.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct GradientPaint {
pub gradient: Gradient,
/// None = fit to the object's bounds at `angle` each render (fresh gradients behave like this).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub geom: Option<GradientGeom>,
#[serde(default)]
pub angle: f64,
/// Name of the gradient swatch, if linked.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub swatch: Option<String>,
}
impl GradientPaint {
pub fn new(gradient: Gradient) -> Self {
Self { gradient, geom: None, angle: 0.0, swatch: None }
}
pub fn resolve(&self, bounds: Rect) -> GradientGeom {
self.geom.unwrap_or_else(|| GradientGeom::fit(self.gradient.kind, bounds, self.angle))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sample_endpoints_and_mid() {
let g = Gradient::default();
assert_eq!(g.sample(0.0).0.to_hex(), "#ffffff");
assert_eq!(g.sample(1.0).0.to_hex(), "#000000");
let mid = g.sample(0.5).0.to_rgb()[0];
assert!((mid - 0.5).abs() < 1e-5);
}
#[test]
fn midpoint_shifts() {
let mut g = Gradient::default();
g.stops[0].midpoint = 0.25;
// At t = 0.25 we should be half way.
assert!((g.sample(0.25).0.to_rgb()[0] - 0.5).abs() < 1e-5);
assert_eq!(g.expanded_stops().len(), 3);
}
#[test]
fn fit_linear_horizontal() {
let g = GradientGeom::fit(GradientKind::Linear, Rect::new(0.0, 0.0, 100.0, 50.0), 0.0);
assert_eq!(g.start, Point::new(0.0, 25.0));
assert_eq!(g.end, Point::new(100.0, 25.0));
assert!((g.angle_deg()).abs() < 1e-9);
let v = GradientGeom::fit(GradientKind::Linear, Rect::new(0.0, 0.0, 100.0, 50.0), 90.0);
assert!((v.angle_deg() - 90.0).abs() < 1e-9);
assert!((v.length() - 50.0).abs() < 1e-9);
}
#[test]
fn reverse_stops() {
let mut g = Gradient::default();
g.reverse();
assert_eq!(g.stops[0].color.to_hex(), "#000000");
assert_eq!(g.stops[0].offset, 0.0);
}
}
+83
View File
@@ -0,0 +1,83 @@
//! Colour harmony rules (the Color Guide panel).
use crate::Color;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Harmony {
Complementary,
SplitComplementary,
LeftComplement,
RightComplement,
Analogous,
Triad,
Tetrad,
Square,
Shades,
}
impl Harmony {
pub const ALL: [Harmony; 9] = [
Harmony::Complementary,
Harmony::SplitComplementary,
Harmony::LeftComplement,
Harmony::RightComplement,
Harmony::Analogous,
Harmony::Triad,
Harmony::Tetrad,
Harmony::Square,
Harmony::Shades,
];
pub fn label(self) -> &'static str {
match self {
Harmony::Complementary => "Complementary",
Harmony::SplitComplementary => "Split Complementary",
Harmony::LeftComplement => "Left Complement",
Harmony::RightComplement => "Right Complement",
Harmony::Analogous => "Analogous",
Harmony::Triad => "Triad",
Harmony::Tetrad => "Tetrad",
Harmony::Square => "Square",
Harmony::Shades => "Shades",
}
}
/// Colours of the harmony, base colour first.
pub fn apply(self, base: Color) -> Vec<Color> {
let [h, s, v] = base.to_hsb();
let hue = |d: f32| Color::from_hsb(h + d, s, v);
match self {
Harmony::Complementary => vec![base, hue(180.0)],
Harmony::SplitComplementary => vec![base, hue(150.0), hue(210.0)],
Harmony::LeftComplement => vec![base, hue(150.0)],
Harmony::RightComplement => vec![base, hue(210.0)],
Harmony::Analogous => vec![hue(-60.0), hue(-30.0), base, hue(30.0), hue(60.0)],
Harmony::Triad => vec![base, hue(120.0), hue(240.0)],
Harmony::Tetrad => vec![base, hue(60.0), hue(180.0), hue(240.0)],
Harmony::Square => vec![base, hue(90.0), hue(180.0), hue(270.0)],
Harmony::Shades => (0..5).map(|i| Color::from_hsb(h, s, (v * (1.0 - i as f32 * 0.18)).max(0.0))).collect(),
}
}
}
/// Tints and shades row for the Color Guide variation grid.
pub fn variations(base: Color, steps: usize) -> Vec<Color> {
let n = steps.max(1) as f32;
(0..steps)
.map(|i| {
let t = (i as f32 + 1.0) / (n + 1.0);
if i < steps / 2 { base.lerp(&Color::BLACK, 1.0 - t * 2.0) } else { base.lerp(&Color::WHITE, (t - 0.5) * 2.0) }
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn complementary_red() {
let c = Harmony::Complementary.apply(Color::rgb(1.0, 0.0, 0.0));
assert_eq!(c[1].to_hex(), "#00ffff");
assert_eq!(Harmony::Triad.apply(Color::rgb(1.0, 0.0, 0.0))[1].to_hex(), "#00ff00");
assert_eq!(variations(Color::rgb(1.0, 0.0, 0.0), 6).len(), 6);
}
}
+248
View File
@@ -0,0 +1,248 @@
//! Colour, paint and blend-mode types for DrawCraft.
//!
//! Colours keep the model the user picked them in (RGB, CMYK, Gray, HSB is a UI view of RGB), so
//! documents don't drift when converting back and forth. Rendering asks for [`Color::to_rgba`].
#![forbid(unsafe_code)]
pub mod blend;
pub mod gradient;
pub mod harmony;
pub mod swatch;
pub use blend::BlendMode;
pub use gradient::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
pub use swatch::{Swatch, SwatchGroup, default_swatches};
use serde::{Deserialize, Serialize};
/// A colour in its authoring model. Components are 0..=1.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "model", rename_all = "lowercase")]
pub enum Color {
Rgb { r: f32, g: f32, b: f32 },
Cmyk { c: f32, m: f32, y: f32, k: f32 },
Gray { k: f32 },
}
impl Default for Color {
fn default() -> Self {
Color::BLACK
}
}
impl Color {
pub const BLACK: Color = Color::Rgb { r: 0.0, g: 0.0, b: 0.0 };
pub const WHITE: Color = Color::Rgb { r: 1.0, g: 1.0, b: 1.0 };
pub fn rgb(r: f32, g: f32, b: f32) -> Self {
Color::Rgb { r, g, b }
}
pub fn rgb8(r: u8, g: u8, b: u8) -> Self {
Color::Rgb { r: r as f32 / 255.0, g: g as f32 / 255.0, b: b as f32 / 255.0 }
}
pub fn cmyk(c: f32, m: f32, y: f32, k: f32) -> Self {
Color::Cmyk { c, m, y, k }
}
pub fn gray(k: f32) -> Self {
Color::Gray { k }
}
/// Display RGB (naive, profile-free conversion; colour management comes later).
pub fn to_rgb(&self) -> [f32; 3] {
match *self {
Color::Rgb { r, g, b } => [r, g, b],
Color::Cmyk { c, m, y, k } => [(1.0 - c) * (1.0 - k), (1.0 - m) * (1.0 - k), (1.0 - y) * (1.0 - k)],
// Illustrator's Gray is ink percentage: 0 = white, 1 = black.
Color::Gray { k } => [1.0 - k; 3],
}
}
pub fn to_rgba8(&self, alpha: f32) -> [u8; 4] {
let [r, g, b] = self.to_rgb();
let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
[q(r), q(g), q(b), q(alpha)]
}
pub fn to_cmyk(&self) -> [f32; 4] {
match *self {
Color::Cmyk { c, m, y, k } => [c, m, y, k],
_ => {
let [r, g, b] = self.to_rgb();
let k = 1.0 - r.max(g).max(b);
if k >= 1.0 {
return [0.0, 0.0, 0.0, 1.0];
}
[(1.0 - r - k) / (1.0 - k), (1.0 - g - k) / (1.0 - k), (1.0 - b - k) / (1.0 - k), k]
}
}
}
/// HSB with hue in degrees 0..360, saturation and brightness 0..1.
pub fn to_hsb(&self) -> [f32; 3] {
let [r, g, b] = self.to_rgb();
let max = r.max(g).max(b);
let min = r.min(g).min(b);
let d = max - min;
let h = if d <= 0.0 {
0.0
} else if max == r {
60.0 * (((g - b) / d).rem_euclid(6.0))
} else if max == g {
60.0 * ((b - r) / d + 2.0)
} else {
60.0 * ((r - g) / d + 4.0)
};
let s = if max <= 0.0 { 0.0 } else { d / max };
[h, s, max]
}
pub fn from_hsb(h: f32, s: f32, v: f32) -> Self {
let h = h.rem_euclid(360.0) / 60.0;
let c = v * s;
let x = c * (1.0 - (h % 2.0 - 1.0).abs());
let (r, g, b) = match h as u32 {
0 => (c, x, 0.0),
1 => (x, c, 0.0),
2 => (0.0, c, x),
3 => (0.0, x, c),
4 => (x, 0.0, c),
_ => (c, 0.0, x),
};
let m = v - c;
Color::Rgb { r: r + m, g: g + m, b: b + m }
}
/// `#rrggbb` (display RGB).
pub fn to_hex(&self) -> String {
let [r, g, b, _] = self.to_rgba8(1.0);
format!("#{r:02x}{g:02x}{b:02x}")
}
/// Parse `#rgb`, `#rrggbb` or a few CSS names.
pub fn from_hex(s: &str) -> Option<Self> {
let s = s.trim();
match s.to_ascii_lowercase().as_str() {
"black" => return Some(Color::BLACK),
"white" => return Some(Color::WHITE),
"red" => return Some(Color::rgb(1.0, 0.0, 0.0)),
"green" => return Some(Color::rgb8(0, 128, 0)),
"blue" => return Some(Color::rgb(0.0, 0.0, 1.0)),
_ => {}
}
let h = s.strip_prefix('#').unwrap_or(s);
let p = |i: usize, n: usize| u8::from_str_radix(h.get(i..i + n)?, 16).ok();
match h.len() {
3 => Some(Color::rgb8(p(0, 1)? * 17, p(1, 1)? * 17, p(2, 1)? * 17)),
6 | 8 => Some(Color::rgb8(p(0, 2)?, p(2, 2)?, p(4, 2)?)),
_ => None,
}
}
/// Complement (Illustrator's Edit → Edit Colors → Invert is 1 - rgb; this is hue + 180).
pub fn complement(&self) -> Self {
let [h, s, v] = self.to_hsb();
Color::from_hsb(h + 180.0, s, v)
}
pub fn invert(&self) -> Self {
let [r, g, b] = self.to_rgb();
Color::rgb(1.0 - r, 1.0 - g, 1.0 - b)
}
/// Linear interpolation in display RGB.
pub fn lerp(&self, other: &Color, t: f32) -> Color {
let a = self.to_rgb();
let b = other.to_rgb();
Color::rgb(a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t)
}
pub fn model_name(&self) -> &'static str {
match self {
Color::Rgb { .. } => "RGB",
Color::Cmyk { .. } => "CMYK",
Color::Gray { .. } => "Grayscale",
}
}
}
/// What fills or strokes an object.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum Paint {
#[default]
None,
Solid {
color: Color,
/// Name of the global swatch this colour is linked to (edits to the swatch update it).
#[serde(default, skip_serializing_if = "Option::is_none")]
swatch: Option<String>,
},
Gradient(Box<GradientPaint>),
Pattern {
pattern: String,
},
}
impl Paint {
pub fn solid(c: Color) -> Self {
Paint::Solid { color: c, swatch: None }
}
pub fn is_none(&self) -> bool {
matches!(self, Paint::None)
}
pub fn color(&self) -> Option<Color> {
match self {
Paint::Solid { color, .. } => Some(*color),
_ => None,
}
}
pub fn label(&self) -> String {
match self {
Paint::None => "None".into(),
Paint::Solid { color, swatch: Some(n) } => format!("{n} ({})", color.to_hex()),
Paint::Solid { color, .. } => color.to_hex(),
Paint::Gradient(g) => format!("{} gradient", g.gradient.kind.label()),
Paint::Pattern { pattern } => format!("pattern {pattern}"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hex_roundtrip() {
let c = Color::from_hex("#ff8000").unwrap();
assert_eq!(c.to_hex(), "#ff8000");
assert_eq!(Color::from_hex("#fff").unwrap().to_hex(), "#ffffff");
assert_eq!(Color::from_hex("zz"), None);
}
#[test]
fn hsb_roundtrip() {
for hex in ["#ff0000", "#00ff00", "#0000ff", "#336699", "#ffffff", "#000000", "#c0ffee"] {
let c = Color::from_hex(hex).unwrap();
let [h, s, v] = c.to_hsb();
assert_eq!(Color::from_hsb(h, s, v).to_hex(), hex, "{hex}");
}
}
#[test]
fn cmyk_conversions() {
assert_eq!(Color::cmyk(0.0, 0.0, 0.0, 1.0).to_hex(), "#000000");
assert_eq!(Color::cmyk(1.0, 0.0, 0.0, 0.0).to_hex(), "#00ffff");
let k = Color::rgb(1.0, 0.0, 0.0).to_cmyk();
assert_eq!(k, [0.0, 1.0, 1.0, 0.0]);
}
#[test]
fn gray_is_ink() {
assert_eq!(Color::gray(0.0).to_hex(), "#ffffff");
assert_eq!(Color::gray(1.0).to_hex(), "#000000");
}
#[test]
fn complement_and_invert() {
assert_eq!(Color::rgb(1.0, 0.0, 0.0).complement().to_hex(), "#00ffff");
assert_eq!(Color::rgb(1.0, 0.0, 0.0).invert().to_hex(), "#00ffff");
}
#[test]
fn paint_serde() {
let p = Paint::solid(Color::rgb8(10, 20, 30));
let s = serde_json::to_string(&p).unwrap();
assert!(s.contains("\"type\":\"solid\""));
assert_eq!(serde_json::from_str::<Paint>(&s).unwrap(), p);
}
}
+119
View File
@@ -0,0 +1,119 @@
//! Swatches and the default swatch set (our own palette, not Adobe's).
use serde::{Deserialize, Serialize};
use crate::{Color, Gradient, GradientKind, GradientStop, Paint};
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Swatch {
pub name: String,
pub paint: Paint,
/// Global swatches update every object that uses them.
#[serde(default)]
pub global: bool,
/// Spot colour (prints on its own plate).
#[serde(default)]
pub spot: bool,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct SwatchGroup {
pub name: String,
pub swatches: Vec<Swatch>,
}
fn solid(name: &str, hex: &str) -> Swatch {
Swatch { name: name.into(), paint: Paint::solid(Color::from_hex(hex).unwrap()), global: false, spot: false }
}
/// The default document swatches: None, Registration-like black, white, black, a spectrum, greys,
/// gradients. (Composition is ours; Illustrator's layout grammar — None first, then specials — is kept.)
pub fn default_swatches() -> (Vec<Swatch>, Vec<SwatchGroup>) {
let mut s =
vec![Swatch { name: "[None]".into(), paint: Paint::None, global: false, spot: false }, solid("White", "#ffffff"), solid("Black", "#000000")];
let spectrum = [
("Red", "#ed1c24"),
("Orange Red", "#f15a24"),
("Orange", "#f7931e"),
("Amber", "#fbb03b"),
("Yellow", "#fcee21"),
("Yellow Green", "#d9e021"),
("Lime", "#8cc63f"),
("Green", "#39b54a"),
("Emerald", "#009245"),
("Teal", "#006837"),
("Aqua", "#22b573"),
("Cyan Green", "#00a99d"),
("Cyan", "#29abe2"),
("Azure", "#0071bc"),
("Blue", "#2e3192"),
("Indigo", "#1b1464"),
("Violet", "#662d91"),
("Purple", "#93278f"),
("Magenta", "#9e005d"),
("Rose", "#d4145a"),
("Pink", "#ed1e79"),
("Brown", "#c7b299"),
("Tan", "#998675"),
("Coffee", "#736357"),
("Chocolate", "#534741"),
("Sand", "#c69c6d"),
("Copper", "#a67c52"),
("Rust", "#8c6239"),
("Walnut", "#754c24"),
("Espresso", "#603813"),
];
s.extend(spectrum.iter().map(|(n, h)| solid(n, h)));
let grays: Vec<Swatch> = (1..=9)
.rev()
.map(|i| {
let k = i as f32 / 10.0;
let c = Color::gray(k);
Swatch { name: format!("K={}", (k * 100.0) as u32), paint: Paint::solid(c), global: false, spot: false }
})
.collect();
let grad = |name: &str, a: &str, b: &str, kind: GradientKind| Swatch {
name: name.into(),
paint: Paint::Gradient(Box::new(crate::GradientPaint::new(Gradient {
kind,
stops: vec![
GradientStop { offset: 0.0, color: Color::from_hex(a).unwrap(), opacity: 1.0, midpoint: 0.5 },
GradientStop { offset: 1.0, color: Color::from_hex(b).unwrap(), opacity: 1.0, midpoint: 0.5 },
],
}))),
global: false,
spot: false,
};
s.push(grad("White, Black", "#ffffff", "#000000", GradientKind::Linear));
s.push(grad("Radial White, Black", "#ffffff", "#000000", GradientKind::Radial));
s.push(grad("Sunset", "#fbb03b", "#d4145a", GradientKind::Linear));
s.push(grad("Ocean", "#29abe2", "#2e3192", GradientKind::Linear));
let groups = vec![
SwatchGroup { name: "Grays".into(), swatches: grays },
SwatchGroup {
name: "Brights".into(),
swatches: vec![
solid("Bright Red", "#ff1d25"),
solid("Bright Yellow", "#ffe600"),
solid("Bright Green", "#00e676"),
solid("Bright Blue", "#2979ff"),
solid("Bright Violet", "#d500f9"),
],
},
];
(s, groups)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn defaults_start_with_none() {
let (s, g) = default_swatches();
assert!(s[0].paint.is_none());
assert!(s.len() > 30);
assert_eq!(g.len(), 2);
assert_eq!(g[0].swatches.len(), 9);
}
}
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "drawcraft-doc"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "DrawCraft document model: artboards, layers, objects, appearance (pure data + serde)"
[lints]
workspace = true
[dependencies]
drawcraft-geom = { workspace = true }
drawcraft-color = { workspace = true }
kurbo = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
[dev-dependencies]
proptest = { workspace = true }
+354
View File
@@ -0,0 +1,354 @@
//! The appearance model: a stack of fills and strokes, each with its own opacity, blend mode and
//! effects, plus object-level effects.
use drawcraft_color::{BlendMode, Color, Paint};
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum LineCap {
#[default]
Butt,
Round,
Square,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum LineJoin {
#[default]
Miter,
Round,
Bevel,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum StrokeAlign {
#[default]
Center,
Inside,
Outside,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Dash {
/// Dash, gap, dash, gap… (up to 6 values in the Stroke panel).
pub pattern: Vec<f64>,
#[serde(default)]
pub offset: f64,
/// "Aligns dashes to corners and path ends, adjusting lengths to fit".
#[serde(default)]
pub align_corners: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Arrowhead {
Triangle,
TriangleOpen,
Circle,
CircleOpen,
Square,
SquareOpen,
Bar,
Diamond,
Arrow,
ArrowOpen,
}
impl Arrowhead {
pub const ALL: [Arrowhead; 10] = [
Arrowhead::Arrow,
Arrowhead::ArrowOpen,
Arrowhead::Triangle,
Arrowhead::TriangleOpen,
Arrowhead::Circle,
Arrowhead::CircleOpen,
Arrowhead::Square,
Arrowhead::SquareOpen,
Arrowhead::Diamond,
Arrowhead::Bar,
];
}
/// Variable-width profile: (position 0..1 along the path, left width factor, right width factor).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct WidthProfile {
pub points: Vec<(f64, f64, f64)>,
}
impl WidthProfile {
/// Width factor at `t` (average of both sides), linear between points.
pub fn at(&self, t: f64) -> (f64, f64) {
let p = &self.points;
if p.is_empty() {
return (1.0, 1.0);
}
if t <= p[0].0 {
return (p[0].1, p[0].2);
}
for w in p.windows(2) {
if t <= w[1].0 {
let u = (t - w[0].0) / (w[1].0 - w[0].0).max(1e-9);
return (w[0].1 + (w[1].1 - w[0].1) * u, w[0].2 + (w[1].2 - w[0].2) * u);
}
}
let l = p.last().unwrap();
(l.1, l.2)
}
/// Illustrator's "Width Profile 1" (lens shape) analogue.
pub fn lens() -> Self {
Self { points: vec![(0.0, 0.0, 0.0), (0.5, 1.0, 1.0), (1.0, 0.0, 0.0)] }
}
pub fn taper_end() -> Self {
Self { points: vec![(0.0, 1.0, 1.0), (1.0, 0.0, 0.0)] }
}
pub fn taper_start() -> Self {
Self { points: vec![(0.0, 0.0, 0.0), (1.0, 1.0, 1.0)] }
}
}
/// A live effect in an appearance stack. Parameters are interpreted by `drawcraft-effects`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Effect {
/// Stable id, e.g. `stylize.dropShadow`, `distort.roughen`, `path.offsetPath`, `warp.arc`.
pub id: String,
#[serde(default)]
pub params: serde_json::Value,
#[serde(default = "yes")]
pub visible: bool,
}
fn yes() -> bool {
true
}
fn one() -> f32 {
1.0
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct FillLayer {
pub paint: Paint,
#[serde(default = "one")]
pub opacity: f32,
#[serde(default)]
pub blend: BlendMode,
#[serde(default = "yes")]
pub visible: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub effects: Vec<Effect>,
}
impl FillLayer {
pub fn new(paint: Paint) -> Self {
Self { paint, opacity: 1.0, blend: BlendMode::Normal, visible: true, effects: vec![] }
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct StrokeLayer {
pub paint: Paint,
/// Weight in points.
pub width: f64,
#[serde(default)]
pub cap: LineCap,
#[serde(default)]
pub join: LineJoin,
#[serde(default = "ten")]
pub miter_limit: f64,
#[serde(default)]
pub align: StrokeAlign,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dash: Option<Dash>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub start_arrow: Option<Arrowhead>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub end_arrow: Option<Arrowhead>,
/// Arrowhead scale in percent (start, end).
#[serde(default = "hundreds")]
pub arrow_scale: (f64, f64),
#[serde(default, skip_serializing_if = "Option::is_none")]
pub profile: Option<WidthProfile>,
/// Brush applied to the stroke (by brush name).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub brush: Option<String>,
#[serde(default = "one")]
pub opacity: f32,
#[serde(default)]
pub blend: BlendMode,
#[serde(default = "yes")]
pub visible: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub effects: Vec<Effect>,
}
fn ten() -> f64 {
10.0
}
fn hundreds() -> (f64, f64) {
(100.0, 100.0)
}
impl StrokeLayer {
pub fn new(paint: Paint, width: f64) -> Self {
Self {
paint,
width,
cap: LineCap::Butt,
join: LineJoin::Miter,
miter_limit: 10.0,
align: StrokeAlign::Center,
dash: None,
start_arrow: None,
end_arrow: None,
arrow_scale: (100.0, 100.0),
profile: None,
brush: None,
opacity: 1.0,
blend: BlendMode::Normal,
visible: true,
effects: vec![],
}
}
}
/// One entry of the appearance stack.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "lowercase")]
pub enum AppearanceItem {
Fill(FillLayer),
Stroke(StrokeLayer),
}
/// Appearance attributes. `items` is in paint order: `items[0]` is painted first (the bottom of the
/// Appearance panel list).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Appearance {
#[serde(default)]
pub items: Vec<AppearanceItem>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub effects: Vec<Effect>,
}
impl Appearance {
/// Illustrator's "basic appearance": one fill below one stroke.
pub fn basic(fill: Paint, stroke: Paint, width: f64) -> Self {
Self { items: vec![AppearanceItem::Fill(FillLayer::new(fill)), AppearanceItem::Stroke(StrokeLayer::new(stroke, width))], effects: vec![] }
}
/// White fill, 1 pt black stroke (the default for new art).
pub fn default_art() -> Self {
Self::basic(Paint::solid(Color::WHITE), Paint::solid(Color::BLACK), 1.0)
}
/// The topmost fill (what the Fill proxy shows).
pub fn fill(&self) -> Option<&FillLayer> {
self.items.iter().rev().find_map(|i| if let AppearanceItem::Fill(f) = i { Some(f) } else { None })
}
pub fn fill_mut(&mut self) -> Option<&mut FillLayer> {
self.items.iter_mut().rev().find_map(|i| if let AppearanceItem::Fill(f) = i { Some(f) } else { None })
}
/// The topmost stroke (what the Stroke proxy shows).
pub fn stroke(&self) -> Option<&StrokeLayer> {
self.items.iter().rev().find_map(|i| if let AppearanceItem::Stroke(s) = i { Some(s) } else { None })
}
pub fn stroke_mut(&mut self) -> Option<&mut StrokeLayer> {
self.items.iter_mut().rev().find_map(|i| if let AppearanceItem::Stroke(s) = i { Some(s) } else { None })
}
pub fn fill_paint(&self) -> Paint {
self.fill().map(|f| f.paint.clone()).unwrap_or(Paint::None)
}
pub fn stroke_paint(&self) -> Paint {
self.stroke().map(|s| s.paint.clone()).unwrap_or(Paint::None)
}
/// Set the top fill's paint, creating a fill if there is none.
pub fn set_fill(&mut self, p: Paint) {
match self.fill_mut() {
Some(f) => f.paint = p,
None => self.items.insert(0, AppearanceItem::Fill(FillLayer::new(p))),
}
}
/// Set the top stroke's paint, creating a 1 pt stroke if there is none.
pub fn set_stroke(&mut self, p: Paint) {
match self.stroke_mut() {
Some(s) => s.paint = p,
None => self.items.push(AppearanceItem::Stroke(StrokeLayer::new(p, 1.0))),
}
}
pub fn stroke_width(&self) -> f64 {
self.stroke().filter(|s| !s.paint.is_none()).map(|s| s.width).unwrap_or(0.0)
}
/// Is this the basic one-fill-one-stroke appearance without effects?
pub fn is_basic(&self) -> bool {
self.effects.is_empty()
&& self.items.len() <= 2
&& self.items.iter().filter(|i| matches!(i, AppearanceItem::Fill(_))).count() <= 1
&& self.items.iter().all(|i| match i {
AppearanceItem::Fill(f) => f.effects.is_empty() && f.opacity == 1.0 && f.blend == BlendMode::Normal,
AppearanceItem::Stroke(s) => s.effects.is_empty() && s.opacity == 1.0 && s.blend == BlendMode::Normal,
})
}
/// Largest distance the painted area extends beyond the geometry (for visual bounds).
pub fn outset(&self) -> f64 {
self.items
.iter()
.filter_map(|i| match i {
AppearanceItem::Stroke(s) if s.visible && !s.paint.is_none() => Some(match s.align {
StrokeAlign::Center => s.width / 2.0 * if s.join == LineJoin::Miter { s.miter_limit.min(4.0) } else { 1.0 },
StrokeAlign::Outside => s.width,
StrokeAlign::Inside => 0.0,
}),
_ => None,
})
.fold(0.0, f64::max)
}
/// Scale stroke weights (Scale Strokes & Effects).
pub fn scale_strokes(&mut self, s: f64) {
for i in &mut self.items {
if let AppearanceItem::Stroke(st) = i {
st.width *= s;
if let Some(d) = &mut st.dash {
for v in &mut d.pattern {
*v *= s;
}
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn basic_appearance() {
let a = Appearance::default_art();
assert!(a.is_basic());
assert_eq!(a.fill_paint(), Paint::solid(Color::WHITE));
assert_eq!(a.stroke_width(), 1.0);
}
#[test]
fn set_creates_missing() {
let mut a = Appearance::default();
a.set_fill(Paint::solid(Color::BLACK));
a.set_stroke(Paint::solid(Color::WHITE));
assert_eq!(a.items.len(), 2);
assert!(matches!(a.items[0], AppearanceItem::Fill(_)));
}
#[test]
fn profile_interp() {
let p = WidthProfile::lens();
assert_eq!(p.at(0.5), (1.0, 1.0));
assert_eq!(p.at(0.25), (0.5, 0.5));
assert_eq!(p.at(2.0), (0.0, 0.0));
}
#[test]
fn outset_depends_on_align() {
let mut a = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 10.0);
a.stroke_mut().unwrap().join = LineJoin::Round;
assert_eq!(a.outset(), 5.0);
a.stroke_mut().unwrap().align = StrokeAlign::Outside;
assert_eq!(a.outset(), 10.0);
a.stroke_mut().unwrap().align = StrokeAlign::Inside;
assert_eq!(a.outset(), 0.0);
}
}
+256
View File
@@ -0,0 +1,256 @@
//! Hit testing over the document tree.
use drawcraft_geom::hit::{fill_contains, stroke_contains};
use drawcraft_geom::{Point, Rect};
use crate::{Document, Node, NodeId, NodeKind};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum HitKind {
Fill,
Stroke,
/// Hit a path outline in outline mode, or an unfilled path's edge.
Outline,
Bounds,
}
/// The result of a hit test: the leaf that was hit and its ancestry (layer first).
#[derive(Clone, Debug, PartialEq)]
pub struct Hit {
pub leaf: NodeId,
pub ancestry: Vec<NodeId>,
pub kind: HitKind,
}
impl Hit {
/// The object the Selection tool selects: the child of the layer (the outermost group).
/// Inside isolation mode (`scope`), the child of the isolated container instead.
pub fn top_object(&self, scope: Option<NodeId>) -> NodeId {
if let Some(s) = scope
&& let Some(i) = self.ancestry.iter().position(|x| *x == s)
{
return self.ancestry.get(i + 1).copied().unwrap_or(self.leaf);
}
// Skip layers and sublayers.
self.ancestry.get(1).copied().unwrap_or(self.leaf)
}
}
/// Options for hit testing.
#[derive(Clone, Copy, Debug)]
pub struct HitOptions {
/// Tolerance in document units (screen tolerance / zoom).
pub tol: f64,
/// Outline mode: only outlines are hittable.
pub outline: bool,
/// "Object Selection by Path Only" preference.
pub path_only: bool,
}
impl Default for HitOptions {
fn default() -> Self {
Self { tol: 3.0, outline: false, path_only: false }
}
}
/// Topmost editable object under `p`.
pub fn hit_test(doc: &Document, p: Point, opt: HitOptions) -> Option<Hit> {
let mut chain = Vec::new();
for layer in doc.layers.iter().rev() {
if !layer.visible || layer.locked {
continue;
}
if let NodeKind::Layer { template: true, .. } = layer.kind {
continue;
}
chain.push(layer.id);
if let Some(h) = hit_children(layer, p, opt, &mut chain) {
return Some(h);
}
chain.pop();
}
None
}
fn hit_children(parent: &Node, p: Point, opt: HitOptions, chain: &mut Vec<NodeId>) -> Option<Hit> {
let children = parent.children()?;
// A clip group only hits inside its clipping path.
if let NodeKind::Group { clip: true, .. } = parent.kind
&& let Some(clip) = children.first()
&& let Some(path) = clip.path_data()
&& !fill_contains(&path.to_bezpath(), drawcraft_geom::FillRule::NonZero, p)
{
return None;
}
for c in children.iter().rev() {
if !c.visible || c.locked {
continue;
}
if let Some(b) = c.visual_bounds()
&& !b.inflate(opt.tol, opt.tol).contains(p)
{
continue;
}
chain.push(c.id);
let hit = match &c.kind {
NodeKind::Layer { .. } | NodeKind::Group { .. } => hit_children(c, p, opt, chain),
_ => hit_leaf(c, p, opt).map(|kind| Hit { leaf: c.id, ancestry: chain.clone(), kind }),
};
if hit.is_some() {
return hit;
}
chain.pop();
}
None
}
fn hit_leaf(n: &Node, p: Point, opt: HitOptions) -> Option<HitKind> {
match &n.kind {
NodeKind::Path { path, rule, .. } => {
let bp = path.to_bezpath();
let sw = n.appearance.stroke_width();
if stroke_contains(&bp, if opt.outline { 0.0 } else { sw }, opt.tol, p) {
return Some(if opt.outline || sw == 0.0 { HitKind::Outline } else { HitKind::Stroke });
}
let filled = !n.appearance.fill_paint().is_none();
if !opt.outline && !opt.path_only && filled && fill_contains(&bp, *rule, p) {
return Some(HitKind::Fill);
}
None
}
NodeKind::Compound { children, rule } => {
let mut bp = drawcraft_geom::BezPath::new();
for c in children {
if let Some(pd) = c.path_data() {
bp.extend(pd.to_bezpath());
}
}
if stroke_contains(&bp, n.appearance.stroke_width(), opt.tol, p) {
return Some(HitKind::Stroke);
}
(!opt.outline && fill_contains(&bp, *rule, p)).then_some(HitKind::Fill)
}
NodeKind::Text(_) | NodeKind::Image(_) | NodeKind::SymbolInstance { .. } => {
n.geometric_bounds().filter(|b| b.inflate(opt.tol, opt.tol).contains(p)).map(|_| HitKind::Bounds)
}
_ => None,
}
}
/// Objects (children of layers, or of `scope` in isolation mode) touched by a marquee rect.
pub fn marquee(doc: &Document, r: Rect, scope: Option<NodeId>, leaves: bool) -> Vec<NodeId> {
let mut out = Vec::new();
let tops: Vec<&std::sync::Arc<Node>> = match scope.and_then(|s| doc.node(s)) {
Some(s) => s.children().map(|c| c.iter().collect()).unwrap_or_default(),
None => doc.layers.iter().filter(|l| l.visible && !l.locked).flat_map(|l| l.children().into_iter().flatten()).collect(),
};
fn touches(n: &Node, r: Rect) -> bool {
match &n.kind {
NodeKind::Path { path, .. } => drawcraft_geom::hit::intersects_rect(path, r),
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Compound { children, .. } => {
children.iter().any(|c| c.visible && touches(c, r))
}
_ => n.geometric_bounds().is_some_and(|b| b.intersect(r).area() > 0.0 || r.contains(b.origin())),
}
}
fn collect_leaves(n: &Node, r: Rect, out: &mut Vec<NodeId>) {
match &n.kind {
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => {
for c in children {
if c.visible && !c.locked {
collect_leaves(c, r, out);
}
}
}
_ => {
if touches(n, r) {
out.push(n.id);
}
}
}
}
for t in tops {
if !t.visible || t.locked {
continue;
}
if leaves {
collect_leaves(t, r, &mut out);
} else if touches(t, r) {
out.push(t.id);
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Appearance, Node};
use drawcraft_color::{Color, Paint};
use drawcraft_geom::shapes;
use std::sync::Arc;
#[test]
fn hits_topmost() {
let mut d = Document::new(200.0, 200.0);
let l = d.layers[0].id;
let a = d.alloc_id();
d.insert(Some(l), 9, Node::path(a, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art())).unwrap();
let b = d.alloc_id();
d.insert(Some(l), 9, Node::path(b, shapes::rectangle(Rect::new(25.0, 25.0, 75.0, 75.0)), Appearance::default_art())).unwrap();
let h = hit_test(&d, Point::new(30.0, 30.0), HitOptions::default()).unwrap();
assert_eq!(h.leaf, b);
assert_eq!(h.kind, HitKind::Fill);
let h = hit_test(&d, Point::new(10.0, 10.0), HitOptions::default()).unwrap();
assert_eq!(h.leaf, a);
assert!(hit_test(&d, Point::new(150.0, 150.0), HitOptions::default()).is_none());
// Outline mode: interior misses.
assert!(hit_test(&d, Point::new(10.0, 10.0), HitOptions { outline: true, ..Default::default() }).is_none());
assert!(hit_test(&d, Point::new(0.5, 10.0), HitOptions { outline: true, ..Default::default() }).is_some());
}
#[test]
fn unfilled_interior_misses() {
let mut d = Document::new(200.0, 200.0);
let l = d.layers[0].id;
let a = d.alloc_id();
d.insert(
Some(l),
9,
Node::path(a, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0)),
)
.unwrap();
assert!(hit_test(&d, Point::new(25.0, 25.0), HitOptions::default()).is_none());
assert!(hit_test(&d, Point::new(50.5, 25.0), HitOptions::default()).is_some());
}
#[test]
fn group_top_object() {
let mut d = Document::new(200.0, 200.0);
let l = d.layers[0].id;
let a = d.alloc_id();
let g = d.alloc_id();
let p = Node::path(a, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art());
d.insert(Some(l), 0, Node::group(g, vec![Arc::new(p)])).unwrap();
let h = hit_test(&d, Point::new(10.0, 10.0), HitOptions::default()).unwrap();
assert_eq!(h.leaf, a);
assert_eq!(h.top_object(None), g);
assert_eq!(h.top_object(Some(g)), a);
assert_eq!(marquee(&d, Rect::new(-5.0, -5.0, 5.0, 5.0), None, false), vec![g]);
assert_eq!(marquee(&d, Rect::new(-5.0, -5.0, 5.0, 5.0), None, true), vec![a]);
assert!(marquee(&d, Rect::new(100.0, 100.0, 105.0, 105.0), None, false).is_empty());
}
#[test]
fn locked_and_hidden_skip() {
let mut d = Document::new(200.0, 200.0);
let l = d.layers[0].id;
let a = d.alloc_id();
d.insert(Some(l), 0, Node::path(a, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art())).unwrap();
d.node_mut(a).unwrap().locked = true;
assert!(hit_test(&d, Point::new(10.0, 10.0), HitOptions::default()).is_none());
d.node_mut(a).unwrap().locked = false;
d.node_mut(l).unwrap().visible = false;
assert!(hit_test(&d, Point::new(10.0, 10.0), HitOptions::default()).is_none());
}
}
+656
View File
@@ -0,0 +1,656 @@
//! DrawCraft document model: artboards, layers and objects, appearance, swatches (pure data + serde).
//!
//! Documents are persistent trees: children are `Arc<Node>`, and edits go through
//! [`Document::node_mut`], which clones only the nodes on the path from the root to the edited node.
//! Keeping the previous `Document` value around is therefore a cheap undo snapshot.
#![forbid(unsafe_code)]
pub mod appearance;
pub mod hit;
pub mod node;
pub mod selection;
pub mod text;
use std::collections::BTreeMap;
use std::sync::Arc;
pub use appearance::{Appearance, AppearanceItem, Arrowhead, Dash, Effect, FillLayer, LineCap, LineJoin, StrokeAlign, StrokeLayer, WidthProfile};
pub use drawcraft_color as color;
pub use drawcraft_geom as geom;
pub use hit::{Hit, HitKind};
pub use node::{ImageObject, LAYER_COLORS, LayerColor, LiveShape, Node, NodeId, NodeKind};
pub use selection::{AnchorRef, Selection};
pub use text::{CharStyle, Justify, ParaStyle, TextKind, TextObject, TextRun};
use drawcraft_color::{Swatch, SwatchGroup};
use drawcraft_geom::{Point, Rect};
use serde::{Deserialize, Serialize};
#[derive(Debug, thiserror::Error, PartialEq)]
pub enum DocError {
#[error("no such object {0}")]
NoNode(NodeId),
#[error("object {0} cannot have children")]
NotContainer(NodeId),
#[error("{0}")]
Invalid(String),
}
/// Measurement units (display only; the model is always in points).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum Unit {
#[default]
Points,
Picas,
Inches,
Millimeters,
Centimeters,
Pixels,
FeetInches,
Meters,
Yards,
Feet,
}
impl Unit {
pub const ALL: [Unit; 10] = [
Unit::Points,
Unit::Picas,
Unit::Inches,
Unit::Millimeters,
Unit::Centimeters,
Unit::Pixels,
Unit::FeetInches,
Unit::Meters,
Unit::Yards,
Unit::Feet,
];
/// Points per unit.
pub fn points(self) -> f64 {
match self {
Unit::Points | Unit::Pixels => 1.0,
Unit::Picas => 12.0,
Unit::Inches => 72.0,
Unit::Millimeters => 72.0 / 25.4,
Unit::Centimeters => 72.0 / 2.54,
Unit::Meters => 72.0 / 0.0254,
Unit::Yards => 72.0 * 36.0,
Unit::Feet | Unit::FeetInches => 72.0 * 12.0,
}
}
pub fn suffix(self) -> &'static str {
match self {
Unit::Points => "pt",
Unit::Picas => "p",
Unit::Inches => "in",
Unit::Millimeters => "mm",
Unit::Centimeters => "cm",
Unit::Pixels => "px",
Unit::FeetInches | Unit::Feet => "ft",
Unit::Meters => "m",
Unit::Yards => "yd",
}
}
pub fn label(self) -> &'static str {
match self {
Unit::Points => "Points",
Unit::Picas => "Picas",
Unit::Inches => "Inches",
Unit::Millimeters => "Millimeters",
Unit::Centimeters => "Centimeters",
Unit::Pixels => "Pixels",
Unit::FeetInches => "Feet & Inches",
Unit::Meters => "Meters",
Unit::Yards => "Yards",
Unit::Feet => "Feet",
}
}
pub fn from_pt(self, v: f64) -> f64 {
v / self.points()
}
pub fn to_pt(self, v: f64) -> f64 {
v * self.points()
}
/// Format a point value in this unit the way fields show it (e.g. `12.5 pt`, `3 in`).
pub fn format(self, pt: f64) -> String {
let v = self.from_pt(pt);
let s = format!("{:.3}", v);
let s = s.trim_end_matches('0').trim_end_matches('.');
let s = if s == "-0" { "0" } else { s };
format!("{s} {}", self.suffix())
}
/// Parse `12`, `12pt`, `1in`, `3 mm`, `2p6` (picas+points), simple `+ - * /` arithmetic.
pub fn parse(self, s: &str) -> Option<f64> {
parse_measure(s, self)
}
}
fn parse_measure(s: &str, default: Unit) -> Option<f64> {
let s = s.trim();
// Simple arithmetic on the right (Illustrator fields accept "10+5", "100/2", "3in*2").
for op in ['+', '-', '*', '/'] {
if let Some(i) = s[1.min(s.len())..].rfind(op).map(|i| i + 1.min(s.len())) {
let (l, r) = (&s[..i], &s[i + 1..]);
if l.trim().is_empty() {
continue;
}
let a = parse_measure(l, default)?;
return match op {
'+' => Some(a + parse_measure(r, default)?),
'-' => Some(a - parse_measure(r, default)?),
'*' => Some(a * r.trim().parse::<f64>().ok()?),
_ => {
let d = r.trim().parse::<f64>().ok()?;
if d == 0.0 { None } else { Some(a / d) }
}
};
}
}
if let Some((p, pt)) = s.split_once('p')
&& !p.is_empty()
&& p.trim().parse::<f64>().is_ok()
&& (pt.is_empty() || pt.trim().parse::<f64>().is_ok())
&& !s.ends_with("pt")
&& !s.ends_with("px")
{
return Some(p.trim().parse::<f64>().ok()? * 12.0 + pt.trim().parse::<f64>().unwrap_or(0.0));
}
let num_end = s.find(|c: char| !(c.is_ascii_digit() || c == '.' || c == '-')).unwrap_or(s.len());
let v: f64 = s[..num_end].trim().parse().ok()?;
let unit = match s[num_end..].trim() {
"" => default,
"pt" => Unit::Points,
"px" => Unit::Pixels,
"in" | "\"" => Unit::Inches,
"mm" => Unit::Millimeters,
"cm" => Unit::Centimeters,
"m" => Unit::Meters,
"ft" | "'" => Unit::Feet,
"yd" => Unit::Yards,
"pc" => Unit::Picas,
_ => return None,
};
Some(unit.to_pt(v))
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum ColorMode {
#[default]
Rgb,
Cmyk,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Artboard {
pub id: u32,
pub name: String,
/// Artboard rectangle in document points.
pub rect: Rect,
#[serde(default)]
pub show_center_mark: bool,
#[serde(default)]
pub show_cross_hairs: bool,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Guide {
/// true = vertical guide at `pos` (x), false = horizontal at `pos` (y).
pub vertical: bool,
pub pos: f64,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct GridPrefs {
pub spacing: f64,
pub subdivisions: u32,
}
impl Default for GridPrefs {
fn default() -> Self {
Self { spacing: 72.0, subdivisions: 8 }
}
}
/// A named graphic style (Graphic Styles panel).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct GraphicStyle {
pub name: String,
pub appearance: Appearance,
}
/// A symbol definition.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Symbol {
pub name: String,
pub art: Arc<Node>,
}
/// Encoded image bytes (PNG/JPEG/WebP…) shared by image objects.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ImageBlob {
pub mime: String,
#[serde(skip)]
pub bytes: Arc<Vec<u8>>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Document {
pub version: u32,
#[serde(default)]
pub title: String,
#[serde(default)]
pub units: Unit,
#[serde(default)]
pub color_mode: ColorMode,
pub artboards: Vec<Artboard>,
/// Top-level layers, bottom first.
pub layers: Vec<Arc<Node>>,
#[serde(default)]
pub swatches: Vec<Swatch>,
#[serde(default)]
pub swatch_groups: Vec<SwatchGroup>,
#[serde(default)]
pub graphic_styles: Vec<GraphicStyle>,
#[serde(default)]
pub symbols: Vec<Symbol>,
#[serde(default)]
pub guides: Vec<Guide>,
#[serde(default)]
pub grid: GridPrefs,
#[serde(default = "ppi72")]
pub raster_effects_ppi: f64,
#[serde(default)]
pub images: BTreeMap<String, ImageBlob>,
next_id: u64,
/// Foreign data preserved on round-trip.
#[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
pub unknown: serde_json::Map<String, serde_json::Value>,
}
fn ppi72() -> f64 {
72.0
}
pub const FORMAT_VERSION: u32 = 1;
impl Document {
/// A new document with one artboard of `size` and one layer ("Layer 1").
pub fn new(width: f64, height: f64) -> Self {
let (swatches, swatch_groups) = drawcraft_color::default_swatches();
let mut d = Self {
version: FORMAT_VERSION,
title: "Untitled-1".into(),
units: Unit::Points,
color_mode: ColorMode::Rgb,
artboards: vec![Artboard {
id: 1,
name: "Artboard 1".into(),
rect: Rect::new(0.0, 0.0, width, height),
show_center_mark: false,
show_cross_hairs: false,
}],
layers: vec![],
swatches,
swatch_groups,
graphic_styles: default_graphic_styles(),
symbols: vec![],
guides: vec![],
grid: GridPrefs::default(),
raster_effects_ppi: 72.0,
images: BTreeMap::new(),
next_id: 1,
unknown: Default::default(),
};
let id = d.alloc_id();
d.layers.push(Arc::new(Node::layer(id, "Layer 1", LayerColor::Preset(0))));
d
}
/// Allocate a fresh node id.
pub fn alloc_id(&mut self) -> NodeId {
let id = NodeId(self.next_id);
self.next_id += 1;
id
}
pub fn peek_next_id(&self) -> u64 {
self.next_id
}
/// Ensure `next_id` is above every id in the tree (after deserializing foreign data).
pub fn fix_next_id(&mut self) {
let mut max = 0;
for l in &self.layers {
l.walk(&mut |n| max = max.max(n.id.0));
}
self.next_id = self.next_id.max(max + 1);
}
/// Find a node anywhere in the tree.
pub fn node(&self, id: NodeId) -> Option<&Node> {
fn find(nodes: &[Arc<Node>], id: NodeId) -> Option<&Node> {
for n in nodes {
if n.id == id {
return Some(n);
}
if let Some(ch) = n.children()
&& let Some(f) = find(ch, id)
{
return Some(f);
}
}
None
}
find(&self.layers, id)
}
/// Index path from the top-level layer list to `id` (e.g. `[0, 3, 1]`).
pub fn index_path(&self, id: NodeId) -> Option<Vec<usize>> {
fn find(nodes: &[Arc<Node>], id: NodeId, path: &mut Vec<usize>) -> bool {
for (i, n) in nodes.iter().enumerate() {
path.push(i);
if n.id == id {
return true;
}
if let Some(ch) = n.children()
&& find(ch, id, path)
{
return true;
}
path.pop();
}
false
}
let mut p = vec![];
find(&self.layers, id, &mut p).then_some(p)
}
/// Ids from the top-level layer down to `id` inclusive.
pub fn ancestry(&self, id: NodeId) -> Option<Vec<NodeId>> {
let path = self.index_path(id)?;
let mut out = Vec::with_capacity(path.len());
let mut nodes = &self.layers;
for &i in &path {
let n = &nodes[i];
out.push(n.id);
if let Some(ch) = n.children() {
nodes = ch;
}
}
Some(out)
}
pub fn parent_of(&self, id: NodeId) -> Option<NodeId> {
let a = self.ancestry(id)?;
(a.len() >= 2).then(|| a[a.len() - 2])
}
/// The top-level layer that contains `id`.
pub fn layer_of(&self, id: NodeId) -> Option<NodeId> {
self.ancestry(id).and_then(|a| a.first().copied())
}
/// Mutable access to a node; clones the Arc path (copy-on-write).
pub fn node_mut(&mut self, id: NodeId) -> Option<&mut Node> {
let path = self.index_path(id)?;
let mut cur: &mut Arc<Node> = &mut self.layers[path[0]];
for &i in &path[1..] {
cur = &mut Arc::make_mut(cur).children_mut()?[i];
}
Some(Arc::make_mut(cur))
}
/// Children list of a container (or the top-level layer list for `None`).
pub fn children_mut(&mut self, parent: Option<NodeId>) -> Result<&mut Vec<Arc<Node>>, DocError> {
match parent {
None => Ok(&mut self.layers),
Some(p) => self.node_mut(p).ok_or(DocError::NoNode(p))?.children_mut().ok_or(DocError::NotContainer(p)),
}
}
pub fn children(&self, parent: Option<NodeId>) -> Option<&Vec<Arc<Node>>> {
match parent {
None => Some(&self.layers),
Some(p) => self.node(p)?.children(),
}
}
/// Insert `node` into `parent` at `index` (clamped; `usize::MAX` = top).
pub fn insert(&mut self, parent: Option<NodeId>, index: usize, node: Node) -> Result<NodeId, DocError> {
let id = node.id;
let ch = self.children_mut(parent)?;
let i = index.min(ch.len());
ch.insert(i, Arc::new(node));
Ok(id)
}
/// Remove a node and return it.
pub fn remove(&mut self, id: NodeId) -> Result<Arc<Node>, DocError> {
let parent = self.parent_of(id);
let path = self.index_path(id).ok_or(DocError::NoNode(id))?;
let idx = *path.last().unwrap();
let ch = self.children_mut(parent)?;
Ok(ch.remove(idx))
}
/// Move a node to `parent` at `index` (index measured after removal).
pub fn move_node(&mut self, id: NodeId, parent: Option<NodeId>, index: usize) -> Result<(), DocError> {
if let Some(p) = parent
&& self.ancestry(p).is_some_and(|a| a.contains(&id))
{
return Err(DocError::Invalid("cannot move an object into itself".into()));
}
let n = self.remove(id)?;
let ch = self.children_mut(parent)?;
let i = index.min(ch.len());
ch.insert(i, n);
Ok(())
}
/// Position of `id` within its parent: (parent, index, sibling count).
pub fn position(&self, id: NodeId) -> Option<(Option<NodeId>, usize, usize)> {
let parent = self.parent_of(id);
let idx = *self.index_path(id)?.last()?;
let n = self.children(parent)?.len();
Some((parent, idx, n))
}
/// Default target layer: the topmost visible, unlocked layer.
pub fn default_layer(&self) -> Option<NodeId> {
self.layers.iter().rev().find(|l| l.visible && !l.locked).or(self.layers.last()).map(|l| l.id)
}
/// Add a new top-level layer above all others.
pub fn add_layer(&mut self, name: Option<&str>) -> NodeId {
let id = self.alloc_id();
let n = self.layers.len();
let name = name.map(str::to_string).unwrap_or_else(|| self.next_layer_name());
self.layers.push(Arc::new(Node::layer(id, &name, LayerColor::Preset((n % LAYER_COLORS.len()) as u8))));
id
}
pub fn next_layer_name(&self) -> String {
let mut i = self.layers.len() + 1;
loop {
let name = format!("Layer {i}");
if !self.layers.iter().any(|l| l.name.as_deref() == Some(&name)) {
return name;
}
i += 1;
}
}
/// Visit every node depth first in paint order (bottom to top).
pub fn walk<'a>(&'a self, mut f: impl FnMut(&'a Node)) {
for l in &self.layers {
l.walk(&mut f);
}
}
pub fn node_count(&self) -> usize {
self.layers.iter().map(|l| l.count()).sum()
}
/// Is the node (and every ancestor) visible and unlocked?
pub fn is_editable(&self, id: NodeId) -> bool {
self.ancestry(id).is_some_and(|a| a.iter().all(|i| self.node(*i).is_some_and(|n| n.visible && !n.locked)))
}
pub fn is_visible(&self, id: NodeId) -> bool {
self.ancestry(id).is_some_and(|a| a.iter().all(|i| self.node(*i).is_some_and(|n| n.visible)))
}
/// Colour of the layer containing `id` (selection highlight colour).
pub fn layer_color(&self, id: NodeId) -> [u8; 3] {
let l = self.layer_of(id).and_then(|l| self.node(l));
match l.map(|n| &n.kind) {
Some(NodeKind::Layer { color, .. }) => color.rgb(),
_ => LAYER_COLORS[0].1,
}
}
/// Union of the geometric bounds of `ids`.
pub fn bounds_of(&self, ids: &[NodeId], visual: bool) -> Option<Rect> {
ids.iter()
.filter_map(|id| self.node(*id))
.fold(None, |acc, n| drawcraft_geom::union_opt(acc, if visual { n.visual_bounds() } else { n.geometric_bounds() }))
}
/// Bounds of all art.
pub fn art_bounds(&self) -> Option<Rect> {
self.layers.iter().fold(None, |acc, l| drawcraft_geom::union_opt(acc, l.visual_bounds()))
}
/// Artboard index containing point `p` (topmost = last).
pub fn artboard_at(&self, p: Point) -> Option<usize> {
self.artboards.iter().rposition(|a| a.rect.contains(p))
}
pub fn next_artboard_id(&self) -> u32 {
self.artboards.iter().map(|a| a.id).max().unwrap_or(0) + 1
}
pub fn swatch(&self, name: &str) -> Option<&Swatch> {
self.swatches.iter().chain(self.swatch_groups.iter().flat_map(|g| g.swatches.iter())).find(|s| s.name == name)
}
/// Deep-clone `node` with fresh ids for it and all descendants.
pub fn reid(&mut self, node: &Node) -> Node {
let mut n = node.clone();
n.id = self.alloc_id();
if let Some(ch) = n.children_mut() {
let old: Vec<Arc<Node>> = std::mem::take(ch);
let fresh: Vec<Arc<Node>> = old.iter().map(|c| Arc::new(self.reid(c))).collect();
*n.children_mut().unwrap() = fresh;
}
n
}
}
fn default_graphic_styles() -> Vec<GraphicStyle> {
use drawcraft_color::{Color, Paint};
vec![
GraphicStyle { name: "Default Graphic Style".into(), appearance: Appearance::default_art() },
GraphicStyle { name: "Black Outline".into(), appearance: Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0) },
GraphicStyle {
name: "Heavy Ink".into(),
appearance: Appearance::basic(Paint::solid(Color::from_hex("#1b1464").unwrap()), Paint::solid(Color::BLACK), 4.0),
},
GraphicStyle {
name: "Sunshine".into(),
appearance: Appearance::basic(Paint::solid(Color::from_hex("#fbb03b").unwrap()), Paint::solid(Color::from_hex("#f15a24").unwrap()), 2.0),
},
]
}
#[cfg(test)]
mod tests {
use super::*;
use drawcraft_geom::shapes;
fn doc_with_rects() -> (Document, NodeId, NodeId) {
let mut d = Document::new(612.0, 792.0);
let layer = d.layers[0].id;
let a = d.alloc_id();
d.insert(Some(layer), usize::MAX, Node::path(a, shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art())).unwrap();
let b = d.alloc_id();
d.insert(Some(layer), usize::MAX, Node::path(b, shapes::rectangle(Rect::new(20.0, 0.0, 30.0, 10.0)), Appearance::default_art())).unwrap();
(d, a, b)
}
#[test]
fn new_document_has_layer_and_artboard() {
let d = Document::new(612.0, 792.0);
assert_eq!(d.layers.len(), 1);
assert_eq!(d.layers[0].display_name(), "Layer 1");
assert_eq!(d.artboards[0].rect, Rect::new(0.0, 0.0, 612.0, 792.0));
}
#[test]
fn find_and_paths() {
let (d, a, b) = doc_with_rects();
assert_eq!(d.index_path(b), Some(vec![0, 1]));
assert_eq!(d.parent_of(a), Some(d.layers[0].id));
assert_eq!(d.layer_of(b), Some(d.layers[0].id));
assert!(d.node(NodeId(999)).is_none());
assert_eq!(d.node_count(), 3);
}
#[test]
fn copy_on_write_shares_untouched() {
let (d, a, b) = doc_with_rects();
let mut e = d.clone();
e.node_mut(a).unwrap().name = Some("A".into());
// b's Arc is shared between both versions; a's is not.
let lb = &d.layers[0].children().unwrap()[1];
let eb = &e.layers[0].children().unwrap()[1];
assert!(Arc::ptr_eq(lb, eb));
assert_eq!(d.node(a).unwrap().name, None);
assert_eq!(e.node(a).unwrap().name.as_deref(), Some("A"));
let _ = b;
}
#[test]
fn move_and_remove() {
let (mut d, a, b) = doc_with_rects();
let layer = d.layers[0].id;
d.move_node(a, Some(layer), usize::MAX).unwrap();
assert_eq!(d.index_path(a), Some(vec![0, 1]));
assert_eq!(d.index_path(b), Some(vec![0, 0]));
let r = d.remove(a).unwrap();
assert_eq!(r.id, a);
assert!(d.node(a).is_none());
assert!(d.move_node(layer, Some(layer), 0).is_err());
}
#[test]
fn layers_and_names() {
let mut d = Document::new(100.0, 100.0);
let l2 = d.add_layer(None);
assert_eq!(d.node(l2).unwrap().display_name(), "Layer 2");
assert_eq!(d.default_layer(), Some(l2));
assert_eq!(d.layer_color(l2), LAYER_COLORS[1].1);
}
#[test]
fn serde_roundtrip() {
let (d, _, _) = doc_with_rects();
let s = serde_json::to_string(&d).unwrap();
let back: Document = serde_json::from_str(&s).unwrap();
assert_eq!(back.node_count(), d.node_count());
assert_eq!(back.peek_next_id(), d.peek_next_id());
}
#[test]
fn reid_gives_fresh_ids() {
let (mut d, a, b) = doc_with_rects();
let g = d.alloc_id();
let na = (*d.remove(a).unwrap()).clone();
let nb = (*d.remove(b).unwrap()).clone();
let group = Node::group(g, vec![Arc::new(na), Arc::new(nb)]);
let copy = d.reid(&group);
assert_ne!(copy.id, g);
let ids: Vec<NodeId> = copy.children().unwrap().iter().map(|c| c.id).collect();
assert!(!ids.contains(&a) && !ids.contains(&b));
}
#[test]
fn units() {
assert_eq!(Unit::Inches.parse("1"), Some(72.0));
assert_eq!(Unit::Points.parse("1in"), Some(72.0));
assert_eq!(Unit::Points.parse("10 mm").map(|v| (v * 1000.0).round()), Some(28346.0));
assert_eq!(Unit::Points.parse("2p6"), Some(30.0));
assert_eq!(Unit::Points.parse("10+5"), Some(15.0));
assert_eq!(Unit::Points.parse("100/4"), Some(25.0));
assert_eq!(Unit::Points.parse("-5"), Some(-5.0));
assert_eq!(Unit::Points.parse("abc"), None);
assert_eq!(Unit::Points.format(12.5), "12.5 pt");
assert_eq!(Unit::Inches.format(144.0), "2 in");
}
}
+448
View File
@@ -0,0 +1,448 @@
//! Document nodes.
use std::sync::Arc;
use drawcraft_color::BlendMode;
use drawcraft_geom::{Affine, FillRule, PathData, Point, Rect, shapes};
use serde::{Deserialize, Serialize};
use crate::appearance::Appearance;
use crate::text::TextObject;
/// Stable per-document object id. Never reused.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(transparent)]
pub struct NodeId(pub u64);
impl std::fmt::Display for NodeId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "#{}", self.0)
}
}
/// Layer colours used for selection highlighting (index into [`LAYER_COLORS`]) or a custom RGB.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum LayerColor {
Preset(u8),
Custom([u8; 3]),
}
/// Layer highlight colours, assigned in sequence to new layers. First is Illustrator-like light blue.
pub const LAYER_COLORS: [(&str, [u8; 3]); 27] = [
("Light Blue", [0x4f, 0x80, 0xff]),
("Red", [0xff, 0x4f, 0x4f]),
("Green", [0x4f, 0xff, 0x4f]),
("Blue", [0x4f, 0x4f, 0xff]),
("Yellow", [0xff, 0xff, 0x4f]),
("Magenta", [0xff, 0x4f, 0xff]),
("Cyan", [0x4f, 0xff, 0xff]),
("Gray", [0x80, 0x80, 0x80]),
("Black", [0x00, 0x00, 0x00]),
("Orange", [0xff, 0x66, 0x00]),
("Dark Green", [0x00, 0x80, 0x00]),
("Teal", [0x00, 0x80, 0x80]),
("Tan", [0xcc, 0x99, 0x66]),
("Brown", [0x99, 0x33, 0x00]),
("Violet", [0x99, 0x33, 0xff]),
("Gold", [0xff, 0x99, 0x00]),
("Dark Blue", [0x00, 0x00, 0x80]),
("Pink", [0xff, 0x99, 0xcc]),
("Lavender", [0x99, 0x99, 0xff]),
("Brick Red", [0x99, 0x00, 0x00]),
("Olive Green", [0x66, 0x66, 0x00]),
("Peach", [0xff, 0x99, 0x99]),
("Burgundy", [0x99, 0x00, 0x33]),
("Grass Green", [0x99, 0xcc, 0x00]),
("Ochre", [0x99, 0x66, 0x00]),
("Purple", [0x66, 0x00, 0x99]),
("Light Gray", [0xbb, 0xbb, 0xbb]),
];
impl LayerColor {
pub fn rgb(self) -> [u8; 3] {
match self {
LayerColor::Preset(i) => LAYER_COLORS[i as usize % LAYER_COLORS.len()].1,
LayerColor::Custom(c) => c,
}
}
}
/// Live shape parameters (Illustrator's "Live Shapes"). The node's path is regenerated from these
/// while the shape stays live; editing anchors directly converts it to a plain path.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "shape", rename_all = "lowercase")]
pub enum LiveShape {
Rectangle {
/// Untransformed width/height.
w: f64,
h: f64,
/// Corner radii: top-left, top-right, bottom-right, bottom-left.
radii: [f64; 4],
/// Maps the untransformed shape (origin at its top-left) into the document.
xf: Affine,
},
Ellipse {
w: f64,
h: f64,
/// Pie start/end angles in degrees (0/360 = full ellipse).
pie: (f64, f64),
xf: Affine,
},
Polygon {
radius: f64,
sides: u32,
xf: Affine,
},
Line {
a: Point,
b: Point,
},
}
impl LiveShape {
pub fn label(&self) -> &'static str {
match self {
LiveShape::Rectangle { radii, .. } if radii.iter().any(|r| *r > 0.0) => "Rounded Rectangle",
LiveShape::Rectangle { .. } => "Rectangle",
LiveShape::Ellipse { .. } => "Ellipse",
LiveShape::Polygon { .. } => "Polygon",
LiveShape::Line { .. } => "Line",
}
}
/// Regenerate the path.
pub fn to_path(&self) -> PathData {
match self {
LiveShape::Rectangle { w, h, radii, xf } => {
let r = Rect::new(0.0, 0.0, *w, *h);
let p = if radii.iter().all(|x| (x - radii[0]).abs() < 1e-9) {
shapes::rounded_rectangle(r, radii[0])
} else {
// Per-corner radii: generate uniformly then adjust (approximation until M2.5).
shapes::rounded_rectangle(r, radii.iter().cloned().fold(0.0, f64::max))
};
p.transformed(*xf)
}
LiveShape::Ellipse { w, h, xf, .. } => shapes::ellipse(Rect::new(0.0, 0.0, *w, *h)).transformed(*xf),
LiveShape::Polygon { radius, sides, xf } => shapes::polygon(Point::ZERO, *radius, *sides, 0.0).transformed(*xf),
LiveShape::Line { a, b } => shapes::line(*a, *b),
}
}
pub fn transform(&mut self, a: Affine) {
match self {
LiveShape::Rectangle { xf, .. } | LiveShape::Ellipse { xf, .. } | LiveShape::Polygon { xf, .. } => *xf = a * *xf,
LiveShape::Line { a: p, b } => {
*p = a * *p;
*b = a * *b;
}
}
}
/// Rotation angle of the live shape in degrees (shown in the Properties panel).
pub fn angle_deg(&self) -> f64 {
match self {
LiveShape::Rectangle { xf, .. } | LiveShape::Ellipse { xf, .. } | LiveShape::Polygon { xf, .. } => {
let c = xf.as_coeffs();
c[1].atan2(c[0]).to_degrees()
}
LiveShape::Line { a, b } => (b.y - a.y).atan2(b.x - a.x).to_degrees(),
}
}
}
/// Embedded or linked raster image.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ImageObject {
/// Key into [`crate::Document::images`].
pub key: String,
pub width: u32,
pub height: u32,
/// Maps pixel space (0..w, 0..h) into the document.
pub xf: Affine,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub link: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum NodeKind {
/// A top-level layer or sublayer.
Layer {
color: LayerColor,
#[serde(default)]
template: bool,
#[serde(default = "yes")]
printable: bool,
children: Vec<Arc<Node>>,
},
/// A group. With `clip`, the first (bottom-most) child is the clipping path.
Group {
children: Vec<Arc<Node>>,
#[serde(default)]
clip: bool,
},
Path {
path: PathData,
#[serde(default)]
rule: FillRule,
#[serde(default, skip_serializing_if = "Option::is_none")]
live: Option<LiveShape>,
/// This path acts as a clipping path inside a clip group.
#[serde(default)]
clipping: bool,
/// Guide (non-printing) path.
#[serde(default)]
guide: bool,
},
/// Compound path: children are paths painted as one with the compound's appearance.
Compound {
children: Vec<Arc<Node>>,
#[serde(default)]
rule: FillRule,
},
Text(Box<TextObject>),
Image(ImageObject),
SymbolInstance {
symbol: String,
xf: Affine,
},
}
fn yes() -> bool {
true
}
fn one() -> f32 {
1.0
}
/// One object in the tree. Children are `Arc`s so documents share structure between undo states.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Node {
pub id: NodeId,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default = "yes")]
pub visible: bool,
#[serde(default)]
pub locked: bool,
#[serde(default = "one")]
pub opacity: f32,
#[serde(default)]
pub blend: BlendMode,
#[serde(default)]
pub isolate: bool,
#[serde(default)]
pub knockout: bool,
#[serde(default)]
pub appearance: Appearance,
pub kind: NodeKind,
}
impl Node {
pub fn new(id: NodeId, kind: NodeKind) -> Self {
Self {
id,
name: None,
visible: true,
locked: false,
opacity: 1.0,
blend: BlendMode::Normal,
isolate: false,
knockout: false,
appearance: Appearance::default(),
kind,
}
}
pub fn path(id: NodeId, path: PathData, appearance: Appearance) -> Self {
let mut n = Self::new(id, NodeKind::Path { path, rule: FillRule::NonZero, live: None, clipping: false, guide: false });
n.appearance = appearance;
n
}
pub fn group(id: NodeId, children: Vec<Arc<Node>>) -> Self {
Self::new(id, NodeKind::Group { children, clip: false })
}
pub fn layer(id: NodeId, name: &str, color: LayerColor) -> Self {
let mut n = Self::new(id, NodeKind::Layer { color, template: false, printable: true, children: vec![] });
n.name = Some(name.to_string());
n
}
pub fn children(&self) -> Option<&Vec<Arc<Node>>> {
match &self.kind {
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Compound { children, .. } => Some(children),
_ => None,
}
}
pub fn children_mut(&mut self) -> Option<&mut Vec<Arc<Node>>> {
match &mut self.kind {
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Compound { children, .. } => Some(children),
_ => None,
}
}
pub fn is_layer(&self) -> bool {
matches!(self.kind, NodeKind::Layer { .. })
}
pub fn is_container(&self) -> bool {
self.children().is_some()
}
/// Kind name as the Layers panel / Properties panel shows it.
pub fn kind_label(&self) -> &'static str {
match &self.kind {
NodeKind::Layer { .. } => "Layer",
NodeKind::Group { clip: true, .. } => "Clip Group",
NodeKind::Group { .. } => "Group",
NodeKind::Path { live: Some(l), .. } => l.label(),
NodeKind::Path { guide: true, .. } => "Guide",
NodeKind::Path { .. } => "Path",
NodeKind::Compound { .. } => "Compound Path",
NodeKind::Text(_) => "Text",
NodeKind::Image(_) => "Image",
NodeKind::SymbolInstance { .. } => "Symbol",
}
}
/// Name shown in the Layers panel: explicit name or `<Kind>`.
pub fn display_name(&self) -> String {
if let Some(n) = &self.name {
return n.clone();
}
match &self.kind {
NodeKind::Text(t) => {
let s: String = t.plain_text().chars().take(32).collect();
if s.is_empty() { "<Text>".into() } else { s }
}
_ => format!("<{}>", self.kind_label()),
}
}
/// Path data for path nodes.
pub fn path_data(&self) -> Option<&PathData> {
match &self.kind {
NodeKind::Path { path, .. } => Some(path),
_ => None,
}
}
pub fn path_data_mut(&mut self) -> Option<&mut PathData> {
match &mut self.kind {
NodeKind::Path { path, .. } => Some(path),
_ => None,
}
}
/// Geometric bounds (no stroke), recursively. Clip groups are bounded by their clip path.
pub fn geometric_bounds(&self) -> Option<Rect> {
match &self.kind {
NodeKind::Path { path, .. } => path.bounds(),
NodeKind::Group { children, clip: true } => children.first().and_then(|c| c.geometric_bounds()),
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Compound { children, .. } => children
.iter()
.filter(|c| c.visible || !matches!(self.kind, NodeKind::Layer { .. }))
.fold(None, |acc, c| drawcraft_geom::union_opt(acc, c.geometric_bounds())),
NodeKind::Text(t) => t.bounds(),
NodeKind::Image(im) => Some(im.xf.transform_rect_bbox(Rect::new(0.0, 0.0, im.width as f64, im.height as f64))),
NodeKind::SymbolInstance { xf, .. } => Some(xf.transform_rect_bbox(Rect::new(-10.0, -10.0, 10.0, 10.0))),
}
}
/// Visual bounds (includes stroke outset).
pub fn visual_bounds(&self) -> Option<Rect> {
match &self.kind {
NodeKind::Group { children, clip: true } => children.first().and_then(|c| c.geometric_bounds()),
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => {
children.iter().fold(None, |acc, c| drawcraft_geom::union_opt(acc, c.visual_bounds()))
}
_ => {
let o = self.appearance.outset();
self.geometric_bounds().map(|b| b.inflate(o, o))
}
}
}
/// Apply an affine transform to the geometry (and gradients) of this node and its descendants.
/// `scale_strokes` also scales stroke weights by the transform's mean scale.
pub fn transform(&mut self, a: Affine, scale_strokes: bool) {
if scale_strokes {
let det = a.determinant().abs().sqrt();
if (det - 1.0).abs() > 1e-9 {
self.appearance.scale_strokes(det);
}
}
transform_paints(&mut self.appearance, a);
match &mut self.kind {
NodeKind::Path { path, live, .. } => {
path.transform(a);
if let Some(l) = live {
l.transform(a);
}
}
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Compound { children, .. } => {
for c in children.iter_mut() {
Arc::make_mut(c).transform(a, scale_strokes);
}
}
NodeKind::Text(t) => t.transform(a),
NodeKind::Image(im) => im.xf = a * im.xf,
NodeKind::SymbolInstance { xf, .. } => *xf = a * *xf,
}
}
/// Visit this node and all descendants depth first (paint order).
pub fn walk<'a>(&'a self, f: &mut impl FnMut(&'a Node)) {
f(self);
if let Some(ch) = self.children() {
for c in ch {
c.walk(f);
}
}
}
/// Count of nodes in this subtree.
pub fn count(&self) -> usize {
let mut n = 0;
self.walk(&mut |_| n += 1);
n
}
}
fn transform_paints(ap: &mut Appearance, a: Affine) {
use crate::appearance::AppearanceItem;
use drawcraft_color::Paint;
for it in &mut ap.items {
let p = match it {
AppearanceItem::Fill(f) => &mut f.paint,
AppearanceItem::Stroke(s) => &mut s.paint,
};
if let Paint::Gradient(g) = p
&& let Some(geom) = &mut g.geom
{
geom.transform(a);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rect_live_shape_roundtrip() {
let l = LiveShape::Rectangle { w: 10.0, h: 20.0, radii: [0.0; 4], xf: Affine::translate((5.0, 5.0)) };
assert_eq!(l.to_path().bounds(), Some(Rect::new(5.0, 5.0, 15.0, 25.0)));
assert_eq!(l.label(), "Rectangle");
}
#[test]
fn transform_group_recurses() {
let p = Node::path(NodeId(2), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art());
let mut g = Node::group(NodeId(1), vec![Arc::new(p)]);
g.transform(Affine::scale(2.0), true);
assert_eq!(g.geometric_bounds(), Some(Rect::new(0.0, 0.0, 20.0, 20.0)));
let child = &g.children().unwrap()[0];
assert_eq!(child.appearance.stroke_width(), 2.0);
}
#[test]
fn display_names() {
let p = Node::path(NodeId(2), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art());
assert_eq!(p.display_name(), "<Path>");
let l = Node::layer(NodeId(1), "Layer 1", LayerColor::Preset(0));
assert_eq!(l.display_name(), "Layer 1");
}
#[test]
fn visual_bounds_include_stroke() {
let mut p = Node::path(NodeId(2), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art());
p.appearance.stroke_mut().unwrap().join = crate::appearance::LineJoin::Round;
assert_eq!(p.visual_bounds(), Some(Rect::new(-0.5, -0.5, 10.5, 10.5)));
}
}
+96
View File
@@ -0,0 +1,96 @@
//! Selection state (objects and, for direct selection, individual anchors).
use std::collections::{BTreeMap, BTreeSet};
use serde::{Deserialize, Serialize};
use crate::{Document, NodeId};
/// (subpath index, anchor index) inside a path.
pub type AnchorRef = (usize, usize);
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Selection {
/// Selected objects in selection order.
pub objects: Vec<NodeId>,
/// Direct-selected anchors per path. A path in `objects` with no entry here is fully selected.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub anchors: BTreeMap<NodeId, BTreeSet<AnchorRef>>,
/// Key object for Align.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub key: Option<NodeId>,
}
impl Selection {
pub fn is_empty(&self) -> bool {
self.objects.is_empty()
}
pub fn len(&self) -> usize {
self.objects.len()
}
pub fn contains(&self, id: NodeId) -> bool {
self.objects.contains(&id)
}
pub fn clear(&mut self) {
self.objects.clear();
self.anchors.clear();
self.key = None;
}
pub fn set(&mut self, ids: impl IntoIterator<Item = NodeId>) {
self.clear();
for id in ids {
self.add(id);
}
}
pub fn add(&mut self, id: NodeId) {
if !self.objects.contains(&id) {
self.objects.push(id);
}
}
pub fn remove(&mut self, id: NodeId) {
self.objects.retain(|x| *x != id);
self.anchors.remove(&id);
if self.key == Some(id) {
self.key = None;
}
}
pub fn toggle(&mut self, id: NodeId) {
if self.contains(id) { self.remove(id) } else { self.add(id) }
}
/// Are some (not all) anchors of `id` direct-selected?
pub fn partial(&self, id: NodeId) -> Option<&BTreeSet<AnchorRef>> {
self.anchors.get(&id)
}
/// Drop ids that no longer exist or are no longer editable.
pub fn prune(&mut self, doc: &Document) {
self.objects.retain(|id| doc.node(*id).is_some());
self.anchors.retain(|id, _| doc.node(*id).is_some());
if self.key.is_some_and(|k| doc.node(k).is_none()) {
self.key = None;
}
}
/// Top-level ordering: selected ids sorted by paint order (bottom first).
pub fn in_paint_order(&self, doc: &Document) -> Vec<NodeId> {
let mut v: Vec<(Vec<usize>, NodeId)> = self.objects.iter().filter_map(|id| doc.index_path(*id).map(|p| (p, *id))).collect();
v.sort();
v.into_iter().map(|(_, id)| id).collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn basic_ops() {
let mut s = Selection::default();
s.add(NodeId(1));
s.add(NodeId(1));
s.toggle(NodeId(2));
assert_eq!(s.objects, vec![NodeId(1), NodeId(2)]);
s.toggle(NodeId(1));
assert_eq!(s.objects, vec![NodeId(2)]);
s.clear();
assert!(s.is_empty());
}
}
+193
View File
@@ -0,0 +1,193 @@
//! Text objects (model only; layout and glyph outlines live in `drawcraft-text`).
use drawcraft_color::{Color, Paint};
use drawcraft_geom::{Affine, PathData, Point, Rect};
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum Justify {
#[default]
Left,
Center,
Right,
JustifyLeft,
JustifyCenter,
JustifyRight,
JustifyAll,
}
/// Character attributes (the Character panel).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct CharStyle {
pub font_family: String,
#[serde(default = "regular")]
pub font_style: String,
/// Size in points.
pub size: f64,
/// Leading in points; None = Auto (120% of size).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub leading: Option<f64>,
/// Tracking in 1/1000 em.
#[serde(default)]
pub tracking: f64,
/// Kerning: None = Auto (metrics), Some(v) = manual in 1/1000 em.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub kerning: Option<f64>,
#[serde(default)]
pub baseline_shift: f64,
#[serde(default = "hundred")]
pub h_scale: f64,
#[serde(default = "hundred")]
pub v_scale: f64,
#[serde(default)]
pub rotation: f64,
pub fill: Paint,
#[serde(default)]
pub stroke: Paint,
#[serde(default)]
pub stroke_width: f64,
#[serde(default)]
pub underline: bool,
#[serde(default)]
pub strikethrough: bool,
#[serde(default)]
pub all_caps: bool,
}
fn regular() -> String {
"Regular".into()
}
fn hundred() -> f64 {
100.0
}
impl Default for CharStyle {
fn default() -> Self {
Self {
font_family: "Source Sans 3".into(),
font_style: "Regular".into(),
size: 12.0,
leading: None,
tracking: 0.0,
kerning: None,
baseline_shift: 0.0,
h_scale: 100.0,
v_scale: 100.0,
rotation: 0.0,
fill: Paint::solid(Color::BLACK),
stroke: Paint::None,
stroke_width: 0.0,
underline: false,
strikethrough: false,
all_caps: false,
}
}
}
impl CharStyle {
pub fn effective_leading(&self) -> f64 {
self.leading.unwrap_or(self.size * 1.2)
}
}
/// Paragraph attributes (the Paragraph panel).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct ParaStyle {
#[serde(default)]
pub justify: Justify,
#[serde(default)]
pub left_indent: f64,
#[serde(default)]
pub right_indent: f64,
#[serde(default)]
pub first_line_indent: f64,
#[serde(default)]
pub space_before: f64,
#[serde(default)]
pub space_after: f64,
#[serde(default)]
pub hyphenate: bool,
}
/// A run of text sharing one character style.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct TextRun {
pub text: String,
pub style: CharStyle,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "lowercase")]
pub enum TextKind {
/// Point type: anchored at the baseline origin of the first line.
Point,
/// Area type flowed inside `frame` (document coordinates, untransformed by `xf`).
Area { frame: PathData },
/// Type on a path, starting at `start` (0..1 of the path length).
OnPath { path: PathData, start: f64 },
}
/// A text object. `runs` split into paragraphs at `\n`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct TextObject {
pub kind: TextKind,
/// Maps text space (origin = first baseline start for point type) to the document.
pub xf: Affine,
pub runs: Vec<TextRun>,
#[serde(default)]
pub para: ParaStyle,
/// Cached layout bounds in text space, filled in by the layout engine (not serialized).
#[serde(skip)]
pub cached_bounds: Option<Rect>,
}
impl TextObject {
pub fn point(origin: Point, text: &str, style: CharStyle) -> Self {
Self {
kind: TextKind::Point,
xf: Affine::translate(origin.to_vec2()),
runs: vec![TextRun { text: text.into(), style }],
para: ParaStyle::default(),
cached_bounds: None,
}
}
pub fn plain_text(&self) -> String {
self.runs.iter().map(|r| r.text.as_str()).collect()
}
pub fn first_style(&self) -> CharStyle {
self.runs.first().map(|r| r.style.clone()).unwrap_or_default()
}
/// Approximate bounds when no layout cache is available (0.55 em average advance).
pub fn estimate_bounds(&self) -> Rect {
let st = self.first_style();
let text = self.plain_text();
let lines: Vec<&str> = text.split('\n').collect();
let w = lines.iter().map(|l| l.chars().count()).max().unwrap_or(0) as f64 * st.size * 0.55;
let lead = st.effective_leading();
Rect::new(0.0, -st.size * 0.8, w.max(1.0), -st.size * 0.8 + lead * lines.len().max(1) as f64)
}
pub fn bounds(&self) -> Option<Rect> {
match &self.kind {
TextKind::Area { frame } => frame.bounds().map(|b| self.xf.transform_rect_bbox(b)),
TextKind::OnPath { path, .. } => path.bounds().map(|b| self.xf.transform_rect_bbox(b)),
TextKind::Point => Some(self.xf.transform_rect_bbox(self.cached_bounds.unwrap_or_else(|| self.estimate_bounds()))),
}
}
pub fn transform(&mut self, a: Affine) {
self.xf = a * self.xf;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn point_text_basics() {
let t = TextObject::point(Point::new(10.0, 20.0), "Hello", CharStyle::default());
assert_eq!(t.plain_text(), "Hello");
let b = t.bounds().unwrap();
assert!(b.x0 >= 10.0 - 1e-9 && b.y1 > 20.0);
assert_eq!(CharStyle::default().effective_leading(), 14.399999999999999);
}
}
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "drawcraft-effects"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "DrawCraft live effects evaluated to geometry"
[lints]
workspace = true
[dependencies]
drawcraft-geom = { workspace = true }
drawcraft-doc = { workspace = true }
drawcraft-pathops = { workspace = true }
kurbo = { workspace = true }
serde_json = { workspace = true }
+2
View File
@@ -0,0 +1,2 @@
//! DrawCraft live effects evaluated to geometry
#![forbid(unsafe_code)]
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "drawcraft-engine"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "DrawCraft engine: sessions, history, selection, the command registry"
[lints]
workspace = true
[dependencies]
drawcraft-geom = { workspace = true }
drawcraft-color = { workspace = true }
drawcraft-doc = { workspace = true }
drawcraft-tools = { workspace = true }
drawcraft-text = { workspace = true }
drawcraft-render = { workspace = true }
drawcraft-pathops = { workspace = true }
drawcraft-svg = { workspace = true }
drawcraft-format = { workspace = true }
kurbo = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
log = { workspace = true }
+236
View File
@@ -0,0 +1,236 @@
//! Creating art: shapes, paths, text.
use drawcraft_doc::{Appearance, CharStyle, LiveShape, Node, NodeKind, TextObject};
use drawcraft_geom::{Affine, Anchor, AnchorKind, FillRule, PathData, Point, Rect, SubPath, shapes};
use serde_json::{Value, json};
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.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!("shape.line", "Line Segment", [], None, "{x1, y1, x2, y2} → {id}", has_doc, line),
cmd!("shape.spiral", "Spiral", [], None, "{cx, cy, radius, decay=80 (%), segments=10, clockwise?} → {id}", has_doc, spiral),
cmd!("shape.arc", "Arc", [], None, "{x1, y1, x2, y2, closed?} → {id}", has_doc, arc),
cmd!("shape.rectangularGrid", "Rectangular Grid", [], None, "{x, y, width, height, rows=5, columns=5} → {id}", has_doc, rect_grid),
cmd!("shape.polarGrid", "Polar Grid", [], None, "{x, y, width, height, concentric=5, radial=5} → {id}", has_doc, polar_grid),
cmd!(
"path.create",
"Create Path",
[],
None,
"{anchors: [{x, y, in?: [x,y], out?: [x,y]}], closed?: bool, d?: SVG path data} → {id}",
has_doc,
path_create
),
cmd!(
"text.create",
"Create Text",
[],
None,
"{x, y, text, size?: pt, font?: family, style?, color?, area?: {width, height}} → {id}",
has_doc,
text_create
),
]
}
/// Insert a new object at the top of the insertion parent with the current paint; select it.
pub(crate) fn add_art(s: &mut Session, label: &str, kind: NodeKind, name: Option<String>) -> Result<Value> {
let appearance = Appearance::basic(s.paint.fill.clone(), s.paint.stroke.clone(), s.paint.stroke_width);
add_node(s, label, kind, appearance, name)
}
pub(crate) fn add_node(s: &mut Session, label: &str, kind: NodeKind, appearance: Appearance, name: Option<String>) -> Result<Value> {
let parent = s.doc()?.insertion_parent();
let id = s.edit(label, |d, sel| {
let id = d.alloc_id();
let mut n = Node::new(id, kind);
n.appearance = appearance;
n.name = name;
d.insert(parent, usize::MAX, n)?;
sel.set([id]);
Ok(id)
})?;
Ok(json!({ "id": id.0 }))
}
fn path_kind(path: PathData, live: Option<LiveShape>) -> NodeKind {
NodeKind::Path { path, rule: FillRule::NonZero, live, clipping: false, guide: false }
}
fn rect_of(p: &Value, cmd: &str) -> Result<Rect> {
let x = f64_req(p, "x", cmd)?;
let y = f64_req(p, "y", cmd)?;
let w = f64_req(p, "width", cmd)?;
let h = f64_req(p, "height", cmd)?;
if !(w.is_finite() && h.is_finite()) {
return Err(bad(cmd, "invalid size"));
}
Ok(Rect::new(x, y, x + w, y + h).abs())
}
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);
let live = LiveShape::Rectangle { w: r.width(), h: r.height(), radii: [radius; 4], xf: Affine::translate(r.origin().to_vec2()) };
let label = if radius > 0.0 { "Rounded Rectangle" } else { "Rectangle" };
add_art(s, label, path_kind(live.to_path(), Some(live)), None)
}
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()) };
add_art(s, "Ellipse", path_kind(live.to_path(), Some(live)), None)
}
fn polygon(s: &mut Session, p: &Value) -> Result<Value> {
let c = Point::new(f64_req(p, "cx", "shape.polygon")?, f64_req(p, "cy", "shape.polygon")?);
let r = f64_req(p, "radius", "shape.polygon")?.abs();
let sides = p.get("sides").and_then(Value::as_u64).unwrap_or(6).clamp(3, 1000) as u32;
let rot = f64_or(p, "rotation", 0.0);
let live = LiveShape::Polygon { radius: r, sides, xf: Affine::translate(c.to_vec2()) * Affine::rotate(rot.to_radians()) };
add_art(s, "Polygon", path_kind(live.to_path(), Some(live)), None)
}
fn star(s: &mut Session, p: &Value) -> Result<Value> {
let c = Point::new(f64_req(p, "cx", "shape.star")?, f64_req(p, "cy", "shape.star")?);
let r1 = f64_req(p, "radius1", "shape.star")?.abs();
let r2 = f64_or(p, "radius2", r1 / 2.0).abs();
let n = p.get("points").and_then(Value::as_u64).unwrap_or(5).clamp(2, 1000) as u32;
let path = shapes::star(c, r1, r2, n, f64_or(p, "rotation", 0.0));
add_art(s, "Star", path_kind(path, None), None)
}
fn line(s: &mut Session, p: &Value) -> Result<Value> {
let a = Point::new(f64_req(p, "x1", "shape.line")?, f64_req(p, "y1", "shape.line")?);
let b = Point::new(f64_req(p, "x2", "shape.line")?, f64_req(p, "y2", "shape.line")?);
let live = LiveShape::Line { a, b };
// Lines are stroked, never filled (Illustrator ignores the fill for new open lines).
let appearance = Appearance::basic(
drawcraft_color::Paint::None,
if s.paint.stroke.is_none() { drawcraft_color::Paint::solid(drawcraft_color::Color::BLACK) } else { s.paint.stroke.clone() },
s.paint.stroke_width.max(0.1),
);
add_node(s, "Line", path_kind(live.to_path(), Some(live)), appearance, None)
}
fn spiral(s: &mut Session, p: &Value) -> Result<Value> {
let c = Point::new(f64_req(p, "cx", "shape.spiral")?, f64_req(p, "cy", "shape.spiral")?);
let path = shapes::spiral(
c,
f64_req(p, "radius", "shape.spiral")?,
f64_or(p, "decay", 80.0),
p.get("segments").and_then(Value::as_u64).unwrap_or(10).clamp(2, 1000) as u32,
bool_or(p, "clockwise", true),
);
add_art(s, "Spiral", path_kind(path, None), None)
}
fn arc(s: &mut Session, p: &Value) -> Result<Value> {
let a = Point::new(f64_req(p, "x1", "shape.arc")?, f64_req(p, "y1", "shape.arc")?);
let b = Point::new(f64_req(p, "x2", "shape.arc")?, f64_req(p, "y2", "shape.arc")?);
add_art(s, "Arc", path_kind(shapes::arc(a, b, 0.0, bool_or(p, "closed", false)), None), None)
}
fn grid_group(s: &mut Session, label: &str, paths: Vec<PathData>) -> Result<Value> {
let parent = s.doc()?.insertion_parent();
let ap = Appearance::basic(drawcraft_color::Paint::None, s.paint.stroke.clone(), s.paint.stroke_width);
let id = s.edit(label, |d, sel| {
let children = paths
.into_iter()
.map(|pd| {
let id = d.alloc_id();
std::sync::Arc::new(Node::path(id, pd, ap.clone()))
})
.collect();
let gid = d.alloc_id();
d.insert(parent, usize::MAX, Node::group(gid, children))?;
sel.set([gid]);
Ok(gid)
})?;
Ok(json!({ "id": id.0 }))
}
fn rect_grid(s: &mut Session, p: &Value) -> Result<Value> {
let r = rect_of(p, "shape.rectangularGrid")?;
let rows = p.get("rows").and_then(Value::as_u64).unwrap_or(5).min(999) as u32;
let cols = p.get("columns").and_then(Value::as_u64).unwrap_or(5).min(999) as u32;
grid_group(s, "Rectangular Grid", shapes::rectangular_grid(r, rows, cols, true))
}
fn polar_grid(s: &mut Session, p: &Value) -> Result<Value> {
let r = rect_of(p, "shape.polarGrid")?;
let c = p.get("concentric").and_then(Value::as_u64).unwrap_or(5).min(999) as u32;
let rad = p.get("radial").and_then(Value::as_u64).unwrap_or(5).min(999) as u32;
grid_group(s, "Polar Grid", shapes::polar_grid(r, c, rad))
}
pub(crate) fn anchor_from_json(v: &Value) -> Option<Anchor> {
let p = Point::new(v.get("x")?.as_f64()?, v.get("y")?.as_f64()?);
let h_in = point_param(v, "in").unwrap_or(p);
let h_out = point_param(v, "out").unwrap_or(p);
let mut a = Anchor::with_handles(p, h_in, h_out);
if v.get("smooth").and_then(Value::as_bool) == Some(true) {
a.kind = AnchorKind::Smooth;
}
Some(a)
}
fn path_create(s: &mut Session, p: &Value) -> Result<Value> {
let path = if let Some(d) = str_param(p, "d") {
let bp = drawcraft_geom::BezPath::from_svg(d).map_err(|e| bad("path.create", format!("bad path data: {e}")))?;
PathData::from_bezpath(&bp)
} else {
let anchors: Vec<Anchor> = p
.get("anchors")
.and_then(Value::as_array)
.ok_or_else(|| bad("path.create", "missing anchors"))?
.iter()
.filter_map(anchor_from_json)
.collect();
if anchors.is_empty() {
return Err(bad("path.create", "need at least one anchor"));
}
PathData::single(SubPath::new(anchors, bool_or(p, "closed", false)))
};
let mut ap = Appearance::basic(s.paint.fill.clone(), s.paint.stroke.clone(), s.paint.stroke_width);
// Open paths drawn with a stroke of None would be invisible: mimic Illustrator and keep what the user set.
if !path.is_closed() && ap.stroke_paint().is_none() && ap.fill_paint().is_none() {
ap.set_stroke(drawcraft_color::Paint::solid(drawcraft_color::Color::BLACK));
}
add_node(s, "Pen", path_kind(path, None), ap, None)
}
fn text_create(s: &mut Session, p: &Value) -> Result<Value> {
let x = f64_req(p, "x", "text.create")?;
let y = f64_req(p, "y", "text.create")?;
let text = str_param(p, "text").unwrap_or("");
let mut style = CharStyle::default();
if let Some(sz) = p.get("size").and_then(Value::as_f64) {
style.size = sz.clamp(0.1, 1296.0);
}
if let Some(f) = str_param(p, "font") {
style.font_family = f.to_string();
}
if let Some(f) = str_param(p, "style") {
style.font_style = f.to_string();
}
style.fill = match p.get("color").and_then(color_value) {
Some(c) => drawcraft_color::Paint::solid(c),
None if !s.paint.fill.is_none() && s.paint.fill != drawcraft_color::Paint::solid(drawcraft_color::Color::WHITE) => s.paint.fill.clone(),
None => drawcraft_color::Paint::solid(drawcraft_color::Color::BLACK),
};
let mut t = TextObject::point(Point::new(x, y), text, style);
if let Some(a) = p.get("area") {
let w = f64_or(a, "width", 200.0);
let h = f64_or(a, "height", 100.0);
t.kind = drawcraft_doc::TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, w, h)) };
}
let lay = drawcraft_text::layout(drawcraft_text::FontDb::global(), &t);
t.cached_bounds = Some(lay.bounds);
add_node(s, "Type", NodeKind::Text(Box::new(t)), Appearance::default(), None)
}
+283
View File
@@ -0,0 +1,283 @@
//! File (document-level) and Edit commands, plus document queries.
use std::sync::Arc;
use drawcraft_doc::{Document, Node, NodeId, Unit};
use drawcraft_geom::{Affine, Vec2};
use serde_json::{Value, json};
use super::*;
use crate::{EngineError, inspect};
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"file.new",
"New…",
["File"],
Some("Cmd+N"),
"{width?: pt=612, height?: pt=792, units?: \"Points\"|\"Inches\"|\"Millimeters\"|\"Pixels\"…, title?, artboards?: n, colorMode?: \"rgb\"|\"cmyk\"}",
always,
file_new
),
cmd!("file.close", "Close", ["File"], Some("Cmd+W"), "{index?}", has_doc, file_close),
cmd!("document.activate", "Activate Document", [], None, "{index}", always, doc_activate),
cmd!(query "document.inspect", "Inspect Document", [], None, "{} → layer tree, artboards, selection, history", has_doc, |s, _| Ok(inspect::document(s))),
cmd!(query "document.node", "Inspect Object", [], None, "{id} → full object JSON", has_doc, doc_node),
cmd!(query "document.json", "Document JSON", [], None, "{} → complete document model", has_doc, |s, _| Ok(serde_json::to_value(&*s.doc()?.doc).unwrap_or(Value::Null))),
cmd!(
"document.setUnits",
"Units",
["File", "Document Setup"],
None,
"{units: \"Points\"|\"Picas\"|\"Inches\"|\"Millimeters\"|\"Centimeters\"|\"Pixels\"}",
has_doc,
set_units
),
cmd!("edit.undo", "Undo", ["Edit"], Some("Cmd+Z"), "{}", can_undo, undo),
cmd!("edit.redo", "Redo", ["Edit"], Some("Cmd+Shift+Z"), "{}", can_redo, redo),
cmd!("edit.cut", "Cut", ["Edit"], Some("Cmd+X"), "{}", has_selection, cut),
cmd!("edit.copy", "Copy", ["Edit"], Some("Cmd+C"), "{}", has_selection, copy),
cmd!("edit.paste", "Paste", ["Edit"], Some("Cmd+V"), "{dx?, dy?}", has_clipboard, |s, p| paste(s, p, PasteMode::Offset)),
cmd!("edit.pasteInFront", "Paste in Front", ["Edit"], Some("Cmd+F"), "{}", has_clipboard, |s, p| paste(s, p, PasteMode::Front)),
cmd!("edit.pasteInBack", "Paste in Back", ["Edit"], Some("Cmd+B"), "{}", has_clipboard, |s, p| paste(s, p, PasteMode::Back)),
cmd!("edit.pasteInPlace", "Paste in Place", ["Edit"], Some("Cmd+Shift+V"), "{}", has_clipboard, |s, p| paste(s, p, PasteMode::InPlace)),
cmd!("edit.pasteOnAllArtboards", "Paste on All Artboards", ["Edit"], Some("Cmd+Alt+Shift+V"), "{}", has_clipboard, |s, p| paste(
s,
p,
PasteMode::AllArtboards
)),
cmd!("edit.clear", "Clear", ["Edit"], Some("Delete"), "{ids?}", has_selection, clear),
cmd!("edit.duplicate", "Duplicate", [], None, "{dx?, dy?} duplicate the selection in place (offset optional)", has_selection, duplicate),
]
}
fn file_new(s: &mut Session, p: &Value) -> Result<Value> {
let w = f64_or(p, "width", 612.0);
let h = f64_or(p, "height", 792.0);
if !(w > 0.0 && h > 0.0) || w > 16383.0 * 10.0 || h > 16383.0 * 10.0 {
return Err(bad("file.new", "width/height must be positive and within the canvas"));
}
let mut d = Document::new(w, h);
d.title = str_param(p, "title").map(str::to_string).unwrap_or_else(|| s.next_untitled());
if let Some(u) = str_param(p, "units") {
d.units = parse_unit(u).ok_or_else(|| bad("file.new", format!("unknown units `{u}`")))?;
}
if str_param(p, "colorMode").is_some_and(|m| m.eq_ignore_ascii_case("cmyk")) {
d.color_mode = drawcraft_doc::ColorMode::Cmyk;
}
let n = p.get("artboards").and_then(Value::as_u64).unwrap_or(1).clamp(1, 1000) as usize;
for i in 1..n {
let r = drawcraft_geom::Rect::new(0.0, 0.0, w, h) + Vec2::new(i as f64 * (w + 20.0), 0.0);
d.artboards.push(drawcraft_doc::Artboard {
id: (i + 1) as u32,
name: format!("Artboard {}", i + 1),
rect: r,
show_center_mark: false,
show_cross_hairs: false,
});
}
let i = s.add_document(d, None);
Ok(json!({ "index": i }))
}
pub(crate) fn parse_unit(u: &str) -> Option<Unit> {
let n = u.to_ascii_lowercase();
Unit::ALL.into_iter().find(|x| x.label().to_ascii_lowercase() == n || x.suffix() == n)
}
fn file_close(s: &mut Session, p: &Value) -> Result<Value> {
let i = p.get("index").and_then(Value::as_u64).map(|v| v as usize).or(s.active_index()).ok_or(EngineError::NoDocument)?;
if !s.close_document(i) {
return Err(bad("file.close", "no such document"));
}
ok()
}
fn doc_activate(s: &mut Session, p: &Value) -> Result<Value> {
let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("document.activate", "missing index"))? as usize;
if s.set_active(i) { ok() } else { Err(bad("document.activate", "no such document")) }
}
fn doc_node(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id").ok_or_else(|| bad("document.node", "missing id"))?;
let n = s.doc()?.doc.node(id).ok_or(EngineError::NoNode(id))?;
Ok(serde_json::to_value(n).unwrap_or(Value::Null))
}
fn set_units(s: &mut Session, p: &Value) -> Result<Value> {
let u = str_param(p, "units").and_then(parse_unit).ok_or_else(|| bad("document.setUnits", "unknown units"))?;
s.edit("Document Setup", |d, _| {
d.units = u;
Ok(())
})?;
ok()
}
fn undo(s: &mut Session, _: &Value) -> Result<Value> {
s.cancel_interaction()?;
let st = s.doc_mut()?;
let e = st.history.undo.pop().ok_or_else(|| EngineError::Other("nothing to undo".into()))?;
let label = e.label.clone();
st.history.redo.push(crate::HistoryEntry {
label: e.label,
doc: std::mem::replace(&mut st.doc, e.doc),
selection: std::mem::replace(&mut st.selection, e.selection),
});
st.revision += 1;
st.selection.prune(&st.doc);
Ok(json!({ "undone": label }))
}
fn redo(s: &mut Session, _: &Value) -> Result<Value> {
let st = s.doc_mut()?;
let e = st.history.redo.pop().ok_or_else(|| EngineError::Other("nothing to redo".into()))?;
let label = e.label.clone();
st.history.undo.push(crate::HistoryEntry {
label: e.label,
doc: std::mem::replace(&mut st.doc, e.doc),
selection: std::mem::replace(&mut st.selection, e.selection),
});
st.revision += 1;
st.selection.prune(&st.doc);
Ok(json!({ "redone": label }))
}
/// Selected top-level objects in paint order, dropping any whose ancestor is also selected.
pub(crate) fn selected_roots(s: &Session) -> Result<Vec<NodeId>> {
let st = s.doc()?;
let sel = st.selection.in_paint_order(&st.doc);
Ok(sel
.iter()
.copied()
.filter(|id| {
let anc = st.doc.ancestry(*id).unwrap_or_default();
!anc[..anc.len().saturating_sub(1)].iter().any(|a| sel.contains(a))
})
.filter(|id| st.doc.node(*id).is_some_and(|n| !n.is_layer()))
.collect())
}
fn copy(s: &mut Session, _: &Value) -> Result<Value> {
let roots = selected_roots(s)?;
let st = s.doc()?;
let nodes: Vec<Node> = roots.iter().filter_map(|id| st.doc.node(*id).cloned()).collect();
let n = nodes.len();
s.clipboard = nodes;
Ok(json!({ "copied": n }))
}
fn cut(s: &mut Session, p: &Value) -> Result<Value> {
copy(s, p)?;
clear(s, &json!({}))
}
fn clear(s: &mut Session, p: &Value) -> Result<Value> {
// Direct-selected anchors: delete those anchors instead of whole objects.
if ids_param(p, "ids").is_none() && !s.doc()?.selection.anchors.is_empty() {
return super::path::delete_anchors(s, p);
}
let ids = match ids_param(p, "ids") {
Some(v) => v,
None => selected_roots(s)?,
};
s.edit("Clear", |d, sel| {
for id in &ids {
if d.node(*id).is_some_and(|n| n.is_layer()) {
continue;
}
let _ = d.remove(*id);
}
sel.clear();
Ok(())
})?;
ok()
}
enum PasteMode {
Offset,
Front,
Back,
InPlace,
AllArtboards,
}
fn paste(s: &mut Session, p: &Value, mode: PasteMode) -> Result<Value> {
let clip = s.clipboard.clone();
let off = s.prefs.paste_offset;
let st = s.doc()?;
let parent = st.insertion_parent();
// Front/back: relative to the selection (top-most / bottom-most selected object).
let anchor = match mode {
PasteMode::Front | PasteMode::Back => {
let order = st.selection.in_paint_order(&st.doc);
let a = if matches!(mode, PasteMode::Front) { order.last() } else { order.first() };
a.and_then(|id| st.doc.position(*id))
}
_ => None,
};
let artboards: Vec<drawcraft_geom::Rect> = st.doc.artboards.iter().map(|a| a.rect).collect();
let dx = f64_or(p, "dx", off);
let dy = f64_or(p, "dy", off);
let label = match mode {
PasteMode::Front => "Paste in Front",
PasteMode::Back => "Paste in Back",
PasteMode::InPlace => "Paste in Place",
PasteMode::AllArtboards => "Paste on All Artboards",
PasteMode::Offset => "Paste",
};
let ids = s.edit(label, |d, sel| {
let mut new_ids = vec![];
let placements: Vec<Affine> = match mode {
PasteMode::Offset => vec![Affine::translate((dx, dy))],
PasteMode::AllArtboards => {
let src = d.artboards.first().map(|a| a.rect.origin()).unwrap_or_default();
artboards.iter().map(|r| Affine::translate(r.origin() - src)).collect()
}
_ => vec![Affine::IDENTITY],
};
for xf in placements {
for (k, n) in clip.iter().enumerate() {
let mut c = d.reid(n);
if xf != Affine::IDENTITY {
c.transform(xf, false);
}
let (par, idx) = match anchor {
Some((par, i, _)) => (par, if matches!(mode, PasteMode::Front) { i + 1 + k } else { i + k }),
None => (parent, usize::MAX),
};
new_ids.push(d.insert(par, idx, c)?);
}
}
sel.set(new_ids.iter().copied());
Ok(new_ids)
})?;
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))
}
fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
let roots = selected_roots(s)?;
let dx = f64_or(p, "dx", 0.0);
let dy = f64_or(p, "dy", 0.0);
let ids = s.edit("Duplicate", |d, sel| duplicate_in(d, sel, &roots, Affine::translate((dx, dy))))?;
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))
}
/// Duplicate `roots` directly above each original, transform the copies, select them.
pub(crate) fn duplicate_in(d: &mut Document, sel: &mut drawcraft_doc::Selection, roots: &[NodeId], xf: Affine) -> Result<Vec<NodeId>> {
let mut out = vec![];
for id in roots {
let Some((par, idx, _)) = d.position(*id) else { continue };
let Some(n) = d.node(*id).cloned() else { continue };
let mut c = d.reid(&n);
c.transform(xf, true);
out.push(d.insert(par, idx + 1, c)?);
}
sel.set(out.iter().copied());
Ok(out)
}
#[allow(dead_code)]
pub(crate) fn arc_nodes(v: Vec<Node>) -> Vec<Arc<Node>> {
v.into_iter().map(Arc::new).collect()
}
+254
View File
@@ -0,0 +1,254 @@
//! Layers panel, artboards.
use drawcraft_doc::{Artboard, LayerColor, NodeId, NodeKind};
use drawcraft_geom::Rect;
use serde_json::{Value, json};
use super::*;
use crate::EngineError;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("layer.new", "New Layer…", ["Window", "Layers"], Some("Cmd+L"), "{name?} → {id} (above the current layer)", has_doc, new_layer),
cmd!("layer.newSublayer", "New Sublayer…", ["Window", "Layers"], None, "{parent?: id, name?} → {id}", has_doc, new_sublayer),
cmd!("layer.delete", "Delete Layer", ["Window", "Layers"], None, "{id?} (default: current layer)", has_doc, delete_layer),
cmd!("layer.duplicate", "Duplicate Layer", ["Window", "Layers"], None, "{id?}", has_doc, duplicate_layer),
cmd!("layer.setCurrent", "Set Current Layer", [], None, "{id}", has_doc, set_current),
cmd!(
"layer.setProps",
"Layer Options…",
["Window", "Layers"],
None,
"{id, name?, visible?, locked?, template?, printable?, color?: index 0..26}",
has_doc,
set_props
),
cmd!("layer.collectInNew", "Collect in New Layer", ["Window", "Layers"], None, "{}", has_selection, collect),
cmd!("layer.selectAll", "Select All Art on Layer", [], None, "{id}", has_doc, select_all_on),
cmd!("node.move", "Reorder", [], None, "{id, parent?: id (null = top level), index} drag-reorder in the Layers panel", has_doc, move_node),
cmd!(
"artboard.new",
"New Artboard",
["Window", "Artboards"],
None,
"{x?, y?, width?, height?, name?} (default: right of the last)",
has_doc,
artboard_new
),
cmd!("artboard.delete", "Delete Artboard", ["Window", "Artboards"], None, "{index}", has_doc, artboard_delete),
cmd!(
"artboard.setProps",
"Artboard Options…",
["Window", "Artboards"],
None,
"{index, name?, x?, y?, width?, height?}",
has_doc,
artboard_set
),
cmd!("artboard.fitToArt", "Fit to Artwork Bounds", ["Object", "Artboards"], None, "{index?}", has_doc, artboard_fit_art),
cmd!("artboard.fitToSelection", "Fit to Selected Art", ["Object", "Artboards"], None, "{index?}", has_selection, artboard_fit_sel),
]
}
fn new_layer(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "name").map(str::to_string);
let st = s.doc()?;
let cur = st.active_layer.and_then(|l| st.doc.position(l)).filter(|(par, _, _)| par.is_none()).map(|(_, i, _)| i);
let id = s.edit("New Layer", |d, _| {
let id = d.add_layer(name.as_deref());
if let Some(i) = cur {
d.move_node(id, None, i + 1)?;
}
Ok(id)
})?;
s.doc_mut()?.active_layer = Some(id);
Ok(json!({ "id": id.0 }))
}
fn new_sublayer(s: &mut Session, p: &Value) -> Result<Value> {
let parent = id_param(p, "parent").or(s.doc()?.active_layer).ok_or_else(|| bad("layer.newSublayer", "no parent layer"))?;
let name = str_param(p, "name").map(str::to_string);
let id = s.edit("New Sublayer", |d, _| {
let n = d.node(parent).and_then(|n| n.children()).map(|c| c.iter().filter(|c| c.is_layer()).count()).unwrap_or(0);
let id = d.alloc_id();
let color = LayerColor::Preset(((d.layers.len() + n + 1) % 27) as u8);
let nm = name.unwrap_or_else(|| format!("Layer {}", d.node_count()));
d.insert(Some(parent), usize::MAX, drawcraft_doc::Node::layer(id, &nm, color))?;
Ok(id)
})?;
Ok(json!({ "id": id.0 }))
}
fn delete_layer(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id").or(s.doc()?.active_layer).ok_or_else(|| bad("layer.delete", "no layer"))?;
let st = s.doc()?;
if st.doc.layers.len() == 1 && st.doc.layers[0].id == id {
return Err(EngineError::Other("a document needs at least one layer".into()));
}
s.edit("Delete Layer", |d, sel| {
d.remove(id)?;
sel.prune(d);
Ok(())
})?;
let st = s.doc_mut()?;
if st.active_layer == Some(id) || st.active_layer.is_some_and(|l| st.doc.node(l).is_none()) {
st.active_layer = st.doc.default_layer();
}
ok()
}
fn duplicate_layer(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id").or(s.doc()?.active_layer).ok_or_else(|| bad("layer.duplicate", "no layer"))?;
let nid = s.edit("Duplicate Layer", |d, _| {
let (par, idx, _) = d.position(id).ok_or(EngineError::NoNode(id))?;
let n = d.node(id).cloned().ok_or(EngineError::NoNode(id))?;
let mut c = d.reid(&n);
c.name = Some(format!("{} copy", n.display_name()));
Ok(d.insert(par, idx + 1, c)?)
})?;
Ok(json!({ "id": nid.0 }))
}
fn set_current(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id").ok_or_else(|| bad("layer.setCurrent", "missing id"))?;
let st = s.doc_mut()?;
if !st.doc.node(id).is_some_and(|n| n.is_layer()) {
return Err(bad("layer.setCurrent", "not a layer"));
}
st.active_layer = Some(id);
st.revision += 1;
ok()
}
fn set_props(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id").ok_or_else(|| bad("layer.setProps", "missing id"))?;
s.edit("Layer Options", |d, sel| {
let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?;
if let Some(v) = str_param(p, "name") {
n.name = Some(v.to_string());
}
if let Some(v) = p.get("visible").and_then(Value::as_bool) {
n.visible = v;
}
if let Some(v) = p.get("locked").and_then(Value::as_bool) {
n.locked = v;
}
if let NodeKind::Layer { template, printable, color, .. } = &mut n.kind {
if let Some(v) = p.get("template").and_then(Value::as_bool) {
*template = v;
}
if let Some(v) = p.get("printable").and_then(Value::as_bool) {
*printable = v;
}
if let Some(v) = p.get("color").and_then(Value::as_u64) {
*color = LayerColor::Preset((v % 27) as u8);
}
}
// Hidden or locked content can't stay selected.
let hidden: Vec<NodeId> = sel.objects.iter().copied().filter(|o| !d.is_editable(*o)).collect();
for h in hidden {
sel.remove(h);
}
Ok(())
})?;
ok()
}
fn collect(s: &mut Session, _: &Value) -> Result<Value> {
let ids = super::edit::selected_roots(s)?;
let lid = s.edit("Collect in New Layer", |d, _| {
let lid = d.add_layer(None);
for id in &ids {
d.move_node(*id, Some(lid), usize::MAX)?;
}
Ok(lid)
})?;
Ok(json!({ "id": lid.0 }))
}
fn select_all_on(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id").ok_or_else(|| bad("layer.selectAll", "missing id"))?;
let ids: Vec<NodeId> = s
.doc()?
.doc
.node(id)
.and_then(|n| n.children())
.map(|c| c.iter().filter(|n| n.visible && !n.locked).map(|n| n.id).collect())
.unwrap_or_default();
s.select(|_, sel| sel.set(ids.iter().copied()))?;
ok()
}
fn move_node(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id").ok_or_else(|| bad("node.move", "missing id"))?;
let parent = id_param(p, "parent");
let index = p.get("index").and_then(Value::as_u64).unwrap_or(u64::MAX) as usize;
if parent.is_none() && !s.doc()?.doc.node(id).is_some_and(|n| n.is_layer()) {
return Err(bad("node.move", "only layers can be at the top level"));
}
s.edit("Reorder", |d, _| Ok(d.move_node(id, parent, index)?))?;
ok()
}
fn rect_from(p: &Value, default: Rect) -> Rect {
let x = f64_or(p, "x", default.x0);
let y = f64_or(p, "y", default.y0);
let w = f64_or(p, "width", default.width()).max(1.0);
let h = f64_or(p, "height", default.height()).max(1.0);
Rect::new(x, y, x + w, y + h)
}
fn artboard_new(s: &mut Session, p: &Value) -> Result<Value> {
let d0 = &s.doc()?.doc;
let last = d0.artboards.last().map(|a| a.rect).unwrap_or(Rect::new(0.0, 0.0, 612.0, 792.0));
let def = Rect::new(last.x1 + 20.0, last.y0, last.x1 + 20.0 + last.width(), last.y1);
let r = rect_from(p, def);
let idx = s.edit("New Artboard", |d, _| {
let id = d.next_artboard_id();
let name = str_param(p, "name").map(str::to_string).unwrap_or_else(|| format!("Artboard {}", d.artboards.len() + 1));
d.artboards.push(Artboard { id, name, rect: r, show_center_mark: false, show_cross_hairs: false });
Ok(d.artboards.len() - 1)
})?;
Ok(json!({ "index": idx }))
}
fn artboard_delete(s: &mut Session, p: &Value) -> Result<Value> {
let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("artboard.delete", "missing index"))? as usize;
s.edit("Delete Artboard", |d, _| {
if d.artboards.len() <= 1 {
return Err(EngineError::Other("a document needs at least one artboard".into()));
}
if i >= d.artboards.len() {
return Err(EngineError::Other("no such artboard".into()));
}
d.artboards.remove(i);
Ok(())
})?;
ok()
}
fn artboard_set(s: &mut Session, p: &Value) -> Result<Value> {
let i = p.get("index").and_then(Value::as_u64).unwrap_or(0) as usize;
s.edit("Artboard Options", |d, _| {
let a = d.artboards.get_mut(i).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
a.rect = rect_from(p, a.rect);
if let Some(n) = str_param(p, "name") {
a.name = n.to_string();
}
Ok(())
})?;
ok()
}
fn artboard_fit_art(s: &mut Session, p: &Value) -> Result<Value> {
let i = p.get("index").and_then(Value::as_u64).unwrap_or(0) as usize;
let b = s.doc()?.doc.art_bounds().ok_or_else(|| EngineError::Other("no artwork".into()))?;
s.execute("artboard.setProps", &json!({"index": i, "x": b.x0, "y": b.y0, "width": b.width(), "height": b.height()}))
}
fn artboard_fit_sel(s: &mut Session, p: &Value) -> Result<Value> {
let i = p.get("index").and_then(Value::as_u64).unwrap_or(0) as usize;
let st = s.doc()?;
let b = st.doc.bounds_of(&st.selection.objects, true).ok_or_else(|| EngineError::Other("no selection bounds".into()))?;
s.execute("artboard.setProps", &json!({"index": i, "x": b.x0, "y": b.y0, "width": b.width(), "height": b.height()}))
}
+194
View File
@@ -0,0 +1,194 @@
//! The command registry. Ids follow Illustrator's menu structure.
mod create;
mod edit;
mod layer;
mod object;
mod paint;
mod path;
mod select;
use drawcraft_color::Color;
use drawcraft_doc::NodeId;
use drawcraft_geom::{Affine, Point};
use serde::Serialize;
use serde_json::Value;
use crate::{EngineError, Result, Session};
pub type Run = fn(&mut Session, &Value) -> Result<Value>;
pub type Enabled = fn(&Session) -> std::result::Result<(), String>;
/// Metadata + implementation for one command.
pub struct CommandSpec {
pub id: &'static str,
pub label: &'static str,
/// Menu placement, e.g. `["Object", "Arrange"]`. Empty = not in menus.
pub menu: &'static [&'static str],
/// Default shortcut (`Cmd+Shift+]`), mapped per platform by the UI.
pub shortcut: Option<&'static str>,
/// Human/agent-readable parameter description.
pub params: &'static str,
pub enabled: Enabled,
pub run: Run,
/// Record in the journal (false for queries and selection-only helpers).
pub journal: bool,
}
/// Serializable command metadata.
#[derive(Clone, Debug, Serialize)]
pub struct CommandInfo {
pub id: &'static str,
pub label: &'static str,
pub menu: Vec<&'static str>,
pub shortcut: Option<&'static str>,
pub params: &'static str,
pub enabled: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub disabled_reason: Option<String>,
}
impl CommandSpec {
pub fn info(&self, s: &Session) -> CommandInfo {
let e = (self.enabled)(s);
CommandInfo {
id: self.id,
label: self.label,
menu: self.menu.to_vec(),
shortcut: self.shortcut,
params: self.params,
enabled: e.is_ok(),
disabled_reason: e.err(),
}
}
}
// ---------- enablement predicates ----------
pub fn always(_: &Session) -> std::result::Result<(), String> {
Ok(())
}
pub fn has_doc(s: &Session) -> std::result::Result<(), String> {
s.active().map(|_| ()).ok_or_else(|| "no document open".into())
}
pub fn has_selection(s: &Session) -> std::result::Result<(), String> {
has_doc(s)?;
if s.active().unwrap().selection.is_empty() { Err("nothing selected".into()) } else { Ok(()) }
}
pub fn has_multi(s: &Session) -> std::result::Result<(), String> {
has_doc(s)?;
if s.active().unwrap().selection.len() < 2 { Err("select at least two objects".into()) } else { Ok(()) }
}
pub fn can_undo(s: &Session) -> std::result::Result<(), String> {
s.active().filter(|d| !d.history.undo.is_empty()).map(|_| ()).ok_or_else(|| "nothing to undo".into())
}
pub fn can_redo(s: &Session) -> std::result::Result<(), String> {
s.active().filter(|d| !d.history.redo.is_empty()).map(|_| ()).ok_or_else(|| "nothing to redo".into())
}
pub fn has_clipboard(s: &Session) -> std::result::Result<(), String> {
has_doc(s)?;
if s.clipboard.is_empty() { Err("clipboard is empty".into()) } else { Ok(()) }
}
macro_rules! cmd {
($id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => {
$crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: true }
};
(query $id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => {
$crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: false }
};
}
pub(crate) use cmd;
pub fn command_specs() -> &'static [CommandSpec] {
static SPECS: std::sync::OnceLock<Vec<CommandSpec>> = std::sync::OnceLock::new();
SPECS.get_or_init(|| {
let mut v = Vec::new();
v.extend(edit::specs());
v.extend(create::specs());
v.extend(object::specs());
v.extend(path::specs());
v.extend(select::specs());
v.extend(paint::specs());
v.extend(layer::specs());
v
})
}
pub fn find_command(id: &str) -> Option<&'static CommandSpec> {
command_specs().iter().find(|c| c.id == id)
}
// ---------- param helpers ----------
pub(crate) fn bad(cmd: &str, msg: impl Into<String>) -> EngineError {
EngineError::BadParams { cmd: cmd.into(), msg: msg.into() }
}
pub(crate) fn f64_or(p: &Value, key: &str, default: f64) -> f64 {
p.get(key).and_then(Value::as_f64).unwrap_or(default)
}
pub(crate) fn f64_req(p: &Value, key: &str, cmd: &str) -> Result<f64> {
p.get(key).and_then(Value::as_f64).ok_or_else(|| bad(cmd, format!("missing number `{key}`")))
}
pub(crate) fn bool_or(p: &Value, key: &str, default: bool) -> bool {
p.get(key).and_then(Value::as_bool).unwrap_or(default)
}
pub(crate) fn str_param<'a>(p: &'a Value, key: &str) -> Option<&'a str> {
p.get(key).and_then(Value::as_str)
}
pub(crate) fn id_param(p: &Value, key: &str) -> Option<NodeId> {
p.get(key).and_then(Value::as_u64).map(NodeId)
}
pub(crate) fn ids_param(p: &Value, key: &str) -> Option<Vec<NodeId>> {
p.get(key).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(NodeId).collect())
}
pub(crate) fn point_param(p: &Value, key: &str) -> Option<Point> {
let a = p.get(key)?.as_array()?;
Some(Point::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?))
}
/// `[a, b, c, d, e, f]` affine coefficients.
pub fn matrix_param(p: &Value, key: &str) -> Option<Affine> {
let a = p.get(key)?.as_array()?;
if a.len() != 6 {
return None;
}
let mut c = [0.0; 6];
for (i, v) in a.iter().enumerate() {
c[i] = v.as_f64()?;
}
Some(Affine::new(c))
}
/// A colour from `"#rrggbb"`, `[r,g,b]` (0..1), `{"c":..,"m":..,"y":..,"k":..}` or `{"gray":..}`.
pub(crate) fn color_value(v: &Value) -> Option<Color> {
match v {
Value::String(s) => Color::from_hex(s),
Value::Array(a) if a.len() >= 3 => Some(Color::rgb(a[0].as_f64()? as f32, a[1].as_f64()? as f32, a[2].as_f64()? as f32)),
Value::Object(o) => {
if let (Some(c), Some(m), Some(y), Some(k)) = (o.get("c"), o.get("m"), o.get("y"), o.get("k")) {
let f = |v: &Value| v.as_f64().map(|x| if x > 1.0 { x / 100.0 } else { x } as f32);
return Some(Color::cmyk(f(c)?, f(m)?, f(y)?, f(k)?));
}
if let Some(g) = o.get("gray") {
let g = g.as_f64()?;
return Some(Color::gray(if g > 1.0 { g / 100.0 } else { g } as f32));
}
serde_json::from_value(v.clone()).ok()
}
_ => None,
}
}
/// Objects a command targets: explicit `ids` param or the selection.
pub(crate) fn targets(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
if let Some(ids) = ids_param(p, "ids") {
return Ok(ids);
}
if let Some(id) = id_param(p, "id") {
return Ok(vec![id]);
}
Ok(s.doc()?.selection.objects.clone())
}
pub(crate) fn ok() -> Result<Value> {
Ok(Value::Null)
}
+790
View File
@@ -0,0 +1,790 @@
//! Object menu: transforms, arrange, group, lock/hide, compound paths, clipping masks, isolation,
//! align & distribute, object properties.
use std::sync::Arc;
use drawcraft_color::{BlendMode, Paint};
use drawcraft_doc::{Appearance, Document, Node, NodeId, NodeKind};
use drawcraft_geom::{Affine, FillRule, Point, Rect, Vec2};
use serde_json::{Value, json};
use super::edit::{duplicate_in, selected_roots};
use super::*;
use crate::EngineError;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"object.transform",
"Transform",
[],
None,
"{matrix: [a,b,c,d,e,f], copy?: bool, ids?} apply an affine to the selection (or ids)",
has_doc,
transform
),
cmd!("object.move", "Move…", ["Object", "Transform"], Some("Cmd+Shift+M"), "{dx, dy, copy?}", has_selection, move_cmd),
cmd!(
"object.rotate",
"Rotate…",
["Object", "Transform"],
None,
"{angle: deg (counter-clockwise), origin?: [x,y], copy?}",
has_selection,
rotate
),
cmd!("object.scale", "Scale…", ["Object", "Transform"], None, "{sx: %, sy?: %, origin?: [x,y], copy?, strokes?: bool}", has_selection, scale),
cmd!(
"object.reflect",
"Reflect…",
["Object", "Transform"],
None,
"{axis: \"vertical\"|\"horizontal\"|deg, origin?, copy?}",
has_selection,
reflect
),
cmd!(
"object.shear",
"Shear…",
["Object", "Transform"],
None,
"{angle: deg, axis?: \"horizontal\"|\"vertical\", origin?, copy?}",
has_selection,
shear
),
cmd!("object.transformAgain", "Transform Again", ["Object", "Transform"], Some("Cmd+D"), "{}", has_selection, transform_again),
cmd!(
"object.nudge",
"Nudge",
[],
None,
"{dx: -1|0|1, dy: -1|0|1, big?: bool (×10), copy?: bool} arrow-key nudge by the keyboard increment",
has_selection,
nudge
),
cmd!("object.arrange.bringToFront", "Bring to Front", ["Object", "Arrange"], Some("Cmd+Shift+]"), "{}", has_selection, |s, _| arrange(
s,
Arrange::Front
)),
cmd!("object.arrange.bringForward", "Bring Forward", ["Object", "Arrange"], Some("Cmd+]"), "{}", has_selection, |s, _| arrange(
s,
Arrange::Forward
)),
cmd!("object.arrange.sendBackward", "Send Backward", ["Object", "Arrange"], Some("Cmd+["), "{}", has_selection, |s, _| arrange(
s,
Arrange::Backward
)),
cmd!("object.arrange.sendToBack", "Send to Back", ["Object", "Arrange"], Some("Cmd+Shift+["), "{}", has_selection, |s, _| arrange(
s,
Arrange::Back
)),
cmd!("object.arrange.sendToCurrentLayer", "Send to Current Layer", ["Object", "Arrange"], None, "{}", has_selection, send_to_current_layer),
cmd!("object.group", "Group", ["Object"], Some("Cmd+G"), "{} → {id}", has_selection, group),
cmd!("object.ungroup", "Ungroup", ["Object"], Some("Cmd+Shift+G"), "{}", has_selection, ungroup),
cmd!("object.lock", "Selection", ["Object", "Lock"], Some("Cmd+2"), "{}", has_selection, lock),
cmd!("object.unlockAll", "Unlock All", ["Object"], Some("Cmd+Alt+2"), "{}", has_doc, unlock_all),
cmd!("object.hide", "Selection", ["Object", "Hide"], Some("Cmd+3"), "{}", has_selection, hide),
cmd!("object.showAll", "Show All", ["Object"], Some("Cmd+Alt+3"), "{}", has_doc, show_all),
cmd!("object.compoundPath.make", "Make", ["Object", "Compound Path"], Some("Cmd+8"), "{}", has_selection, compound_make),
cmd!("object.compoundPath.release", "Release", ["Object", "Compound Path"], Some("Cmd+Alt+Shift+8"), "{}", has_selection, compound_release),
cmd!("object.clippingMask.make", "Make", ["Object", "Clipping Mask"], Some("Cmd+7"), "{}", has_multi, clip_make),
cmd!("object.clippingMask.release", "Release", ["Object", "Clipping Mask"], Some("Cmd+Alt+7"), "{}", has_selection, clip_release),
cmd!("object.isolate", "Enter Isolation Mode", [], None, "{id}", has_doc, isolate),
cmd!("object.exitIsolation", "Exit Isolation Mode", [], None, "{}", has_doc, exit_isolation),
cmd!(
"object.setProps",
"Object Properties",
[],
None,
"{ids?|id?, name?, visible?, locked?, opacity?: 0..1 or %, blend?: \"Multiply\"…, isolate?, knockout?}",
has_doc,
set_props
),
cmd!(
"object.align",
"Align",
["Window", "Align"],
None,
"{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\", to?: \"selection\"|\"artboard\"|\"key\"}",
has_selection,
align
),
cmd!(
"object.distribute",
"Distribute",
["Window", "Align"],
None,
"{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\"}",
has_multi,
distribute
),
cmd!(
"object.distributeSpacing",
"Distribute Spacing",
["Window", "Align"],
None,
"{axis: \"horizontal\"|\"vertical\", spacing?: pt}",
has_multi,
distribute_spacing
),
cmd!(
"object.setBounds",
"Set Bounds",
[],
None,
"{x?, y?, width?, height?, reference?: 0..8 (9-point grid), proportional?} (Transform panel)",
has_selection,
set_bounds
),
cmd!("object.expandShape", "Expand Shape", ["Object", "Shape"], None, "{} convert live shapes to plain paths", has_selection, expand_shape),
cmd!("object.setLiveShape", "Live Shape Properties", [], None, "{id?, radius?: pt (all corners), sides?: n}", has_selection, set_live_shape),
]
}
fn origin_of(s: &Session, p: &Value, ids: &[NodeId]) -> Result<Point> {
if let Some(o) = point_param(p, "origin") {
return Ok(o);
}
s.doc()?.doc.bounds_of(ids, false).map(|b| b.center()).ok_or_else(|| EngineError::Other("selection has no bounds".into()))
}
/// Apply `xf` to `ids` (copy = duplicate first). Records Transform Again.
pub(crate) fn apply_transform(s: &mut Session, label: &str, ids: Vec<NodeId>, xf: Affine, copy: bool) -> Result<Value> {
let scale_strokes = s.prefs.scale_strokes;
let ids = s.edit(label, |d, sel| {
let targets = if copy { duplicate_in(d, sel, &ids, Affine::IDENTITY)? } else { ids.clone() };
for id in &targets {
if let Some(n) = d.node_mut(*id) {
n.transform(xf, scale_strokes);
}
}
Ok(targets)
})?;
let st = s.doc_mut()?;
if st.interaction.is_none() {
st.last_transform = Some((xf, copy));
}
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))
}
fn transform(s: &mut Session, p: &Value) -> Result<Value> {
let m = matrix_param(p, "matrix").ok_or_else(|| bad("object.transform", "missing matrix [a,b,c,d,e,f]"))?;
let ids = match ids_param(p, "ids") {
Some(v) => v,
None => selected_roots(s)?,
};
apply_transform(s, "Transform", ids, m, bool_or(p, "copy", false))
}
fn move_cmd(s: &mut Session, p: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
apply_transform(s, "Move", ids, Affine::translate((f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0))), bool_or(p, "copy", false))
}
fn nudge(s: &mut Session, p: &Value) -> Result<Value> {
let k = s.prefs.keyboard_increment * if bool_or(p, "big", false) { 10.0 } else { 1.0 };
let dx = f64_or(p, "dx", 0.0) * k;
let dy = f64_or(p, "dy", 0.0) * k;
if !s.doc()?.selection.anchors.is_empty() {
return super::path::move_anchors(s, &json!({ "dx": dx, "dy": dy }));
}
let ids = selected_roots(s)?;
apply_transform(s, "Move", ids, Affine::translate((dx, dy)), bool_or(p, "copy", false))
}
fn about(o: Point, a: Affine) -> Affine {
Affine::translate(o.to_vec2()) * a * Affine::translate(-o.to_vec2())
}
fn rotate(s: &mut Session, p: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
let o = origin_of(s, p, &ids)?;
// Illustrator angles are counter-clockwise; y is down, so negate.
let a = about(o, Affine::rotate(-f64_or(p, "angle", 0.0).to_radians()));
apply_transform(s, "Rotate", ids, a, bool_or(p, "copy", false))
}
fn scale(s: &mut Session, p: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
let o = origin_of(s, p, &ids)?;
let sx = f64_req(p, "sx", "object.scale")? / 100.0;
let sy = f64_or(p, "sy", sx * 100.0) / 100.0;
if sx == 0.0 || sy == 0.0 {
return Err(bad("object.scale", "scale must be non-zero"));
}
let prev = s.prefs.scale_strokes;
if let Some(b) = p.get("strokes").and_then(Value::as_bool) {
s.prefs.scale_strokes = b;
}
let r = apply_transform(s, "Scale", ids, about(o, Affine::scale_non_uniform(sx, sy)), bool_or(p, "copy", false));
s.prefs.scale_strokes = prev;
r
}
fn reflect(s: &mut Session, p: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
let o = origin_of(s, p, &ids)?;
let m = match p.get("axis") {
Some(Value::String(a)) if a == "horizontal" => Affine::scale_non_uniform(1.0, -1.0),
Some(Value::Number(n)) => {
let t = -n.as_f64().unwrap_or(90.0).to_radians();
Affine::rotate(t) * Affine::scale_non_uniform(1.0, -1.0) * Affine::rotate(-t)
}
_ => Affine::scale_non_uniform(-1.0, 1.0),
};
apply_transform(s, "Reflect", ids, about(o, m), bool_or(p, "copy", false))
}
fn shear(s: &mut Session, p: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
let o = origin_of(s, p, &ids)?;
let t = f64_or(p, "angle", 0.0).clamp(-89.0, 89.0).to_radians().tan();
let m =
if str_param(p, "axis") == Some("vertical") { Affine::new([1.0, t, 0.0, 1.0, 0.0, 0.0]) } else { Affine::new([1.0, 0.0, -t, 1.0, 0.0, 0.0]) };
apply_transform(s, "Shear", ids, about(o, m), bool_or(p, "copy", false))
}
fn transform_again(s: &mut Session, _: &Value) -> Result<Value> {
let (m, copy) = s.doc()?.last_transform.ok_or_else(|| EngineError::Other("no previous transform".into()))?;
let ids = selected_roots(s)?;
apply_transform(s, "Transform Again", ids, m, copy)
}
enum Arrange {
Front,
Forward,
Backward,
Back,
}
fn arrange(s: &mut Session, how: Arrange) -> Result<Value> {
let mut ids = selected_roots(s)?;
if matches!(how, Arrange::Forward | Arrange::Front) {
ids.reverse();
}
let label = match how {
Arrange::Front => "Bring to Front",
Arrange::Forward => "Bring Forward",
Arrange::Backward => "Send Backward",
Arrange::Back => "Send to Back",
};
s.edit(label, |d, _| {
let n = ids.len();
for (k, id) in ids.iter().enumerate() {
let Some((par, idx, len)) = d.position(*id) else { continue };
let to = match how {
Arrange::Front => len - 1 - k,
Arrange::Back => k,
Arrange::Forward => (idx + 1).min(len - 1 - k.min(len - 1)),
Arrange::Backward => idx.saturating_sub(1).max(k.min(n)),
};
if to != idx {
d.move_node(*id, par, to)?;
}
}
Ok(())
})?;
ok()
}
fn send_to_current_layer(s: &mut Session, _: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
let layer = s.doc()?.active_layer.ok_or_else(|| EngineError::Other("no current layer".into()))?;
s.edit("Send to Current Layer", |d, _| {
for id in &ids {
d.move_node(*id, Some(layer), usize::MAX)?;
}
Ok(())
})?;
ok()
}
fn group(s: &mut Session, _: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
let Some(top) = ids.last().copied() else { return Err(EngineError::Other("nothing selected".into())) };
let gid = s.edit("Group", |d, sel| {
let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?;
let gid = d.alloc_id();
d.insert(par, idx + 1, Node::group(gid, vec![]))?;
for id in &ids {
d.move_node(*id, Some(gid), usize::MAX)?;
}
sel.set([gid]);
Ok(gid)
})?;
Ok(json!({ "id": gid.0 }))
}
fn ungroup(s: &mut Session, _: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
s.edit("Ungroup", |d, sel| {
let mut new_sel = vec![];
for id in &ids {
let Some(n) = d.node(*id) else { continue };
let NodeKind::Group { children, clip } = &n.kind else {
new_sel.push(*id);
continue;
};
let children = children.clone();
let clip = *clip;
let (par, idx, _) = d.position(*id).ok_or(EngineError::NoNode(*id))?;
let opacity = n.opacity;
d.remove(*id)?;
for (k, c) in children.into_iter().enumerate() {
let mut c = (*c).clone();
if clip && k == 0 {
if let NodeKind::Path { clipping, .. } = &mut c.kind {
*clipping = false;
}
c.appearance = Appearance::default();
}
c.opacity *= opacity;
new_sel.push(c.id);
d.insert(par, idx + k, c)?;
}
}
sel.set(new_sel);
Ok(())
})?;
ok()
}
fn lock(s: &mut Session, _: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
s.edit("Lock", |d, sel| {
for id in &ids {
if let Some(n) = d.node_mut(*id) {
n.locked = true;
}
}
sel.clear();
Ok(())
})?;
ok()
}
fn collect_flag(d: &Document, f: impl Fn(&Node) -> bool) -> Vec<NodeId> {
let mut v = vec![];
d.walk(|n| {
if !n.is_layer() && f(n) {
v.push(n.id)
}
});
v
}
fn unlock_all(s: &mut Session, _: &Value) -> Result<Value> {
let ids = collect_flag(&s.doc()?.doc, |n| n.locked);
s.edit("Unlock All", |d, sel| {
for id in &ids {
if let Some(n) = d.node_mut(*id) {
n.locked = false;
}
}
sel.set(ids.iter().copied());
Ok(())
})?;
Ok(json!({ "count": ids.len() }))
}
fn hide(s: &mut Session, _: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
s.edit("Hide", |d, sel| {
for id in &ids {
if let Some(n) = d.node_mut(*id) {
n.visible = false;
}
}
sel.clear();
Ok(())
})?;
ok()
}
fn show_all(s: &mut Session, _: &Value) -> Result<Value> {
let ids = collect_flag(&s.doc()?.doc, |n| !n.visible);
s.edit("Show All", |d, sel| {
for id in &ids {
if let Some(n) = d.node_mut(*id) {
n.visible = true;
}
}
sel.set(ids.iter().copied());
Ok(())
})?;
Ok(json!({ "count": ids.len() }))
}
fn compound_make(s: &mut Session, _: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
let Some(top) = ids.last().copied() else { return Err(EngineError::Other("nothing selected".into())) };
let id = s.edit("Make Compound Path", |d, sel| {
let appearance = d.node(top).map(|n| n.appearance.clone()).unwrap_or_default();
let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?;
let mut paths: Vec<Arc<Node>> = vec![];
for id in &ids {
let n = d.node(*id).cloned().ok_or(EngineError::NoNode(*id))?;
match &n.kind {
NodeKind::Path { .. } => paths.push(Arc::new(n)),
NodeKind::Compound { children, .. } => paths.extend(children.iter().cloned()),
NodeKind::Group { .. } => {
n.walk(&mut |c| {
if matches!(c.kind, NodeKind::Path { .. }) {
paths.push(Arc::new(c.clone()))
}
});
}
_ => return Err(EngineError::Other("compound paths can only contain paths".into())),
}
}
let cid = d.alloc_id();
let mut c = Node::new(cid, NodeKind::Compound { children: vec![], rule: FillRule::NonZero });
c.appearance = appearance;
d.insert(par, idx + 1, c)?;
for id in &ids {
d.remove(*id)?;
}
let ch = d.node_mut(cid).unwrap().children_mut().unwrap();
for mut p in paths {
let pn = Arc::make_mut(&mut p);
pn.appearance = Appearance::default();
if let NodeKind::Path { live, .. } = &mut pn.kind {
*live = None;
}
ch.push(p);
}
sel.set([cid]);
Ok(cid)
})?;
Ok(json!({ "id": id.0 }))
}
fn compound_release(s: &mut Session, _: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
s.edit("Release Compound Path", |d, sel| {
let mut out = vec![];
for id in &ids {
let Some(n) = d.node(*id).cloned() else { continue };
let NodeKind::Compound { children, .. } = &n.kind else { continue };
let (par, idx, _) = d.position(*id).ok_or(EngineError::NoNode(*id))?;
d.remove(*id)?;
for (k, c) in children.iter().enumerate() {
let mut c = (**c).clone();
c.appearance = n.appearance.clone();
out.push(c.id);
d.insert(par, idx + k, c)?;
}
}
sel.set(out);
Ok(())
})?;
ok()
}
fn clip_make(s: &mut Session, _: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
let top = *ids.last().unwrap();
let gid = s.edit("Make Clipping Mask", |d, sel| {
if !matches!(d.node(top).map(|n| &n.kind), Some(NodeKind::Path { .. })) {
return Err(EngineError::Other("the top object must be a path to use as a clipping mask".into()));
}
let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?;
let gid = d.alloc_id();
d.insert(par, idx + 1, Node::new(gid, NodeKind::Group { children: vec![], clip: true }))?;
d.move_node(top, Some(gid), 0)?;
for id in &ids[..ids.len() - 1] {
d.move_node(*id, Some(gid), usize::MAX)?;
}
if let Some(c) = d.node_mut(top) {
c.appearance = Appearance::basic(Paint::None, Paint::None, 0.0);
if let NodeKind::Path { clipping, .. } = &mut c.kind {
*clipping = true;
}
}
sel.set([gid]);
Ok(gid)
})?;
Ok(json!({ "id": gid.0 }))
}
fn clip_release(s: &mut Session, _: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
s.edit("Release Clipping Mask", |d, _| {
for id in &ids {
let clip_child = match d.node(*id).map(|n| &n.kind) {
Some(NodeKind::Group { children, clip: true }) => children.first().map(|c| c.id),
_ => continue,
};
if let Some(NodeKind::Group { clip, .. }) = d.node_mut(*id).map(|n| &mut n.kind) {
*clip = false;
}
if let Some(c) = clip_child.and_then(|c| d.node_mut(c))
&& let NodeKind::Path { clipping, .. } = &mut c.kind
{
*clipping = false;
}
}
Ok(())
})?;
ok()
}
fn isolate(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id")
.or_else(|| s.active().and_then(|d| d.selection.objects.first().copied()))
.ok_or_else(|| bad("object.isolate", "missing id"))?;
let st = s.doc_mut()?;
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);
st.selection.clear();
st.revision += 1;
ok()
}
fn exit_isolation(s: &mut Session, _: &Value) -> Result<Value> {
let st = s.doc_mut()?;
if let Some(i) = st.isolation.take() {
st.selection.set([i]);
}
st.revision += 1;
ok()
}
fn set_props(s: &mut Session, p: &Value) -> Result<Value> {
let ids = targets(s, p)?;
let opacity = p.get("opacity").and_then(Value::as_f64).map(|o| if o > 1.0 { o / 100.0 } else { o }.clamp(0.0, 1.0) as f32);
let blend = match str_param(p, "blend") {
Some(b) => Some(BlendMode::parse(b).ok_or_else(|| bad("object.setProps", format!("unknown blend mode `{b}`")))?),
None => None,
};
s.edit("Object Properties", |d, _| {
for id in &ids {
let n = d.node_mut(*id).ok_or(EngineError::NoNode(*id))?;
if let Some(v) = str_param(p, "name") {
n.name = if v.is_empty() { None } else { Some(v.to_string()) };
}
if let Some(v) = p.get("visible").and_then(Value::as_bool) {
n.visible = v;
}
if let Some(v) = p.get("locked").and_then(Value::as_bool) {
n.locked = v;
}
if let Some(v) = opacity {
n.opacity = v;
}
if let Some(b) = blend {
n.blend = b;
}
if let Some(v) = p.get("isolate").and_then(Value::as_bool) {
n.isolate = v;
}
if let Some(v) = p.get("knockout").and_then(Value::as_bool) {
n.knockout = v;
}
}
Ok(())
})?;
ok()
}
fn reference_rect(s: &Session, p: &Value, ids: &[NodeId]) -> Result<Rect> {
let st = s.doc()?;
match str_param(p, "to") {
Some("artboard") => {
let b = st.doc.bounds_of(ids, false).unwrap_or_default();
let i = st.doc.artboard_at(b.center()).unwrap_or(0);
Ok(st.doc.artboards.get(i).map(|a| a.rect).unwrap_or(b))
}
Some("key") => {
let k = st.selection.key.ok_or_else(|| EngineError::Other("no key object".into()))?;
st.doc.node(k).and_then(|n| n.geometric_bounds()).ok_or(EngineError::NoNode(k))
}
_ => st.doc.bounds_of(ids, false).ok_or_else(|| EngineError::Other("nothing to align".into())),
}
}
fn align(s: &mut Session, p: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
let r = reference_rect(s, p, &ids)?;
let h = str_param(p, "horizontal");
let v = str_param(p, "vertical");
let key = s.doc()?.selection.key;
let moves: Vec<(NodeId, Vec2)> = {
let d = &s.doc()?.doc;
ids.iter()
.filter(|id| Some(**id) != key || str_param(p, "to") != Some("key"))
.filter_map(|id| {
let b = d.node(*id)?.geometric_bounds()?;
let dx = match h {
Some("left") => r.x0 - b.x0,
Some("center") => r.center().x - b.center().x,
Some("right") => r.x1 - b.x1,
_ => 0.0,
};
let dy = match v {
Some("top") => r.y0 - b.y0,
Some("center") => r.center().y - b.center().y,
Some("bottom") => r.y1 - b.y1,
_ => 0.0,
};
Some((*id, Vec2::new(dx, dy)))
})
.collect()
};
s.edit("Align", |d, _| {
for (id, dv) in &moves {
if let Some(n) = d.node_mut(*id) {
n.transform(Affine::translate(*dv), false);
}
}
Ok(())
})?;
ok()
}
fn distribute(s: &mut Session, p: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
let d0 = &s.doc()?.doc;
let mut items: Vec<(NodeId, Rect)> = ids.iter().filter_map(|id| Some((*id, d0.node(*id)?.geometric_bounds()?))).collect();
let (horiz, key): (bool, fn(&Rect, bool) -> f64) = match (str_param(p, "horizontal"), str_param(p, "vertical")) {
(Some(h), _) => (
true,
match h {
"left" => |r: &Rect, _| r.x0,
"right" => |r: &Rect, _| r.x1,
_ => |r: &Rect, _| r.center().x,
},
),
(_, Some(v)) => (
false,
match v {
"top" => |r: &Rect, _| r.y0,
"bottom" => |r: &Rect, _| r.y1,
_ => |r: &Rect, _| r.center().y,
},
),
_ => return Err(bad("object.distribute", "give horizontal or vertical")),
};
items.sort_by(|a, b| key(&a.1, horiz).total_cmp(&key(&b.1, horiz)));
let n = items.len();
if n < 3 {
return ok();
}
let first = key(&items[0].1, horiz);
let last = key(&items[n - 1].1, horiz);
let moves: Vec<(NodeId, Vec2)> = items
.iter()
.enumerate()
.map(|(i, (id, r))| {
let target = first + (last - first) * i as f64 / (n - 1) as f64;
let delta = target - key(r, horiz);
(*id, if horiz { Vec2::new(delta, 0.0) } else { Vec2::new(0.0, delta) })
})
.collect();
s.edit("Distribute", |d, _| {
for (id, dv) in &moves {
if let Some(n) = d.node_mut(*id) {
n.transform(Affine::translate(*dv), false);
}
}
Ok(())
})?;
ok()
}
fn distribute_spacing(s: &mut Session, p: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
let horiz = str_param(p, "axis") != Some("vertical");
let d0 = &s.doc()?.doc;
let mut items: Vec<(NodeId, Rect)> = ids.iter().filter_map(|id| Some((*id, d0.node(*id)?.geometric_bounds()?))).collect();
items.sort_by(|a, b| if horiz { a.1.x0.total_cmp(&b.1.x0) } else { a.1.y0.total_cmp(&b.1.y0) });
let n = items.len();
let size = |r: &Rect| if horiz { r.width() } else { r.height() };
let start = |r: &Rect| if horiz { r.x0 } else { r.y0 };
let gap = match p.get("spacing").and_then(Value::as_f64) {
Some(g) => g,
None => {
let span = if horiz { items[n - 1].1.x1 - items[0].1.x0 } else { items[n - 1].1.y1 - items[0].1.y0 };
(span - items.iter().map(|i| size(&i.1)).sum::<f64>()) / (n - 1) as f64
}
};
let mut pos = start(&items[0].1);
let mut moves = vec![];
for (id, r) in &items {
let delta = pos - start(r);
moves.push((*id, if horiz { Vec2::new(delta, 0.0) } else { Vec2::new(0.0, delta) }));
pos += size(r) + gap;
}
s.edit("Distribute Spacing", |d, _| {
for (id, dv) in &moves {
if let Some(n) = d.node_mut(*id) {
n.transform(Affine::translate(*dv), false);
}
}
Ok(())
})?;
ok()
}
fn set_bounds(s: &mut Session, p: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
let b = s.doc()?.doc.bounds_of(&ids, false).ok_or_else(|| EngineError::Other("no bounds".into()))?;
let refi = p.get("reference").and_then(Value::as_u64).unwrap_or(4) as usize;
let rp = drawcraft_geom::reference_point(b, refi);
let mut w = f64_or(p, "width", b.width());
let mut h = f64_or(p, "height", b.height());
if bool_or(p, "proportional", false) {
if p.get("width").is_some() && b.width() > 0.0 {
h = b.height() * w / b.width();
} else if p.get("height").is_some() && b.height() > 0.0 {
w = b.width() * h / b.height();
}
}
let sx = if b.width() > 1e-9 { w / b.width() } else { 1.0 };
let sy = if b.height() > 1e-9 { h / b.height() } else { 1.0 };
let scale = about(rp, Affine::scale_non_uniform(sx, sy));
let new_rp = scale * rp;
let tx = f64_or(p, "x", new_rp.x) - new_rp.x;
let ty = f64_or(p, "y", new_rp.y) - new_rp.y;
apply_transform(s, "Transform", ids, Affine::translate((tx, ty)) * scale, false)
}
fn expand_shape(s: &mut Session, _: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
s.edit("Expand Shape", |d, _| {
for id in &ids {
if let Some(NodeKind::Path { live, .. }) = d.node_mut(*id).map(|n| &mut n.kind) {
*live = None;
}
}
Ok(())
})?;
ok()
}
fn set_live_shape(s: &mut Session, p: &Value) -> Result<Value> {
let ids = targets(s, p)?;
s.edit("Live Shape", |d, _| {
for id in &ids {
let Some(NodeKind::Path { path, live: Some(live), .. }) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
match live {
drawcraft_doc::LiveShape::Rectangle { radii, .. } => {
if let Some(r) = p.get("radius").and_then(Value::as_f64) {
*radii = [r.max(0.0); 4];
}
}
drawcraft_doc::LiveShape::Polygon { sides, .. } => {
if let Some(n) = p.get("sides").and_then(Value::as_u64) {
*sides = n.clamp(3, 1000) as u32;
}
}
_ => {}
}
*path = live.to_path();
}
Ok(())
})?;
ok()
}
+494
View File
@@ -0,0 +1,494 @@
//! Fill/stroke, Stroke panel, Appearance, Transparency, Swatches, Graphic Styles.
use drawcraft_color::{BlendMode, Color, Gradient, GradientKind, GradientPaint, Paint};
use drawcraft_doc::{Appearance, AppearanceItem, Arrowhead, Dash, FillLayer, LineCap, LineJoin, NodeKind, StrokeAlign, StrokeLayer};
use serde_json::{Value, json};
use super::*;
use crate::EngineError;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"paint.setFill",
"Fill",
[],
None,
"{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}, none?: true, swatch?: name, gradient?: {kind, stops:[{offset,color}], angle?}, ids?} sets selection fill and the default",
has_doc,
|s, p| set_paint(s, p, true)
),
cmd!("paint.setStroke", "Stroke", [], None, "same as paint.setFill, for the stroke", has_doc, |s, p| set_paint(s, p, false)),
cmd!("paint.swap", "Swap Fill and Stroke", [], Some("Shift+X"), "{}", has_doc, swap),
cmd!("paint.default", "Default Fill and Stroke", [], Some("D"), "{}", has_doc, default_paint),
cmd!("paint.toggleActive", "Toggle Fill/Stroke Focus", [], Some("X"), "{}", always, |s, _| {
s.fill_active = !s.fill_active;
Ok(json!({ "fillActive": s.fill_active }))
}),
cmd!("paint.none", "None", [], Some("/"), "{} set the active proxy (fill or stroke) to None", has_doc, |s, _| {
let f = s.fill_active;
set_paint(s, &json!({"none": true}), f)
}),
cmd!(
"stroke.set",
"Stroke Options",
["Window", "Stroke"],
None,
"{weight?, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null, dashOffset?, alignDashes?, startArrow?, endArrow?: name|null, profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\", ids?}",
has_doc,
stroke_set
),
cmd!("appearance.addFill", "Add New Fill", ["Window", "Appearance"], None, "{}", has_selection, |s, _| add_item(s, true)),
cmd!("appearance.addStroke", "Add New Stroke", ["Window", "Appearance"], None, "{}", has_selection, |s, _| add_item(s, false)),
cmd!("appearance.clear", "Clear Appearance", ["Window", "Appearance"], None, "{}", has_selection, clear_appearance),
cmd!("appearance.reduceToBasic", "Reduce to Basic Appearance", ["Window", "Appearance"], None, "{}", has_selection, reduce_basic),
cmd!(
"appearance.setItem",
"Appearance Item",
[],
None,
"{index, opacity?, blend?, visible?, color?|none?} edit one fill/stroke of the selection's appearance stack",
has_selection,
set_item
),
cmd!("appearance.removeItem", "Remove Item", [], None, "{index}", has_selection, remove_item),
cmd!("appearance.addEffect", "Add Effect", ["Effect"], None, "{effect: id, params?: {…}} append a live effect", has_selection, add_effect),
cmd!(
"transparency.set",
"Transparency",
["Window", "Transparency"],
None,
"{opacity?: 0..100, blend?: name, isolate?, knockout?}",
has_selection,
transparency
),
cmd!("graphicStyle.apply", "Apply Graphic Style", ["Window", "Graphic Styles"], None, "{name}", has_selection, style_apply),
cmd!("graphicStyle.new", "New Graphic Style", ["Window", "Graphic Styles"], None, "{name?} from the selection", has_selection, style_new),
cmd!("swatch.new", "New Swatch", ["Window", "Swatches"], None, "{name?, color?, global?} (default: current fill)", has_doc, swatch_new),
cmd!("swatch.delete", "Delete Swatch", ["Window", "Swatches"], None, "{name}", has_doc, swatch_delete),
]
}
/// Parse a paint from params (color / none / swatch / gradient). None = no paint keys given.
pub(crate) fn paint_from(s: &Session, p: &Value) -> Result<Option<Paint>> {
if bool_or(p, "none", false) {
return Ok(Some(Paint::None));
}
if let Some(name) = str_param(p, "swatch") {
let st = s.doc()?;
let sw = st.doc.swatch(name).ok_or_else(|| EngineError::Other(format!("no swatch `{name}`")))?;
let mut paint = sw.paint.clone();
if sw.global
&& let Paint::Solid { swatch, .. } = &mut paint
{
*swatch = Some(name.to_string());
}
return Ok(Some(paint));
}
if let Some(g) = p.get("gradient") {
let kind = match str_param(g, "kind") {
Some("radial") => GradientKind::Radial,
_ => GradientKind::Linear,
};
let mut grad = Gradient { kind, ..Default::default() };
if let Some(stops) = g.get("stops").and_then(Value::as_array) {
grad.stops = stops
.iter()
.filter_map(|st| {
Some(drawcraft_color::GradientStop {
offset: st.get("offset")?.as_f64()? as f32,
color: color_value(st.get("color")?)?,
opacity: st.get("opacity").and_then(Value::as_f64).unwrap_or(1.0) as f32,
midpoint: 0.5,
})
})
.collect();
grad.sort();
}
let mut gp = GradientPaint::new(grad);
gp.angle = f64_or(g, "angle", 0.0);
return Ok(Some(Paint::Gradient(Box::new(gp))));
}
if let Some(c) = p.get("color") {
let c = color_value(c).ok_or_else(|| bad("paint", format!("bad color {c}")))?;
return Ok(Some(Paint::solid(c)));
}
Ok(None)
}
/// Leaf objects whose appearance should change for `ids` (groups apply to children).
fn paint_targets(s: &Session, p: &Value) -> Result<Vec<drawcraft_doc::NodeId>> {
let ids = targets(s, p)?;
let d = &s.doc()?.doc;
let mut out = vec![];
for id in ids {
let Some(n) = d.node(id) else { continue };
match &n.kind {
NodeKind::Group { .. } | NodeKind::Layer { .. } => n.walk(&mut |c| {
if !c.is_container() || matches!(c.kind, NodeKind::Compound { .. }) {
out.push(c.id)
}
}),
_ => out.push(id),
}
}
// Don't recurse into compound children (the compound owns the appearance).
let comp: Vec<_> = out.iter().filter(|id| matches!(d.node(**id).map(|n| &n.kind), Some(NodeKind::Compound { .. }))).copied().collect();
out.retain(|id| !comp.iter().any(|c| d.parent_of(*id) == Some(*c)));
Ok(out)
}
fn set_paint(s: &mut Session, p: &Value, fill: bool) -> Result<Value> {
let paint = paint_from(s, p)?.ok_or_else(|| bad("paint.setFill", "give color, none, swatch or gradient"))?;
if fill {
s.paint.fill = paint.clone();
} else {
s.paint.stroke = paint.clone();
}
s.fill_active = fill;
let ids = paint_targets(s, p)?;
if ids.is_empty() {
return ok();
}
s.edit(if fill { "Fill Color" } else { "Stroke Color" }, |d, _| {
for id in &ids {
let Some(n) = d.node_mut(*id) else { continue };
if let NodeKind::Text(t) = &mut n.kind {
for r in &mut t.runs {
if fill {
r.style.fill = paint.clone();
} else {
r.style.stroke = paint.clone();
if r.style.stroke_width == 0.0 {
r.style.stroke_width = 1.0;
}
}
}
continue;
}
if fill {
n.appearance.set_fill(paint.clone());
} else {
n.appearance.set_stroke(paint.clone());
}
}
Ok(())
})?;
ok()
}
fn swap(s: &mut Session, _: &Value) -> Result<Value> {
std::mem::swap(&mut s.paint.fill, &mut s.paint.stroke);
let ids = paint_targets(s, &json!({}))?;
if !ids.is_empty() {
s.edit("Swap Fill and Stroke", |d, _| {
for id in &ids {
if let Some(n) = d.node_mut(*id) {
let f = n.appearance.fill_paint();
let st = n.appearance.stroke_paint();
n.appearance.set_fill(st);
n.appearance.set_stroke(f);
}
}
Ok(())
})?;
}
ok()
}
fn default_paint(s: &mut Session, _: &Value) -> Result<Value> {
s.paint.fill = Paint::solid(Color::WHITE);
s.paint.stroke = Paint::solid(Color::BLACK);
s.paint.stroke_width = 1.0;
let ids = paint_targets(s, &json!({}))?;
if !ids.is_empty() {
s.edit("Default Fill and Stroke", |d, _| {
for id in &ids {
if let Some(n) = d.node_mut(*id)
&& !matches!(n.kind, NodeKind::Text(_))
{
n.appearance = Appearance::default_art();
}
}
Ok(())
})?;
}
ok()
}
fn stroke_set(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(w) = p.get("weight").and_then(Value::as_f64) {
s.paint.stroke_width = w.max(0.0);
}
let ids = paint_targets(s, p)?;
if ids.is_empty() {
return ok();
}
let arrow = |k: &str| -> Result<Option<Option<Arrowhead>>> {
match p.get(k) {
None => Ok(None),
Some(Value::Null) => Ok(Some(None)),
Some(Value::String(n)) if n == "none" => Ok(Some(None)),
Some(v) => {
serde_json::from_value::<Arrowhead>(v.clone()).map(|a| Some(Some(a))).map_err(|_| bad("stroke.set", format!("unknown arrowhead {v}")))
}
}
};
let (sa, ea) = (arrow("startArrow")?, arrow("endArrow")?);
s.edit("Stroke", |d, _| {
for id in &ids {
let Some(n) = d.node_mut(*id) else { continue };
if n.appearance.stroke().is_none() {
n.appearance.set_stroke(Paint::solid(Color::BLACK));
}
let st: &mut StrokeLayer = n.appearance.stroke_mut().unwrap();
if let Some(w) = p.get("weight").and_then(Value::as_f64) {
st.width = w.max(0.0);
}
match str_param(p, "cap") {
Some("round") => st.cap = LineCap::Round,
Some("square") | Some("projecting") => st.cap = LineCap::Square,
Some("butt") => st.cap = LineCap::Butt,
_ => {}
}
match str_param(p, "join") {
Some("round") => st.join = LineJoin::Round,
Some("bevel") => st.join = LineJoin::Bevel,
Some("miter") => st.join = LineJoin::Miter,
_ => {}
}
if let Some(m) = p.get("miterLimit").and_then(Value::as_f64) {
st.miter_limit = m.clamp(1.0, 500.0);
}
match str_param(p, "align") {
Some("inside") => st.align = StrokeAlign::Inside,
Some("outside") => st.align = StrokeAlign::Outside,
Some("center") => st.align = StrokeAlign::Center,
_ => {}
}
match p.get("dash") {
Some(Value::Null) => st.dash = None,
Some(Value::Array(a)) => {
let pattern: Vec<f64> = a.iter().filter_map(Value::as_f64).collect();
st.dash = if pattern.is_empty() {
None
} else {
Some(Dash { pattern, offset: f64_or(p, "dashOffset", 0.0), align_corners: bool_or(p, "alignDashes", false) })
};
}
_ => {}
}
if let Some(a) = sa {
st.start_arrow = a;
}
if let Some(a) = ea {
st.end_arrow = a;
}
match str_param(p, "profile") {
Some("uniform") => st.profile = None,
Some("lens") => st.profile = Some(drawcraft_doc::WidthProfile::lens()),
Some("taperStart") => st.profile = Some(drawcraft_doc::WidthProfile::taper_start()),
Some("taperEnd") => st.profile = Some(drawcraft_doc::WidthProfile::taper_end()),
_ => {}
}
}
Ok(())
})?;
ok()
}
fn add_item(s: &mut Session, fill: bool) -> Result<Value> {
let ids = paint_targets(s, &json!({}))?;
s.edit(if fill { "Add New Fill" } else { "Add New Stroke" }, |d, _| {
for id in &ids {
if let Some(n) = d.node_mut(*id) {
let item = if fill {
AppearanceItem::Fill(FillLayer::new(n.appearance.fill_paint()))
} else {
AppearanceItem::Stroke(StrokeLayer::new(n.appearance.stroke_paint(), n.appearance.stroke_width().max(1.0)))
};
n.appearance.items.push(item);
}
}
Ok(())
})?;
ok()
}
fn clear_appearance(s: &mut Session, _: &Value) -> Result<Value> {
let ids = paint_targets(s, &json!({}))?;
s.edit("Clear Appearance", |d, _| {
for id in &ids {
if let Some(n) = d.node_mut(*id) {
n.appearance = Appearance::basic(Paint::None, Paint::None, 1.0);
}
}
Ok(())
})?;
ok()
}
fn reduce_basic(s: &mut Session, _: &Value) -> Result<Value> {
let ids = paint_targets(s, &json!({}))?;
s.edit("Reduce to Basic Appearance", |d, _| {
for id in &ids {
if let Some(n) = d.node_mut(*id) {
let f = n.appearance.fill_paint();
let st = n.appearance.stroke().cloned();
n.appearance = Appearance { items: vec![AppearanceItem::Fill(FillLayer::new(f))], effects: vec![] };
if let Some(mut st) = st {
st.effects.clear();
st.opacity = 1.0;
st.blend = BlendMode::Normal;
n.appearance.items.push(AppearanceItem::Stroke(st));
}
}
}
Ok(())
})?;
ok()
}
fn set_item(s: &mut Session, p: &Value) -> Result<Value> {
let idx = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("appearance.setItem", "missing index"))? as usize;
let paint = paint_from(s, p)?;
let ids = paint_targets(s, p)?;
let blend = str_param(p, "blend").and_then(BlendMode::parse);
s.edit("Appearance", |d, _| {
for id in &ids {
let Some(n) = d.node_mut(*id) else { continue };
let Some(item) = n.appearance.items.get_mut(idx) else { continue };
let (pp, op, bl, vis) = match item {
AppearanceItem::Fill(f) => (&mut f.paint, &mut f.opacity, &mut f.blend, &mut f.visible),
AppearanceItem::Stroke(st) => {
if let Some(w) = p.get("weight").and_then(Value::as_f64) {
st.width = w.max(0.0);
}
(&mut st.paint, &mut st.opacity, &mut st.blend, &mut st.visible)
}
};
if let Some(pa) = &paint {
*pp = pa.clone();
}
if let Some(o) = p.get("opacity").and_then(Value::as_f64) {
*op = if o > 1.0 { o / 100.0 } else { o }.clamp(0.0, 1.0) as f32;
}
if let Some(b) = blend {
*bl = b;
}
if let Some(v) = p.get("visible").and_then(Value::as_bool) {
*vis = v;
}
}
Ok(())
})?;
ok()
}
fn remove_item(s: &mut Session, p: &Value) -> Result<Value> {
let idx = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("appearance.removeItem", "missing index"))? as usize;
let ids = paint_targets(s, p)?;
s.edit("Remove Item", |d, _| {
for id in &ids {
if let Some(n) = d.node_mut(*id)
&& idx < n.appearance.items.len()
{
n.appearance.items.remove(idx);
}
}
Ok(())
})?;
ok()
}
fn add_effect(s: &mut Session, p: &Value) -> Result<Value> {
let id = str_param(p, "effect").ok_or_else(|| bad("appearance.addEffect", "missing effect id"))?.to_string();
let params = p.get("params").cloned().unwrap_or(json!({}));
let ids = targets(s, p)?;
s.edit("Add Effect", |d, _| {
for nid in &ids {
if let Some(n) = d.node_mut(*nid) {
n.appearance.effects.push(drawcraft_doc::Effect { id: id.clone(), params: params.clone(), visible: true });
}
}
Ok(())
})?;
ok()
}
fn transparency(s: &mut Session, p: &Value) -> Result<Value> {
let mut q = p.clone();
if let Some(o) = p.get("opacity").and_then(Value::as_f64) {
q["opacity"] = json!(o / 100.0);
}
s.execute("object.setProps", &q)
}
fn style_apply(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "name").ok_or_else(|| bad("graphicStyle.apply", "missing name"))?;
let ap = s
.doc()?
.doc
.graphic_styles
.iter()
.find(|g| g.name == name)
.map(|g| g.appearance.clone())
.ok_or_else(|| EngineError::Other(format!("no graphic style `{name}`")))?;
let ids = paint_targets(s, p)?;
s.edit("Apply Graphic Style", |d, _| {
for id in &ids {
if let Some(n) = d.node_mut(*id) {
n.appearance = ap.clone();
}
}
Ok(())
})?;
ok()
}
fn style_new(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.doc()?;
let id = *st.selection.objects.first().unwrap();
let ap = st.doc.node(id).map(|n| n.appearance.clone()).unwrap_or_default();
let name = str_param(p, "name").map(str::to_string).unwrap_or_else(|| format!("Graphic Style {}", st.doc.graphic_styles.len() + 1));
s.edit("New Graphic Style", |d, _| {
d.graphic_styles.push(drawcraft_doc::GraphicStyle { name: name.clone(), appearance: ap });
Ok(())
})?;
Ok(json!({ "name": name }))
}
fn swatch_new(s: &mut Session, p: &Value) -> Result<Value> {
let paint = match paint_from(s, p)? {
Some(x) => x,
None => s.paint.fill.clone(),
};
let name = match str_param(p, "name") {
Some(n) => n.to_string(),
None => match &paint {
Paint::Solid { color, .. } => {
let [r, g, b] = color.to_rgb();
format!("R={} G={} B={}", (r * 255.0).round(), (g * 255.0).round(), (b * 255.0).round())
}
_ => format!("Swatch {}", s.doc()?.doc.swatches.len() + 1),
},
};
let global = bool_or(p, "global", false);
s.edit("New Swatch", |d, _| {
d.swatches.push(drawcraft_color::Swatch { name: name.clone(), paint, global, spot: false });
Ok(())
})?;
Ok(json!({ "name": name }))
}
fn swatch_delete(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "name").ok_or_else(|| bad("swatch.delete", "missing name"))?.to_string();
s.edit("Delete Swatch", |d, _| {
d.swatches.retain(|sw| sw.name != name);
for g in &mut d.swatch_groups {
g.swatches.retain(|sw| sw.name != name);
}
Ok(())
})?;
ok()
}
+472
View File
@@ -0,0 +1,472 @@
//! Path editing commands (used by the Pen and Direct Selection tools and Object → Path).
use std::collections::BTreeSet;
use drawcraft_doc::{NodeId, NodeKind};
use drawcraft_geom::{Anchor, AnchorKind, PathData, Point, SubPath, Vec2};
use serde_json::{Value, json};
use super::create::anchor_from_json;
use super::*;
use crate::EngineError;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"path.appendAnchor",
"Add Anchor (Pen)",
[],
None,
"{id, x, y, in?: [x,y], out?: [x,y]} append to the end of the last open subpath",
has_doc,
append_anchor
),
cmd!("path.close", "Close Path", [], None, "{id, in?: [x,y] (handle into the first anchor), independent?: bool}", has_doc, close),
cmd!(
"path.moveAnchors",
"Move Anchors",
[],
None,
"{dx, dy} move direct-selected anchors (whole paths if fully selected)",
has_selection,
move_anchors
),
cmd!(
"path.setHandle",
"Move Direction Handle",
[],
None,
"{id, subpath, anchor, which: \"in\"|\"out\", x, y, independent?: bool}",
has_doc,
set_handle
),
cmd!(
"path.setAnchors",
"Set Anchors",
[],
None,
"{id, subpaths: [{anchors: [{x,y,in?,out?}], closed}]} replace a path's geometry",
has_doc,
set_anchors
),
cmd!("path.reverse", "Reverse Path Direction", ["Object", "Path"], None, "{}", has_selection, reverse),
cmd!(
"path.join",
"Join",
["Object", "Path"],
Some("Cmd+J"),
"{} join two open paths' nearest endpoints, or close a single open path",
has_selection,
join
),
cmd!(
"path.average",
"Average…",
["Object", "Path"],
Some("Cmd+Alt+J"),
"{axis?: \"horizontal\"|\"vertical\"|\"both\"}",
has_selection,
average
),
cmd!(
"path.convertAnchors",
"Convert Anchor Points",
[],
None,
"{to: \"corner\"|\"smooth\"} (Control bar convert buttons)",
has_selection,
convert_anchors
),
cmd!(
"path.deleteAnchors",
"Remove Anchor Points",
["Object", "Path"],
None,
"{} delete direct-selected anchors",
has_selection,
delete_anchors
),
cmd!("path.insertAnchor", "Add Anchor Point", [], None, "{id, subpath, segment, t: 0..1}", has_doc, insert_anchor),
cmd!("path.cutAtAnchors", "Cut Path at Selected Anchor Points", [], None, "{}", has_selection, cut_at_anchors),
]
}
fn path_mut(d: &mut drawcraft_doc::Document, id: NodeId) -> Result<&mut PathData> {
let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?;
match &mut n.kind {
NodeKind::Path { path, live, .. } => {
*live = None; // editing anchors expands a live shape
Ok(path)
}
_ => Err(EngineError::Other(format!("object {id} is not a path"))),
}
}
fn append_anchor(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id").ok_or_else(|| bad("path.appendAnchor", "missing id"))?;
let a = anchor_from_json(p).ok_or_else(|| bad("path.appendAnchor", "missing x/y"))?;
s.edit("Pen", |d, sel| {
let path = path_mut(d, id)?;
match path.subpaths.last_mut() {
Some(sp) if !sp.closed => sp.anchors.push(a),
_ => path.subpaths.push(SubPath::new(vec![a], false)),
}
sel.set([id]);
let last = path.subpaths.len() - 1;
let ai = path.subpaths[last].anchors.len() - 1;
sel.anchors.insert(id, BTreeSet::from([(last, ai)]));
Ok(())
})?;
ok()
}
fn close(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id").ok_or_else(|| bad("path.close", "missing id"))?;
let h_in = point_param(p, "in");
let independent = bool_or(p, "independent", false);
s.edit("Close Path", |d, sel| {
let path = path_mut(d, id)?;
let sp = path.subpaths.iter_mut().rev().find(|s| !s.closed).ok_or_else(|| EngineError::Other("path is already closed".into()))?;
sp.closed = true;
if let (Some(h), Some(first)) = (h_in, sp.anchors.first_mut()) {
first.h_in = h;
if !independent {
first.h_out = first.p - (h - first.p);
first.kind = AnchorKind::Smooth;
}
}
sel.set([id]);
Ok(())
})?;
ok()
}
pub(crate) fn move_anchors(s: &mut Session, p: &Value) -> Result<Value> {
let d = Vec2::new(f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0));
let st = s.doc()?;
let sel = st.selection.clone();
let scale_strokes = s.prefs.scale_strokes;
let _ = scale_strokes;
s.edit("Move", |doc, _| {
for id in &sel.objects {
match sel.anchors.get(id) {
Some(set) => {
let path = path_mut(doc, *id)?;
for &(si, ai) in set {
if let Some(a) = path.anchor_mut(si, ai) {
a.translate(d);
}
}
}
None => {
if let Some(n) = doc.node_mut(*id) {
n.transform(drawcraft_geom::Affine::translate(d), false);
}
}
}
}
Ok(())
})?;
ok()
}
fn set_handle(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id").ok_or_else(|| bad("path.setHandle", "missing id"))?;
let si = p.get("subpath").and_then(Value::as_u64).unwrap_or(0) as usize;
let ai = p.get("anchor").and_then(Value::as_u64).ok_or_else(|| bad("path.setHandle", "missing anchor"))? as usize;
let out = str_param(p, "which") != Some("in");
let pos = Point::new(f64_req(p, "x", "path.setHandle")?, f64_req(p, "y", "path.setHandle")?);
let independent = bool_or(p, "independent", false);
s.edit("Reshape", |d, _| {
let path = path_mut(d, id)?;
let a = path.anchor_mut(si, ai).ok_or_else(|| EngineError::Other("no such anchor".into()))?;
if independent {
a.kind = AnchorKind::Corner;
}
let (moved, other) = if out { (&mut a.h_out, &mut a.h_in) } else { (&mut a.h_in, &mut a.h_out) };
*moved = pos;
if a.kind == AnchorKind::Smooth {
// Keep the opposite handle collinear, preserving its length.
let len = (*other - a.p).hypot();
let dir = a.p - pos;
let l = dir.hypot();
if l > 1e-9 {
*other = a.p + dir * (len / l);
}
}
Ok(())
})?;
ok()
}
fn set_anchors(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id").ok_or_else(|| bad("path.setAnchors", "missing id"))?;
let subs = p.get("subpaths").and_then(Value::as_array).ok_or_else(|| bad("path.setAnchors", "missing subpaths"))?;
let data = PathData::new(
subs.iter()
.map(|sp| {
SubPath::new(
sp.get("anchors").and_then(Value::as_array).map(|a| a.iter().filter_map(anchor_from_json).collect()).unwrap_or_default(),
bool_or(sp, "closed", false),
)
})
.collect(),
);
s.edit("Reshape", |d, _| {
*path_mut(d, id)? = data;
Ok(())
})?;
ok()
}
fn selected_paths(s: &Session) -> Result<Vec<NodeId>> {
let st = s.doc()?;
Ok(st.selection.objects.iter().copied().filter(|id| matches!(st.doc.node(*id).map(|n| &n.kind), Some(NodeKind::Path { .. }))).collect())
}
fn reverse(s: &mut Session, _: &Value) -> Result<Value> {
let ids = selected_paths(s)?;
s.edit("Reverse Path Direction", |d, _| {
for id in &ids {
path_mut(d, *id)?.reverse();
}
Ok(())
})?;
ok()
}
fn join(s: &mut Session, _: &Value) -> Result<Value> {
let ids = selected_paths(s)?;
match ids.as_slice() {
[one] => {
let id = *one;
s.edit("Join", |d, _| {
let path = path_mut(d, id)?;
let sp = path.subpaths.iter_mut().find(|s| !s.closed).ok_or_else(|| EngineError::Other("path is already closed".into()))?;
// Merge coincident end points.
if sp.anchors.len() > 2 && sp.anchors[0].p.distance(sp.anchors.last().unwrap().p) < 1e-6 {
let last = sp.anchors.pop().unwrap();
sp.anchors[0].h_in = last.h_in;
}
sp.closed = true;
Ok(())
})?;
}
[a, b, ..] => {
let (a, b) = (*a, *b);
s.edit("Join", |d, sel| {
let pb = d.node(b).and_then(|n| n.path_data()).cloned().ok_or(EngineError::NoNode(b))?;
let pa = path_mut(d, a)?;
let (Some(sa), Some(sb)) = (pa.subpaths.iter().position(|s| !s.closed), pb.subpaths.iter().position(|s| !s.closed)) else {
return Err(EngineError::Other("join needs open paths".into()));
};
let mut x = pa.subpaths[sa].clone();
let mut y = pb.subpaths[sb].clone();
let (xf, xl) = (x.anchors[0].p, x.anchors.last().unwrap().p);
let (yf, yl) = (y.anchors[0].p, y.anchors.last().unwrap().p);
// Pick the closest pair of ends.
let cands =
[(xl.distance(yf), false, false), (xl.distance(yl), false, true), (xf.distance(yf), true, false), (xf.distance(yl), true, true)];
let (_, rx, ry) = cands.into_iter().min_by(|p, q| p.0.total_cmp(&q.0)).unwrap();
if rx {
x.reverse();
}
if ry {
y.reverse();
}
if x.anchors.last().unwrap().p.distance(y.anchors[0].p) < 1e-6 {
let first = y.anchors.remove(0);
x.anchors.last_mut().unwrap().h_out = first.h_out;
}
x.anchors.extend(y.anchors);
pa.subpaths[sa] = x;
d.remove(b)?;
sel.set([a]);
Ok(())
})?;
}
[] => return Err(EngineError::Other("select paths to join".into())),
}
ok()
}
type AnchorTargets = Vec<(NodeId, Vec<(usize, usize)>)>;
fn anchor_targets(s: &Session) -> Result<AnchorTargets> {
let st = s.doc()?;
let mut out = vec![];
for id in &st.selection.objects {
let Some(pd) = st.doc.node(*id).and_then(|n| n.path_data()) else { continue };
let v: Vec<(usize, usize)> = match st.selection.anchors.get(id) {
Some(set) => set.iter().copied().collect(),
None => pd.anchors().map(|(s, a, _)| (s, a)).collect(),
};
out.push((*id, v));
}
Ok(out)
}
fn average(s: &mut Session, p: &Value) -> Result<Value> {
let t = anchor_targets(s)?;
let d0 = &s.doc()?.doc;
let pts: Vec<Point> = t
.iter()
.flat_map(|(id, v)| v.iter().filter_map(move |(si, ai)| d0.node(*id)?.path_data()?.subpaths.get(*si)?.anchors.get(*ai).map(|a| a.p)))
.collect();
if pts.is_empty() {
return ok();
}
let c = Point::new(pts.iter().map(|p| p.x).sum::<f64>() / pts.len() as f64, pts.iter().map(|p| p.y).sum::<f64>() / pts.len() as f64);
let axis = str_param(p, "axis").unwrap_or("both").to_string();
s.edit("Average", |d, _| {
for (id, v) in &t {
let path = path_mut(d, *id)?;
for &(si, ai) in v {
if let Some(a) = path.anchor_mut(si, ai) {
let target = match axis.as_str() {
"horizontal" => Point::new(a.p.x, c.y),
"vertical" => Point::new(c.x, a.p.y),
_ => c,
};
a.translate(target - a.p);
}
}
}
Ok(())
})?;
ok()
}
fn convert_anchors(s: &mut Session, p: &Value) -> Result<Value> {
let t = anchor_targets(s)?;
let smooth = str_param(p, "to") == Some("smooth");
s.edit("Convert Anchor Points", |d, _| {
for (id, v) in &t {
let path = path_mut(d, *id)?;
for &(si, ai) in v {
let sp = &path.subpaths[si];
let n = sp.anchors.len();
let prev = if ai > 0 {
Some(sp.anchors[ai - 1].p)
} else if sp.closed {
Some(sp.anchors[n - 1].p)
} else {
None
};
let next = if ai + 1 < n {
Some(sp.anchors[ai + 1].p)
} else if sp.closed {
Some(sp.anchors[0].p)
} else {
None
};
let a: &mut Anchor = &mut path.subpaths[si].anchors[ai];
if smooth {
// Handles parallel to prev→next, a third of the neighbour distances.
let (pp, nn) = (prev.unwrap_or(a.p), next.unwrap_or(a.p));
let dir = nn - pp;
let l = dir.hypot();
if l > 1e-9 {
let u = dir / l;
a.h_in = a.p - u * (a.p.distance(pp) / 3.0);
a.h_out = a.p + u * (a.p.distance(nn) / 3.0);
a.kind = AnchorKind::Smooth;
}
} else {
a.retract();
}
}
}
Ok(())
})?;
ok()
}
pub(crate) fn delete_anchors(s: &mut Session, _: &Value) -> Result<Value> {
let st = s.doc()?;
let sel = st.selection.anchors.clone();
s.edit("Clear", |d, selection| {
for (id, set) in &sel {
let remove_node = {
let path = path_mut(d, *id)?;
// Deleting an anchor from a closed path opens it there (Illustrator behaviour on Delete).
for &(si, ai) in set.iter().rev() {
if let Some(sp) = path.subpaths.get_mut(si)
&& ai < sp.anchors.len()
{
if sp.closed {
sp.anchors.rotate_left(ai + 1);
sp.anchors.pop();
sp.closed = false;
} else {
sp.anchors.remove(ai);
}
}
}
path.subpaths.retain(|s| s.anchors.len() >= 2);
path.subpaths.is_empty()
};
if remove_node {
d.remove(*id)?;
}
}
selection.clear();
Ok(())
})?;
ok()
}
fn insert_anchor(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id").ok_or_else(|| bad("path.insertAnchor", "missing id"))?;
let si = p.get("subpath").and_then(Value::as_u64).unwrap_or(0) as usize;
let seg = p.get("segment").and_then(Value::as_u64).ok_or_else(|| bad("path.insertAnchor", "missing segment"))? as usize;
let t = f64_or(p, "t", 0.5).clamp(0.0, 1.0);
let idx = s.edit("Add Anchor Point", |d, _| {
let path = path_mut(d, id)?;
let sp = path.subpaths.get_mut(si).ok_or_else(|| EngineError::Other("no such subpath".into()))?;
if seg >= sp.segment_count() {
return Err(EngineError::Other("no such segment".into()));
}
Ok(sp.insert_anchor(seg, t))
})?;
Ok(json!({ "anchor": idx }))
}
fn cut_at_anchors(s: &mut Session, _: &Value) -> Result<Value> {
let sel = s.doc()?.selection.anchors.clone();
s.edit("Cut Path", |d, _| {
for (id, set) in &sel {
let path = path_mut(d, *id)?;
let mut out = vec![];
for (si, sp) in path.subpaths.iter().enumerate() {
let cuts: Vec<usize> = set.iter().filter(|(s, _)| *s == si).map(|(_, a)| *a).collect();
if cuts.is_empty() {
out.push(sp.clone());
continue;
}
let mut anchors = sp.anchors.clone();
if sp.closed {
anchors.rotate_left(cuts[0]);
anchors.push(anchors[0]);
}
let base = if sp.closed { cuts[0] } else { 0 };
let n = sp.anchors.len();
let rel: Vec<usize> = cuts.iter().map(|c| (c + n - base) % n).collect();
let mut cur = vec![];
for (i, a) in anchors.iter().enumerate() {
cur.push(*a);
if rel.contains(&(i % n)) && cur.len() > 1 {
out.push(SubPath::new(std::mem::take(&mut cur), false));
cur.push(*a);
}
}
if cur.len() > 1 {
out.push(SubPath::new(cur, false));
}
}
path.subpaths = out;
}
Ok(())
})?;
ok()
}
+267
View File
@@ -0,0 +1,267 @@
//! Select menu.
use std::collections::BTreeSet;
use drawcraft_doc::{Document, Node, NodeId, NodeKind};
use serde_json::{Value, json};
use super::*;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("select.all", "All", ["Select"], Some("Cmd+A"), "{}", has_doc, all),
cmd!("select.allOnArtboard", "All on Active Artboard", ["Select"], Some("Cmd+Alt+A"), "{artboard?: index}", has_doc, all_on_artboard),
cmd!("select.none", "Deselect", ["Select"], Some("Cmd+Shift+A"), "{}", has_doc, none),
cmd!("select.reselect", "Reselect", ["Select"], Some("Cmd+6"), "{}", has_doc, reselect),
cmd!("select.inverse", "Inverse", ["Select"], None, "{}", has_doc, inverse),
cmd!("select.nextAbove", "Next Object Above", ["Select"], Some("Cmd+Alt+]"), "{}", has_selection, |s, _| step(s, 1)),
cmd!("select.nextBelow", "Next Object Below", ["Select"], Some("Cmd+Alt+["), "{}", has_selection, |s, _| step(s, -1)),
cmd!("select.set", "Select Objects", [], None, "{ids: [id…]}", has_doc, set),
cmd!("select.add", "Add to Selection", [], None, "{ids: [id…]}", has_doc, add),
cmd!("select.toggle", "Toggle Selection", [], None, "{id}", has_doc, toggle),
cmd!("select.key", "Set Key Object", [], None, "{id?} (none clears)", has_doc, key),
cmd!("select.anchors", "Select Anchors", [], None, "{id, anchors: [[subpath, anchor]…], mode: \"set\"|\"add\"|\"toggle\"}", has_doc, anchors),
cmd!("select.anchorsMany", "Select Anchors", [], None, "{items: [{id, anchors}], add?: bool}", has_doc, anchors_many),
cmd!("select.same.fillColor", "Fill Color", ["Select", "Same"], None, "{}", has_selection, |s, _| same(
s,
"select.same.fillColor",
|a, b| a.appearance.fill_paint() == b.appearance.fill_paint()
)),
cmd!("select.same.strokeColor", "Stroke Color", ["Select", "Same"], None, "{}", has_selection, |s, _| same(
s,
"select.same.strokeColor",
|a, b| a.appearance.stroke_paint() == b.appearance.stroke_paint()
)),
cmd!("select.same.strokeWeight", "Stroke Weight", ["Select", "Same"], None, "{}", has_selection, |s, _| same(
s,
"select.same.strokeWeight",
|a, b| (a.appearance.stroke_width() - b.appearance.stroke_width()).abs() < 1e-9
)),
cmd!("select.same.fillAndStroke", "Fill & Stroke", ["Select", "Same"], None, "{}", has_selection, |s, _| same(
s,
"select.same.fillAndStroke",
|a, b| a.appearance.fill_paint() == b.appearance.fill_paint() && a.appearance.stroke_paint() == b.appearance.stroke_paint()
)),
cmd!("select.same.opacity", "Opacity", ["Select", "Same"], None, "{}", has_selection, |s, _| same(s, "select.same.opacity", |a, b| (a
.opacity
- b.opacity)
.abs()
< 1e-6)),
cmd!("select.same.blendingMode", "Blending Mode", ["Select", "Same"], None, "{}", has_selection, |s, _| same(
s,
"select.same.blendingMode",
|a, b| a.blend == b.blend
)),
cmd!("select.same.appearance", "Appearance", ["Select", "Same"], None, "{}", has_selection, |s, _| same(
s,
"select.same.appearance",
|a, b| a.appearance == b.appearance && a.opacity == b.opacity && a.blend == b.blend
)),
cmd!("select.same.shapeType", "Shape", ["Select", "Same"], None, "{}", has_selection, |s, _| same(s, "select.same.shapeType", |a, b| a
.kind_label()
== b.kind_label())),
cmd!("select.object.allOnSameLayers", "All on Same Layers", ["Select", "Object"], None, "{}", has_selection, same_layers),
cmd!("select.object.clippingMasks", "Clipping Masks", ["Select", "Object"], None, "{}", has_doc, |s, _| by_kind(s, |n| matches!(
n.kind,
NodeKind::Path { clipping: true, .. }
))),
cmd!("select.object.textObjects", "All Text Objects", ["Select", "Object"], None, "{}", has_doc, |s, _| by_kind(s, |n| matches!(
n.kind,
NodeKind::Text(_)
))),
cmd!("select.object.strayPoints", "Stray Points", ["Select", "Object"], None, "{}", has_doc, |s, _| by_kind(s, |n| n
.path_data()
.is_some_and(|p| p.anchor_count() == 1))),
cmd!("select.object.openPaths", "Open Paths", ["Select", "Object"], None, "{}", has_doc, |s, _| by_kind(s, |n| n
.path_data()
.is_some_and(|p| !p.is_closed()))),
]
}
/// Selectable objects: children of visible, unlocked layers (or of the isolation container).
fn selectable(d: &Document, iso: Option<NodeId>) -> Vec<NodeId> {
match iso.and_then(|i| d.node(i)) {
Some(c) => c.children().map(|v| v.iter().filter(|n| n.visible && !n.locked).map(|n| n.id).collect()).unwrap_or_default(),
None => d
.layers
.iter()
.filter(|l| l.visible && !l.locked)
.flat_map(|l| l.children().into_iter().flatten())
.filter(|n| n.visible && !n.locked)
.map(|n| n.id)
.collect(),
}
}
fn all(s: &mut Session, _: &Value) -> Result<Value> {
let st = s.doc()?;
let ids = selectable(&st.doc, st.isolation);
s.select(|_, sel| sel.set(ids.iter().copied()))?;
Ok(json!({ "count": s.doc()?.selection.len() }))
}
fn all_on_artboard(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.doc()?;
let i = p.get("artboard").and_then(Value::as_u64).unwrap_or(0) as usize;
let r = st.doc.artboards.get(i).map(|a| a.rect).ok_or_else(|| bad("select.allOnArtboard", "no such artboard"))?;
let ids: Vec<NodeId> = selectable(&st.doc, st.isolation)
.into_iter()
.filter(|id| st.doc.node(*id).and_then(|n| n.geometric_bounds()).is_some_and(|b| b.intersect(r).area() > 0.0 || r.contains(b.center())))
.collect();
s.select(|_, sel| sel.set(ids.iter().copied()))?;
ok()
}
fn none(s: &mut Session, _: &Value) -> Result<Value> {
s.select(|_, sel| sel.clear())?;
ok()
}
fn reselect(s: &mut Session, _: &Value) -> Result<Value> {
let Some((c, p)) = s.doc()?.last_selection_cmd.clone() else { return ok() };
s.execute(&c, &p)
}
fn inverse(s: &mut Session, _: &Value) -> Result<Value> {
let st = s.doc()?;
let ids: Vec<NodeId> = selectable(&st.doc, st.isolation).into_iter().filter(|id| !st.selection.contains(*id)).collect();
s.select(|_, sel| sel.set(ids.iter().copied()))?;
ok()
}
fn step(s: &mut Session, dir: i64) -> Result<Value> {
let st = s.doc()?;
let Some(cur) = st.selection.objects.first().copied() else { return ok() };
let Some((par, idx, n)) = st.doc.position(cur) else { return ok() };
let j = idx as i64 + dir;
if j < 0 || j >= n as i64 {
return ok();
}
let id = st.doc.children(par).unwrap()[j as usize].id;
s.select(|_, sel| sel.set([id]))?;
ok()
}
fn set(s: &mut Session, p: &Value) -> Result<Value> {
let ids = ids_param(p, "ids").ok_or_else(|| bad("select.set", "missing ids"))?;
s.select(|d, sel| sel.set(ids.iter().copied().filter(|i| d.node(*i).is_some())))?;
ok()
}
fn add(s: &mut Session, p: &Value) -> Result<Value> {
let ids = ids_param(p, "ids").ok_or_else(|| bad("select.add", "missing ids"))?;
s.select(|_, sel| {
for i in ids {
sel.add(i);
}
})?;
ok()
}
fn toggle(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id").ok_or_else(|| bad("select.toggle", "missing id"))?;
s.select(|_, sel| sel.toggle(id))?;
ok()
}
fn key(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id");
s.select(|_, sel| sel.key = id.filter(|i| sel.contains(*i)))?;
ok()
}
fn parse_refs(v: Option<&Value>) -> Vec<(usize, usize)> {
v.and_then(Value::as_array)
.map(|a| a.iter().filter_map(|x| Some((x.get(0)?.as_u64()? as usize, x.get(1)?.as_u64()? as usize))).collect())
.unwrap_or_default()
}
fn anchors(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id").ok_or_else(|| bad("select.anchors", "missing id"))?;
let refs = parse_refs(p.get("anchors"));
let mode = str_param(p, "mode").unwrap_or("set").to_string();
s.select(|_, sel| {
if mode == "set" {
sel.clear();
}
sel.add(id);
let e = sel.anchors.entry(id).or_default();
for r in refs {
if mode == "toggle" && e.contains(&r) {
e.remove(&r);
} else {
e.insert(r);
}
}
})?;
ok()
}
fn anchors_many(s: &mut Session, p: &Value) -> Result<Value> {
let items: Vec<(NodeId, BTreeSet<(usize, usize)>)> = p
.get("items")
.and_then(Value::as_array)
.map(|a| a.iter().filter_map(|it| Some((NodeId(it.get("id")?.as_u64()?), parse_refs(it.get("anchors")).into_iter().collect()))).collect())
.unwrap_or_default();
let add = bool_or(p, "add", false);
s.select(|d, sel| {
if !add {
sel.clear();
}
for (id, refs) in items {
let total = d.node(id).and_then(|n| n.path_data()).map(|p| p.anchor_count()).unwrap_or(0);
sel.add(id);
if refs.len() < total {
sel.anchors.entry(id).or_default().extend(refs);
} else {
sel.anchors.remove(&id);
}
}
})?;
ok()
}
fn same(s: &mut Session, cmd: &str, eq: fn(&Node, &Node) -> bool) -> Result<Value> {
let st = s.doc()?;
let refn = st.selection.objects.first().and_then(|id| st.doc.node(*id)).cloned().ok_or_else(|| bad(cmd, "nothing selected"))?;
let mut ids = vec![];
for l in st.doc.layers.iter().filter(|l| l.visible && !l.locked) {
l.walk(&mut |n| {
if !n.is_container() && n.visible && !n.locked && eq(n, &refn) {
ids.push(n.id);
}
});
}
let c = cmd.to_string();
s.select(|_, sel| sel.set(ids.iter().copied()))?;
s.doc_mut()?.last_selection_cmd = Some((c, json!({})));
Ok(json!({ "count": ids.len() }))
}
fn same_layers(s: &mut Session, _: &Value) -> Result<Value> {
let st = s.doc()?;
let layers: BTreeSet<NodeId> = st.selection.objects.iter().filter_map(|id| st.doc.layer_of(*id)).collect();
let ids: Vec<NodeId> = layers
.iter()
.filter_map(|l| st.doc.node(*l))
.flat_map(|l| l.children().into_iter().flatten())
.filter(|n| n.visible && !n.locked)
.map(|n| n.id)
.collect();
s.select(|_, sel| sel.set(ids.iter().copied()))?;
ok()
}
fn by_kind(s: &mut Session, f: fn(&Node) -> bool) -> Result<Value> {
let st = s.doc()?;
let mut ids = vec![];
for l in st.doc.layers.iter().filter(|l| l.visible && !l.locked) {
l.walk(&mut |n| {
if f(n) {
ids.push(n.id)
}
});
}
s.select(|_, sel| sel.set(ids.iter().copied()))?;
Ok(json!({ "count": ids.len() }))
}
+70
View File
@@ -0,0 +1,70 @@
//! Agent-friendly document summaries and view-models for panels.
use drawcraft_doc::{Node, NodeKind};
use serde_json::{Value, json};
use crate::Session;
fn rect_json(r: Option<drawcraft_geom::Rect>) -> Value {
match r {
Some(r) => json!({ "x": r.x0, "y": r.y0, "width": r.width(), "height": r.height() }),
None => Value::Null,
}
}
/// Compact tree summary of a node (for `document.inspect`).
pub fn node_summary(n: &Node) -> Value {
let mut v = json!({
"id": n.id.0,
"name": n.display_name(),
"kind": n.kind_label(),
"visible": n.visible,
"locked": n.locked,
"bounds": rect_json(n.geometric_bounds()),
});
if n.opacity < 1.0 {
v["opacity"] = json!(n.opacity);
}
if !n.is_container() {
v["fill"] = json!(n.appearance.fill_paint().label());
v["stroke"] = json!(n.appearance.stroke_paint().label());
v["strokeWidth"] = json!(n.appearance.stroke_width());
}
match &n.kind {
NodeKind::Path { path, .. } => {
v["anchors"] = json!(path.anchor_count());
v["closed"] = json!(path.is_closed());
}
NodeKind::Text(t) => v["text"] = json!(t.plain_text()),
_ => {}
}
if let Some(ch) = n.children() {
v["children"] = Value::Array(ch.iter().rev().map(|c| node_summary(c)).collect());
}
v
}
pub fn document(s: &Session) -> Value {
let Some(st) = s.active() else { return Value::Null };
let d = &st.doc;
json!({
"title": st.title(),
"path": st.path,
"dirty": st.is_dirty(),
"revision": st.revision,
"units": d.units.label(),
"colorMode": format!("{:?}", d.color_mode),
"artboards": d.artboards.iter().map(|a| json!({"name": a.name, "x": a.rect.x0, "y": a.rect.y0, "width": a.rect.width(), "height": a.rect.height()})).collect::<Vec<_>>(),
// Top of the stack first, like the Layers panel.
"layers": d.layers.iter().rev().map(|l| node_summary(l)).collect::<Vec<_>>(),
"currentLayer": st.active_layer.map(|l| l.0),
"isolation": st.isolation.map(|l| l.0),
"selection": st.selection.objects.iter().map(|i| i.0).collect::<Vec<_>>(),
"selectionBounds": rect_json(d.bounds_of(&st.selection.objects, false)),
"history": st.history.undo.iter().map(|h| h.label.clone()).collect::<Vec<_>>(),
"redo": st.history.redo.iter().rev().map(|h| h.label.clone()).collect::<Vec<_>>(),
"objects": d.node_count(),
"tool": s.tool_id(),
"paint": {"fill": s.paint.fill.label(), "stroke": s.paint.stroke.label(), "strokeWidth": s.paint.stroke_width, "fillActive": s.fill_active},
})
}
+353
View File
@@ -0,0 +1,353 @@
//! The DrawCraft engine façade.
//!
//! Every user-visible action is a command with a stable id (`object.group`, `select.same.fillColor`,
//! `shape.rectangle`…) and JSON parameters. The egui UI, the CLI, the control channel and the MCP
//! server all go through [`Session::execute`]. Tools (pointer gestures) are hosted here too and
//! reduce to commands, so every gesture is journaled and replayable.
#![forbid(unsafe_code)]
pub mod cmd;
pub mod inspect;
mod tooling;
use std::sync::Arc;
use drawcraft_color::{Color, Paint};
use drawcraft_doc::{Document, NodeId, Selection};
use drawcraft_geom::Affine;
use drawcraft_tools::{PaintDefaults, Tool};
use serde_json::Value;
pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command};
pub use drawcraft_doc as doc;
pub use drawcraft_tools as tools;
pub use tooling::{UiRequest, ViewInfo};
#[derive(Debug, thiserror::Error)]
pub enum EngineError {
#[error("unknown command `{0}`")]
UnknownCommand(String),
#[error("command `{0}` is not available right now: {1}")]
Disabled(String, String),
#[error("invalid parameters for `{cmd}`: {msg}")]
BadParams { cmd: String, msg: String },
#[error("no active document")]
NoDocument,
#[error("no such object {0}")]
NoNode(NodeId),
#[error("{0}")]
Other(String),
}
impl From<drawcraft_doc::DocError> for EngineError {
fn from(e: drawcraft_doc::DocError) -> Self {
EngineError::Other(e.to_string())
}
}
pub type Result<T> = std::result::Result<T, EngineError>;
/// One undo step.
#[derive(Clone, Debug)]
pub struct HistoryEntry {
pub label: String,
pub doc: Arc<Document>,
pub selection: Selection,
}
#[derive(Clone, Debug, Default)]
pub struct History {
pub undo: Vec<HistoryEntry>,
pub redo: Vec<HistoryEntry>,
/// Maximum undo depth (Illustrator has no hard limit; snapshots are cheap here).
pub limit: usize,
}
/// An in-progress drag: the snapshot the previews are applied to.
#[derive(Clone, Debug)]
pub struct Interaction {
pub label: String,
pub doc: Arc<Document>,
pub selection: Selection,
pub preview: Option<(String, Value)>,
}
/// Per-document editing state.
#[derive(Clone, Debug)]
pub struct DocState {
pub doc: Arc<Document>,
pub selection: Selection,
pub history: History,
pub path: Option<String>,
/// Increments on every change; UIs re-render when it moves.
pub revision: u64,
pub saved_revision: u64,
/// The layer new art goes into (the "current layer" in the Layers panel).
pub active_layer: Option<NodeId>,
/// Isolation mode container.
pub isolation: Option<NodeId>,
pub interaction: Option<Interaction>,
/// For Object → Transform → Transform Again (⌘D).
pub last_transform: Option<(Affine, bool)>,
/// Selection saved by Select → Reselect.
pub last_selection_cmd: Option<(String, Value)>,
}
impl DocState {
pub fn new(doc: Document, path: Option<String>) -> Self {
let active_layer = doc.default_layer();
Self {
doc: Arc::new(doc),
selection: Selection::default(),
history: History { limit: 500, ..Default::default() },
path,
revision: 1,
saved_revision: 1,
active_layer,
isolation: None,
interaction: None,
last_transform: None,
last_selection_cmd: None,
}
}
pub fn is_dirty(&self) -> bool {
self.revision != self.saved_revision
}
pub fn title(&self) -> String {
self.path
.as_deref()
.and_then(|p| std::path::Path::new(p).file_name())
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| self.doc.title.clone())
}
/// Where new art is inserted: the isolation container, else the active layer.
pub fn insertion_parent(&self) -> Option<NodeId> {
if let Some(i) = self.isolation
&& self.doc.node(i).is_some()
{
return Some(i);
}
self.active_layer.filter(|l| self.doc.node(*l).is_some_and(|n| !n.locked)).or_else(|| self.doc.default_layer())
}
}
/// Application preferences that affect commands.
#[derive(Clone, Debug)]
pub struct Prefs {
/// Arrow-key nudge distance (General → Keyboard Increment), points.
pub keyboard_increment: f64,
/// Scale Strokes & Effects.
pub scale_strokes: bool,
/// Offset for Paste / duplicate (Illustrator pastes to the view centre; we offset by this).
pub paste_offset: f64,
pub corner_radius: f64,
}
impl Default for Prefs {
fn default() -> Self {
Self { keyboard_increment: 1.0, scale_strokes: true, paste_offset: 10.0, corner_radius: 12.0 }
}
}
pub struct Session {
docs: Vec<DocState>,
active: Option<usize>,
pub prefs: Prefs,
/// Fill/stroke for new art (the toolbar proxy).
pub paint: PaintDefaults,
/// Which proxy is in front (true = Fill, false = Stroke) — the X key toggles.
pub fill_active: bool,
/// Internal clipboard (serialized nodes).
pub clipboard: Vec<drawcraft_doc::Node>,
/// Executed commands (for actions and debugging).
pub journal: Vec<(String, Value)>,
pub(crate) tool: Box<dyn Tool>,
untitled_counter: u32,
}
impl Default for Session {
fn default() -> Self {
Self::new()
}
}
impl Session {
pub fn new() -> Self {
Self {
docs: vec![],
active: None,
prefs: Prefs::default(),
paint: PaintDefaults { fill: Paint::solid(Color::WHITE), stroke: Paint::solid(Color::BLACK), stroke_width: 1.0 },
fill_active: true,
clipboard: vec![],
journal: vec![],
tool: drawcraft_tools::create("selection"),
untitled_counter: 0,
}
}
pub fn documents(&self) -> &[DocState] {
&self.docs
}
pub fn active_index(&self) -> Option<usize> {
self.active
}
pub fn active(&self) -> Option<&DocState> {
self.active.and_then(|i| self.docs.get(i))
}
pub fn active_mut(&mut self) -> Option<&mut DocState> {
self.active.and_then(|i| self.docs.get_mut(i))
}
pub fn doc(&self) -> Result<&DocState> {
self.active().ok_or(EngineError::NoDocument)
}
pub fn doc_mut(&mut self) -> Result<&mut DocState> {
self.active_mut().ok_or(EngineError::NoDocument)
}
pub fn set_active(&mut self, index: usize) -> bool {
if index < self.docs.len() {
self.active = Some(index);
true
} else {
false
}
}
pub fn next_untitled(&mut self) -> String {
self.untitled_counter += 1;
format!("Untitled-{}", self.untitled_counter)
}
/// Add a document and make it active.
pub fn add_document(&mut self, doc: Document, path: Option<String>) -> usize {
self.docs.push(DocState::new(doc, path));
let i = self.docs.len() - 1;
self.active = Some(i);
i
}
pub fn close_document(&mut self, index: usize) -> bool {
if index >= self.docs.len() {
return false;
}
self.docs.remove(index);
self.active = if self.docs.is_empty() { None } else { Some(index.min(self.docs.len() - 1)) };
true
}
/// Execute a command by id. This is THE entry point for every frontend.
pub fn execute(&mut self, id: &str, params: &Value) -> Result<Value> {
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?;
if let Err(why) = (spec.enabled)(self) {
return Err(EngineError::Disabled(id.to_string(), why));
}
let r = (spec.run)(self, params)?;
if spec.journal && self.active().is_none_or(|d| d.interaction.is_none()) {
self.journal.push((id.to_string(), params.clone()));
}
Ok(r)
}
/// Run `f` on a mutable copy of the active document. Outside an interaction this records one
/// undo step labelled `label`; inside one it just mutates (the interaction commits later).
pub fn edit<T>(&mut self, label: &str, f: impl FnOnce(&mut Document, &mut Selection) -> Result<T>) -> Result<T> {
let st = self.doc_mut()?;
let before = st.doc.clone();
let before_sel = st.selection.clone();
let doc = Arc::make_mut(&mut st.doc);
match f(doc, &mut st.selection) {
Ok(v) => {
st.selection.prune(&st.doc);
st.revision += 1;
if st.interaction.is_none() {
st.history.undo.push(HistoryEntry { label: label.to_string(), doc: before, selection: before_sel });
if st.history.undo.len() > st.history.limit {
st.history.undo.remove(0);
}
st.history.redo.clear();
}
Ok(v)
}
Err(e) => {
st.doc = before;
st.selection = before_sel;
Err(e)
}
}
}
/// Change only the selection (not an undo step).
pub fn select(&mut self, f: impl FnOnce(&Document, &mut Selection)) -> Result<()> {
let st = self.doc_mut()?;
f(&st.doc, &mut st.selection);
st.selection.prune(&st.doc);
st.revision += 1;
Ok(())
}
// ---------- interactions (live drags) ----------
pub fn begin_interaction(&mut self, label: &str) -> Result<()> {
let st = self.doc_mut()?;
if st.interaction.is_some() {
return Ok(());
}
st.interaction = Some(Interaction { label: label.to_string(), doc: st.doc.clone(), selection: st.selection.clone(), preview: None });
Ok(())
}
/// Re-apply `cmd` on top of the interaction snapshot (replacing the previous preview).
pub fn preview(&mut self, cmd: &str, params: &Value) -> Result<Value> {
{
let st = self.doc_mut()?;
let Some(it) = &st.interaction else { return Err(EngineError::Other("no interaction in progress".into())) };
st.doc = it.doc.clone();
st.selection = it.selection.clone();
}
let r = self.execute(cmd, params);
let st = self.doc_mut()?;
if let Some(it) = &mut st.interaction {
it.preview = Some((cmd.to_string(), params.clone()));
}
r
}
pub fn commit_interaction(&mut self) -> Result<()> {
let st = self.doc_mut()?;
let Some(it) = st.interaction.take() else { return Ok(()) };
let Some(preview) = it.preview else { return Ok(()) };
if !Arc::ptr_eq(&it.doc, &st.doc) {
st.history.undo.push(HistoryEntry { label: it.label, doc: it.doc, selection: it.selection });
st.history.redo.clear();
st.revision += 1;
}
if preview.0 == "object.transform" {
let m = cmd::matrix_param(&preview.1, "matrix");
let copy = preview.1.get("copy").and_then(Value::as_bool).unwrap_or(false);
if let Some(m) = m {
st.last_transform = Some((m, copy));
}
}
self.journal.push(preview);
Ok(())
}
pub fn cancel_interaction(&mut self) -> Result<()> {
let st = self.doc_mut()?;
if let Some(it) = st.interaction.take() {
st.doc = it.doc;
st.selection = it.selection;
st.revision += 1;
}
Ok(())
}
pub fn in_interaction(&self) -> bool {
self.active().is_some_and(|d| d.interaction.is_some())
}
/// Commands with enablement (for menus, palette, MCP `list_commands`).
pub fn commands(&self) -> Vec<CommandInfo> {
command_specs().iter().map(|c| c.info(self)).collect()
}
}
#[cfg(test)]
mod tests;
+307
View File
@@ -0,0 +1,307 @@
use serde_json::json;
use super::*;
use drawcraft_tools::{PointerEvent, PointerKind};
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
s
}
fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId {
let r = s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap();
NodeId(r["id"].as_u64().unwrap())
}
#[test]
fn create_and_undo_redo() {
let mut s = session();
let a = rect(&mut s, 10.0, 10.0, 100.0, 50.0);
assert!(s.doc().unwrap().doc.node(a).is_some());
assert_eq!(s.doc().unwrap().selection.objects, vec![a]);
s.execute("edit.undo", &json!({})).unwrap();
assert!(s.doc().unwrap().doc.node(a).is_none());
s.execute("edit.redo", &json!({})).unwrap();
assert!(s.doc().unwrap().doc.node(a).is_some());
assert!(s.execute("edit.redo", &json!({})).is_err());
}
#[test]
fn unknown_and_disabled() {
let mut s = Session::new();
assert!(matches!(s.execute("nope", &json!({})), Err(EngineError::UnknownCommand(_))));
assert!(matches!(s.execute("object.group", &json!({})), Err(EngineError::Disabled(..))));
}
#[test]
fn group_ungroup() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
let b = rect(&mut s, 20.0, 0.0, 10.0, 10.0);
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap());
let d = &s.doc().unwrap().doc;
assert_eq!(d.parent_of(a), Some(g));
assert_eq!(d.node(g).unwrap().children().unwrap().len(), 2);
s.execute("object.ungroup", &json!({})).unwrap();
let d = &s.doc().unwrap().doc;
assert!(d.node(g).is_none());
assert_eq!(d.parent_of(a), d.layers.first().map(|l| l.id));
assert_eq!(s.doc().unwrap().selection.len(), 2);
}
#[test]
fn arrange_order() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
let b = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
let c = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
s.execute("object.arrange.bringToFront", &json!({})).unwrap();
let order = |s: &Session| s.doc().unwrap().doc.layers[0].children().unwrap().iter().map(|n| n.id).collect::<Vec<_>>();
assert_eq!(order(&s), vec![b, c, a]);
s.execute("object.arrange.sendBackward", &json!({})).unwrap();
assert_eq!(order(&s), vec![b, a, c]);
s.execute("object.arrange.sendToBack", &json!({})).unwrap();
assert_eq!(order(&s), vec![a, b, c]);
s.execute("object.arrange.bringForward", &json!({})).unwrap();
assert_eq!(order(&s), vec![b, a, c]);
}
#[test]
fn transform_and_again() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
s.execute("object.move", &json!({"dx": 5, "dy": 0})).unwrap();
s.execute("object.transformAgain", &json!({})).unwrap();
let b = s.doc().unwrap().doc.node(a).unwrap().geometric_bounds().unwrap();
assert_eq!(b.x0, 10.0);
s.execute("object.move", &json!({"dx": 0, "dy": 20, "copy": true})).unwrap();
assert_eq!(s.doc().unwrap().doc.layers[0].children().unwrap().len(), 2);
}
#[test]
fn rotate_and_scale() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 20.0, 10.0);
s.execute("object.rotate", &json!({"angle": 90})).unwrap();
let b = s.doc().unwrap().doc.node(a).unwrap().geometric_bounds().unwrap();
assert!((b.width() - 10.0).abs() < 1e-9 && (b.height() - 20.0).abs() < 1e-9);
s.execute("object.scale", &json!({"sx": 200})).unwrap();
let b = s.doc().unwrap().doc.node(a).unwrap().geometric_bounds().unwrap();
assert!((b.width() - 20.0).abs() < 1e-9);
// stroke scaled with the object
assert_eq!(s.doc().unwrap().doc.node(a).unwrap().appearance.stroke_width(), 2.0);
}
#[test]
fn copy_paste_front_back() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
let _b = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
s.execute("edit.copy", &json!({})).unwrap();
let r = s.execute("edit.pasteInFront", &json!({})).unwrap();
let c = NodeId(r["ids"][0].as_u64().unwrap());
let d = &s.doc().unwrap().doc;
assert_eq!(d.index_path(c).unwrap()[1], 1);
s.execute("edit.paste", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc.layers[0].children().unwrap().len(), 4);
s.execute("edit.cut", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc.layers[0].children().unwrap().len(), 3);
}
#[test]
fn fill_and_stroke_commands() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
s.execute("stroke.set", &json!({"weight": 4, "cap": "round", "dash": [3, 2]})).unwrap();
let n = s.doc().unwrap().doc.node(a).unwrap().clone();
assert_eq!(n.appearance.fill_paint().color().unwrap().to_hex(), "#ff0000");
let st = n.appearance.stroke().unwrap();
assert_eq!(st.width, 4.0);
assert_eq!(st.cap, drawcraft_doc::LineCap::Round);
assert_eq!(st.dash.as_ref().unwrap().pattern, vec![3.0, 2.0]);
s.execute("paint.swap", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc.node(a).unwrap().appearance.stroke_paint().color().unwrap().to_hex(), "#ff0000");
s.execute("paint.setFill", &json!({"swatch": "Cyan"})).unwrap();
s.execute("paint.setFill", &json!({"gradient": {"kind": "radial"}})).unwrap();
}
#[test]
fn select_same_fill() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
s.execute("paint.setFill", &json!({"color": "#00ff00"})).unwrap();
let b = rect(&mut s, 20.0, 0.0, 10.0, 10.0);
let _c = {
s.execute("paint.setFill", &json!({"color": "#0000ff"})).unwrap();
rect(&mut s, 40.0, 0.0, 10.0, 10.0)
};
// Setting a fill also recolours the current selection (b), so b and c are blue, a green.
s.execute("select.set", &json!({"ids": [b.0]})).unwrap();
let r = s.execute("select.same.fillColor", &json!({})).unwrap();
assert_eq!(r["count"], 2);
assert!(!s.doc().unwrap().selection.contains(a));
}
#[test]
fn layers_and_lock_hide() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
let l2 = NodeId(s.execute("layer.new", &json!({"name": "Ink"})).unwrap()["id"].as_u64().unwrap());
let b = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
assert_eq!(s.doc().unwrap().doc.layer_of(b), Some(l2));
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
s.execute("object.lock", &json!({})).unwrap();
s.execute("select.all", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().selection.objects, vec![b]);
s.execute("object.unlockAll", &json!({})).unwrap();
s.execute("object.hide", &json!({})).unwrap();
s.execute("object.showAll", &json!({})).unwrap();
assert!(s.doc().unwrap().doc.node(a).unwrap().visible);
s.execute("layer.delete", &json!({"id": l2.0})).unwrap();
assert!(s.doc().unwrap().doc.node(b).is_none());
}
#[test]
fn clipping_and_compound() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
let b = rect(&mut s, 25.0, 25.0, 50.0, 50.0);
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
let c = s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap();
assert_eq!(s.doc().unwrap().doc.node(NodeId(c)).unwrap().kind_label(), "Compound Path");
s.execute("object.compoundPath.release", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().selection.len(), 2);
let g = s.execute("object.clippingMask.make", &json!({})).unwrap()["id"].as_u64().unwrap();
assert_eq!(s.doc().unwrap().doc.node(NodeId(g)).unwrap().kind_label(), "Clip Group");
}
#[test]
fn align_left() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
let b = rect(&mut s, 50.0, 30.0, 10.0, 10.0);
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
s.execute("object.align", &json!({"horizontal": "left"})).unwrap();
assert_eq!(s.doc().unwrap().doc.node(b).unwrap().geometric_bounds().unwrap().x0, 0.0);
}
#[test]
fn selection_tool_drag_is_one_undo_step() {
let mut s = session();
let a = rect(&mut s, 100.0, 100.0, 100.0, 100.0);
let undo_before = s.doc().unwrap().history.undo.len();
let v = ViewInfo::default();
s.select_tool("selection", v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Down, 150.0, 150.0), v).unwrap();
for x in [155.0, 160.0, 170.0, 180.0] {
s.pointer(&PointerEvent::new(PointerKind::Drag, x, 150.0), v).unwrap();
}
s.pointer(&PointerEvent::new(PointerKind::Up, 180.0, 150.0), v).unwrap();
let b = s.doc().unwrap().doc.node(a).unwrap().geometric_bounds().unwrap();
assert_eq!(b.x0, 130.0);
assert_eq!(s.doc().unwrap().history.undo.len(), undo_before + 1);
assert_eq!(s.journal.last().unwrap().0, "object.transform");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc.node(a).unwrap().geometric_bounds().unwrap().x0, 100.0);
}
#[test]
fn rectangle_tool_draws() {
let mut s = session();
let v = ViewInfo::default();
s.select_tool("rectangle", v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Down, 10.0, 10.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, 60.0, 40.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, 110.0, 60.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, 110.0, 60.0), v).unwrap();
let d = &s.doc().unwrap().doc;
assert_eq!(d.layers[0].children().unwrap().len(), 1);
let b = d.layers[0].children().unwrap()[0].geometric_bounds().unwrap();
assert_eq!((b.width(), b.height()), (100.0, 50.0));
assert_eq!(s.doc().unwrap().history.undo.len(), 1);
}
#[test]
fn pen_tool_draws_closed_path() {
let mut s = session();
let v = ViewInfo::default();
s.select_tool("pen", v).unwrap();
for (x, y) in [(10.0, 10.0), (100.0, 10.0), (100.0, 100.0)] {
s.pointer(&PointerEvent::new(PointerKind::Down, x, y), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, x, y), v).unwrap();
}
s.pointer(&PointerEvent::new(PointerKind::Down, 10.0, 10.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, 10.0, 10.0), v).unwrap();
let d = &s.doc().unwrap().doc;
let n = &d.layers[0].children().unwrap()[0];
let p = n.path_data().unwrap();
assert_eq!(p.anchor_count(), 3);
assert!(p.is_closed());
}
#[test]
fn direct_selection_moves_one_anchor() {
let mut s = session();
let a = rect(&mut s, 100.0, 100.0, 100.0, 100.0);
s.execute("select.none", &json!({})).unwrap();
let v = ViewInfo::default();
s.select_tool("directSelection", v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 100.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, 90.0, 90.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, 90.0, 90.0), v).unwrap();
let p = s.doc().unwrap().doc.node(a).unwrap().path_data().unwrap().clone();
assert_eq!(p.subpaths[0].anchors[0].p, drawcraft_geom::Point::new(90.0, 90.0));
assert_eq!(p.subpaths[0].anchors[1].p, drawcraft_geom::Point::new(200.0, 100.0));
}
#[test]
fn text_create_has_bounds() {
let mut s = session();
let r = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Hello DrawCraft", "size": 24})).unwrap();
let n = s.doc().unwrap().doc.node(NodeId(r["id"].as_u64().unwrap())).unwrap().clone();
let b = n.geometric_bounds().unwrap();
assert!(b.width() > 100.0, "{b:?}");
}
#[test]
fn inspect_lists_everything() {
let mut s = session();
rect(&mut s, 0.0, 0.0, 10.0, 10.0);
let v = s.execute("document.inspect", &json!({})).unwrap();
assert_eq!(v["layers"][0]["children"][0]["kind"], "Rectangle");
assert!(s.commands().len() > 100);
}
#[test]
fn every_command_has_unique_id_and_doc() {
let mut ids: Vec<&str> = command_specs().iter().map(|c| c.id).collect();
let n = ids.len();
ids.sort();
ids.dedup();
assert_eq!(ids.len(), n);
assert!(command_specs().iter().all(|c| !c.params.is_empty() && !c.label.is_empty()));
}
#[test]
fn every_command_survives_empty_params() {
// Robustness: no command may panic on {} (with and without a selection).
for sel in [false, true] {
for c in command_specs() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
rect(&mut s, 20.0, 0.0, 10.0, 10.0);
s.execute("edit.copy", &json!({})).ok();
if sel {
s.execute("select.all", &json!({})).unwrap();
} else {
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
}
let _ = s.execute(c.id, &json!({}));
}
}
}
+119
View File
@@ -0,0 +1,119 @@
//! Hosting the active tool: pointer/key events → actions → commands.
use drawcraft_geom::Point;
use drawcraft_tools::{Action, Cursor, Mods, Overlay, PointerEvent, ToolContext, ToolKey};
use serde_json::Value;
use crate::{Result, Session};
/// View state the tools need from the frontend.
#[derive(Clone, Copy, Debug)]
pub struct ViewInfo {
pub zoom: f64,
pub outline: bool,
pub smart_guides: bool,
pub snap_to_grid: bool,
pub show_bbox: bool,
}
impl Default for ViewInfo {
fn default() -> Self {
Self { zoom: 1.0, outline: false, smart_guides: true, snap_to_grid: false, show_bbox: true }
}
}
/// Requests from tools that only the frontend can fulfil.
#[derive(Clone, Debug, PartialEq)]
pub enum UiRequest {
Dialog(String, Value),
SwitchTool(String),
}
impl Session {
pub fn tool_id(&self) -> &'static str {
self.tool.id()
}
/// Switch tools (finishing any pending tool work first).
pub fn select_tool(&mut self, id: &str, view: ViewInfo) -> Result<()> {
if self.tool.id() == id {
return Ok(());
}
let acts = self.with_cx(view, |t, cx| t.deactivate(cx));
self.apply_actions(acts)?;
self.tool = drawcraft_tools::create(id);
Ok(())
}
fn with_cx<R>(&mut self, view: ViewInfo, f: impl FnOnce(&mut dyn drawcraft_tools::Tool, &ToolContext) -> R) -> R
where
R: Default,
{
let Some(st) = self.active.and_then(|i| self.docs.get(i)) else { return R::default() };
let cx = ToolContext {
doc: &st.doc,
selection: &st.selection,
zoom: view.zoom,
isolation: st.isolation,
paint: &self.paint,
outline: view.outline,
smart_guides: view.smart_guides,
snap_to_grid: view.snap_to_grid,
show_bbox: view.show_bbox,
};
f(self.tool.as_mut(), &cx)
}
/// Feed a pointer event to the active tool. Returns requests for the UI.
pub fn pointer(&mut self, ev: &PointerEvent, view: ViewInfo) -> Result<Vec<UiRequest>> {
let acts = self.with_cx(view, |t, cx| t.pointer(cx, ev));
self.apply_actions(acts)
}
pub fn tool_key(&mut self, key: ToolKey, mods: Mods, view: ViewInfo) -> Result<Vec<UiRequest>> {
let acts = self.with_cx(view, |t, cx| t.key(cx, key, mods));
self.apply_actions(acts)
}
pub fn tool_busy(&self) -> bool {
self.tool.busy()
}
pub fn overlays(&mut self, view: ViewInfo) -> Vec<Overlay> {
self.with_cx(view, |t, cx| t.overlays(cx))
}
pub fn cursor(&mut self, p: Point, mods: Mods, view: ViewInfo) -> Cursor {
self.with_cx(view, |t, cx| t.cursor(cx, p, mods))
}
pub fn tool_options(&self) -> Value {
self.tool.options()
}
pub fn set_tool_option(&mut self, key: &str, v: &Value) {
self.tool.set_option(key, v);
}
/// Apply tool actions. Errors from previews are reported but keep the interaction alive.
pub fn apply_actions(&mut self, acts: Vec<Action>) -> Result<Vec<UiRequest>> {
let mut ui = vec![];
for a in acts {
match a {
Action::Begin(label) => self.begin_interaction(&label)?,
Action::Preview(cmd, p) => {
if let Err(e) = self.preview(&cmd, &p) {
log::warn!("preview {cmd}: {e}");
}
}
Action::Commit => self.commit_interaction()?,
Action::Cancel => self.cancel_interaction()?,
Action::Exec(cmd, p) => {
self.execute(&cmd, &p)?;
}
Action::Dialog(k, p) => ui.push(UiRequest::Dialog(k, p)),
Action::SwitchTool(t) => ui.push(UiRequest::SwitchTool(t)),
}
}
Ok(ui)
}
}
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "drawcraft-format"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "DrawCraft native .drawcraft format"
[lints]
workspace = true
[dependencies]
drawcraft-doc = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
+2
View File
@@ -0,0 +1,2 @@
//! DrawCraft native .drawcraft format
#![forbid(unsafe_code)]
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "drawcraft-geom"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "DrawCraft geom"
[lints]
workspace = true
[dependencies]
kurbo = { workspace = true }
serde = { workspace = true }
thiserror = { workspace = true }
[dev-dependencies]
proptest = { workspace = true }
serde_json = { workspace = true }
+93
View File
@@ -0,0 +1,93 @@
//! Hit testing primitives.
use kurbo::{BezPath, ParamCurveNearest, Point, Rect, Shape};
use crate::path::{FillRule, PathData};
/// Is `p` inside the filled area of `path` under `rule`? Open subpaths are implicitly closed (as when filled).
pub fn fill_contains(path: &BezPath, rule: FillRule, p: Point) -> bool {
let w = path.winding(p);
match rule {
FillRule::NonZero => w != 0,
FillRule::EvenOdd => w % 2 != 0,
}
}
/// Distance from `p` to the nearest point on the path outline.
pub fn distance_to_outline(path: &BezPath, p: Point) -> f64 {
path.segments().map(|s| s.nearest(p, 1e-6).distance_sq).fold(f64::INFINITY, f64::min).sqrt()
}
/// Is `p` within `tol` of the path's stroke of width `width`?
pub fn stroke_contains(path: &BezPath, width: f64, tol: f64, p: Point) -> bool {
let quick = path.bounding_box().inflate(width / 2.0 + tol, width / 2.0 + tol);
quick.contains(p) && distance_to_outline(path, p) <= width / 2.0 + tol
}
/// Does the path intersect (or lie inside) the rect? Used for marquee selection.
pub fn intersects_rect(path: &PathData, r: Rect) -> bool {
let Some(b) = path.bounds() else { return false };
if b.intersect(r).area() <= 0.0 && !(b.width() == 0.0 || b.height() == 0.0) {
return false;
}
// Any anchor inside?
if path.anchors().any(|(_, _, a)| r.contains(a.p)) {
return true;
}
// Any segment crossing the rect edges?
let edges = [
kurbo::Line::new((r.x0, r.y0), (r.x1, r.y0)),
kurbo::Line::new((r.x1, r.y0), (r.x1, r.y1)),
kurbo::Line::new((r.x1, r.y1), (r.x0, r.y1)),
kurbo::Line::new((r.x0, r.y1), (r.x0, r.y0)),
];
let bp = path.to_bezpath();
for seg in bp.segments() {
for e in &edges {
if !seg.intersect_line(*e).is_empty() {
return true;
}
}
}
// Rect entirely inside a closed shape counts as touching it.
path.is_closed() && fill_contains(&bp, FillRule::NonZero, r.center())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::shapes;
#[test]
fn fill_and_stroke() {
let p = shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)).to_bezpath();
assert!(fill_contains(&p, FillRule::NonZero, Point::new(5.0, 5.0)));
assert!(!fill_contains(&p, FillRule::NonZero, Point::new(15.0, 5.0)));
assert!(stroke_contains(&p, 2.0, 0.0, Point::new(10.5, 5.0)));
assert!(!stroke_contains(&p, 2.0, 0.0, Point::new(12.0, 5.0)));
}
#[test]
fn evenodd_hole() {
let mut outer = shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0));
outer.subpaths.extend(shapes::rectangle(Rect::new(3.0, 3.0, 7.0, 7.0)).subpaths);
let bp = outer.to_bezpath();
assert!(!fill_contains(&bp, FillRule::EvenOdd, Point::new(5.0, 5.0)));
assert!(fill_contains(&bp, FillRule::NonZero, Point::new(5.0, 5.0)));
assert!(fill_contains(&bp, FillRule::EvenOdd, Point::new(1.0, 5.0)));
}
#[test]
fn marquee() {
let p = shapes::ellipse(Rect::new(0.0, 0.0, 10.0, 10.0));
assert!(intersects_rect(&p, Rect::new(-1.0, 4.0, 1.0, 6.0)));
assert!(!intersects_rect(&p, Rect::new(20.0, 20.0, 30.0, 30.0)));
// corner region of the bbox that misses the ellipse outline
assert!(!intersects_rect(&p, Rect::new(0.0, 0.0, 0.5, 0.5)));
// fully inside
assert!(intersects_rect(&p, Rect::new(4.0, 4.0, 6.0, 6.0)));
// crossing a line
let l = shapes::line(Point::new(0.0, 0.0), Point::new(10.0, 10.0));
assert!(intersects_rect(&l, Rect::new(4.0, 0.0, 6.0, 10.0)));
}
}
+101
View File
@@ -0,0 +1,101 @@
//! DrawCraft geometry kernel.
//!
//! The editable path model is anchor based (like every Illustrator tool thinks about paths):
//! a [`PathData`] is a list of [`SubPath`]s, each a list of [`Anchor`]s with absolute in/out
//! handle positions. Rendering and algorithms convert to [`kurbo::BezPath`].
//!
//! Coordinates are document points (1/72 in), y pointing down.
#![forbid(unsafe_code)]
pub mod hit;
pub mod path;
pub mod shapes;
pub mod snap;
pub use kurbo;
pub use kurbo::{Affine, BezPath, CubicBez, Line, ParamCurve, PathEl, PathSeg, Point, Rect, Shape, Size, Vec2};
pub use path::{Anchor, AnchorKind, FillRule, PathData, SubPath};
/// Tolerance used when comparing handle positions to anchor positions.
pub const EPS: f64 = 1e-9;
/// Union of two optional rects.
pub fn union_opt(a: Option<Rect>, b: Option<Rect>) -> Option<Rect> {
match (a, b) {
(Some(a), Some(b)) => Some(a.union(b)),
(a, None) => a,
(None, b) => b,
}
}
/// Normalize an angle in degrees to (-180, 180].
pub fn normalize_deg(mut a: f64) -> f64 {
a %= 360.0;
if a <= -180.0 {
a += 360.0;
} else if a > 180.0 {
a -= 360.0;
}
a
}
/// Snap a vector to the nearest multiple of `step_deg` (used for Shift-constrained drags).
pub fn constrain_angle(v: Vec2, step_deg: f64) -> Vec2 {
let len = v.hypot();
if len < EPS {
return v;
}
let step = step_deg.to_radians();
let a = (v.y.atan2(v.x) / step).round() * step;
Vec2::new(a.cos() * len, a.sin() * len)
}
/// Rect from two corner points in any order.
pub fn rect_from_points(a: Point, b: Point) -> Rect {
Rect::from_points(a, b)
}
/// The 9 reference points of a rect (Illustrator's reference-point locator), row-major from top-left.
pub fn reference_point(r: Rect, index: usize) -> Point {
let xs = [r.x0, r.center().x, r.x1];
let ys = [r.y0, r.center().y, r.y1];
Point::new(xs[index % 3], ys[(index / 3).min(2)])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn angle_normalization() {
assert_eq!(normalize_deg(190.0), -170.0);
assert_eq!(normalize_deg(-190.0), 170.0);
assert_eq!(normalize_deg(180.0), 180.0);
assert_eq!(normalize_deg(720.0), 0.0);
}
#[test]
fn constrain_45() {
let v = constrain_angle(Vec2::new(10.0, 1.0), 45.0);
assert!((v.y).abs() < 1e-9);
let v = constrain_angle(Vec2::new(10.0, 9.0), 45.0);
assert!((v.x - v.y).abs() < 1e-9);
}
#[test]
fn reference_points() {
let r = Rect::new(0.0, 0.0, 10.0, 20.0);
assert_eq!(reference_point(r, 0), Point::new(0.0, 0.0));
assert_eq!(reference_point(r, 4), Point::new(5.0, 10.0));
assert_eq!(reference_point(r, 8), Point::new(10.0, 20.0));
}
#[test]
fn union_options() {
let a = Rect::new(0.0, 0.0, 1.0, 1.0);
let b = Rect::new(2.0, 2.0, 3.0, 3.0);
assert_eq!(union_opt(Some(a), Some(b)), Some(Rect::new(0.0, 0.0, 3.0, 3.0)));
assert_eq!(union_opt(None, Some(b)), Some(b));
assert_eq!(union_opt(None, None), None);
}
}
+440
View File
@@ -0,0 +1,440 @@
//! The anchor-based editable path model.
use kurbo::{Affine, BezPath, CubicBez, ParamCurve, ParamCurveNearest, PathEl, Point, Rect, Shape};
use serde::{Deserialize, Serialize};
use crate::EPS;
/// Fill rule for paths and compound paths.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum FillRule {
/// Illustrator's default for compound paths created from overlapping shapes.
#[default]
NonZero,
EvenOdd,
}
/// How an anchor's handles behave when edited.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum AnchorKind {
/// Handles move independently (or there are none).
#[default]
Corner,
/// Handles stay collinear (dragging one rotates the other).
Smooth,
}
/// One anchor point with absolute handle positions. A handle equal to `p` means "no handle".
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct Anchor {
pub p: Point,
#[serde(rename = "in")]
pub h_in: Point,
#[serde(rename = "out")]
pub h_out: Point,
#[serde(default)]
pub kind: AnchorKind,
}
impl Anchor {
/// A corner anchor without handles.
pub fn corner(p: Point) -> Self {
Self { p, h_in: p, h_out: p, kind: AnchorKind::Corner }
}
/// A smooth anchor with a symmetric out handle `out` (the in handle is mirrored).
pub fn smooth(p: Point, out: Point) -> Self {
Self { p, h_in: p - (out - p), h_out: out, kind: AnchorKind::Smooth }
}
pub fn with_handles(p: Point, h_in: Point, h_out: Point) -> Self {
let kind =
if is_collinear(p, h_in, h_out) && h_in.distance(p) > EPS && h_out.distance(p) > EPS { AnchorKind::Smooth } else { AnchorKind::Corner };
Self { p, h_in, h_out, kind }
}
pub fn has_in(&self) -> bool {
self.h_in.distance(self.p) > EPS
}
pub fn has_out(&self) -> bool {
self.h_out.distance(self.p) > EPS
}
pub fn transform(&self, a: Affine) -> Self {
Self { p: a * self.p, h_in: a * self.h_in, h_out: a * self.h_out, kind: self.kind }
}
pub fn translate(&mut self, d: kurbo::Vec2) {
self.p += d;
self.h_in += d;
self.h_out += d;
}
/// Swap handles (used when reversing path direction).
pub fn reversed(&self) -> Self {
Self { p: self.p, h_in: self.h_out, h_out: self.h_in, kind: self.kind }
}
/// Remove both handles (convert to a sharp corner).
pub fn retract(&mut self) {
self.h_in = self.p;
self.h_out = self.p;
self.kind = AnchorKind::Corner;
}
}
fn is_collinear(p: Point, a: Point, b: Point) -> bool {
let va = a - p;
let vb = b - p;
let cross = va.cross(vb);
cross.abs() <= 1e-6 * va.hypot().max(1.0) * vb.hypot().max(1.0) && va.dot(vb) < 0.0
}
/// An open or closed run of anchors.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct SubPath {
pub anchors: Vec<Anchor>,
#[serde(default)]
pub closed: bool,
}
impl SubPath {
pub fn new(anchors: Vec<Anchor>, closed: bool) -> Self {
Self { anchors, closed }
}
/// Polyline from points.
pub fn polyline(points: &[Point], closed: bool) -> Self {
Self { anchors: points.iter().map(|&p| Anchor::corner(p)).collect(), closed }
}
/// Number of segments (a closed path has one more, back to the start).
pub fn segment_count(&self) -> usize {
let n = self.anchors.len();
if n < 2 {
0
} else if self.closed {
n
} else {
n - 1
}
}
/// Segment `i` as a cubic (lines are cubics with handles on the anchors).
pub fn segment(&self, i: usize) -> CubicBez {
let n = self.anchors.len();
let a = &self.anchors[i % n];
let b = &self.anchors[(i + 1) % n];
CubicBez::new(a.p, a.h_out, b.h_in, b.p)
}
/// True if segment `i` is a straight line.
pub fn segment_is_line(&self, i: usize) -> bool {
let n = self.anchors.len();
let a = &self.anchors[i % n];
let b = &self.anchors[(i + 1) % n];
!a.has_out() && !b.has_in()
}
pub fn to_bezpath_into(&self, out: &mut BezPath) {
let Some(first) = self.anchors.first() else { return };
out.move_to(first.p);
for i in 0..self.segment_count() {
let c = self.segment(i);
if self.segment_is_line(i) {
out.line_to(c.p3);
} else {
out.curve_to(c.p1, c.p2, c.p3);
}
}
if self.closed {
out.close_path();
}
}
pub fn reverse(&mut self) {
self.anchors.reverse();
for a in &mut self.anchors {
*a = a.reversed();
}
}
/// Split segment `seg` at parameter `t`, inserting a new anchor. Returns the new anchor index.
pub fn insert_anchor(&mut self, seg: usize, t: f64) -> usize {
let n = self.anchors.len();
let c = self.segment(seg);
let line = self.segment_is_line(seg);
let (l, r) = (c.subsegment(0.0..t), c.subsegment(t..1.0));
let i0 = seg % n;
let i1 = (seg + 1) % n;
let mid = if line {
Anchor::corner(l.p3)
} else {
self.anchors[i0].h_out = l.p1;
self.anchors[i1].h_in = r.p2;
Anchor { p: l.p3, h_in: l.p2, h_out: r.p1, kind: AnchorKind::Smooth }
};
self.anchors.insert(seg + 1, mid);
seg + 1
}
/// Signed area via the shoelace formula on the Bézier path (positive = clockwise in y-down).
pub fn area(&self) -> f64 {
let mut bp = BezPath::new();
self.to_bezpath_into(&mut bp);
bp.area()
}
}
/// A (possibly multi-subpath) path.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct PathData {
pub subpaths: Vec<SubPath>,
}
impl PathData {
pub fn new(subpaths: Vec<SubPath>) -> Self {
Self { subpaths }
}
pub fn single(sp: SubPath) -> Self {
Self { subpaths: vec![sp] }
}
pub fn is_empty(&self) -> bool {
self.subpaths.iter().all(|s| s.anchors.is_empty())
}
pub fn anchor_count(&self) -> usize {
self.subpaths.iter().map(|s| s.anchors.len()).sum()
}
pub fn to_bezpath(&self) -> BezPath {
let mut bp = BezPath::new();
for sp in &self.subpaths {
sp.to_bezpath_into(&mut bp);
}
bp
}
/// Convert a kurbo path to anchors. Quadratics are elevated to cubics.
pub fn from_bezpath(bp: &BezPath) -> Self {
let mut subpaths: Vec<SubPath> = Vec::new();
let mut cur: Option<SubPath> = None;
let mut last = Point::ZERO;
for el in bp.elements() {
match *el {
PathEl::MoveTo(p) => {
if let Some(sp) = cur.take()
&& !sp.anchors.is_empty()
{
subpaths.push(sp);
}
cur = Some(SubPath { anchors: vec![Anchor::corner(p)], closed: false });
last = p;
}
PathEl::LineTo(p) => {
let sp = cur.get_or_insert_with(|| SubPath { anchors: vec![Anchor::corner(last)], closed: false });
sp.anchors.push(Anchor::corner(p));
last = p;
}
PathEl::QuadTo(q, p) => {
let c1 = last + (q - last) * (2.0 / 3.0);
let c2 = p + (q - p) * (2.0 / 3.0);
push_curve(&mut cur, last, c1, c2, p);
last = p;
}
PathEl::CurveTo(c1, c2, p) => {
push_curve(&mut cur, last, c1, c2, p);
last = p;
}
PathEl::ClosePath => {
if let Some(mut sp) = cur.take() {
// Merge a closing anchor that duplicates the start.
if sp.anchors.len() > 1 {
let first = sp.anchors[0].p;
let lastp = sp.anchors.last().unwrap().p;
if first.distance(lastp) < 1e-7 {
let l = sp.anchors.pop().unwrap();
sp.anchors[0].h_in = l.h_in;
}
}
sp.closed = true;
for a in &mut sp.anchors {
*a = Anchor::with_handles(a.p, a.h_in, a.h_out);
}
last = sp.anchors.first().map(|a| a.p).unwrap_or(last);
subpaths.push(sp);
}
}
}
}
if let Some(mut sp) = cur
&& !sp.anchors.is_empty()
{
for a in &mut sp.anchors {
*a = Anchor::with_handles(a.p, a.h_in, a.h_out);
}
subpaths.push(sp);
}
Self { subpaths }
}
/// Tight geometric bounds (curve extrema, not control points).
pub fn bounds(&self) -> Option<Rect> {
if self.is_empty() {
return None;
}
let bp = self.to_bezpath();
if self.anchor_count() == 1 {
let p = self.subpaths.iter().find_map(|s| s.anchors.first()).unwrap().p;
return Some(Rect::from_points(p, p));
}
Some(bp.bounding_box())
}
/// Bounds including control handles (used for quick culling).
pub fn control_bounds(&self) -> Option<Rect> {
let mut it = self.subpaths.iter().flat_map(|s| s.anchors.iter()).flat_map(|a| [a.p, a.h_in, a.h_out]);
let first = it.next()?;
Some(it.fold(Rect::from_points(first, first), |r, p| r.union_pt(p)))
}
pub fn transform(&mut self, a: Affine) {
for sp in &mut self.subpaths {
for an in &mut sp.anchors {
*an = an.transform(a);
}
}
}
pub fn transformed(&self, a: Affine) -> Self {
let mut c = self.clone();
c.transform(a);
c
}
pub fn is_closed(&self) -> bool {
!self.subpaths.is_empty() && self.subpaths.iter().all(|s| s.closed)
}
/// Nearest point on any segment: (subpath, segment, t, point, distance).
pub fn nearest(&self, p: Point) -> Option<(usize, usize, f64, Point, f64)> {
let mut best: Option<(usize, usize, f64, Point, f64)> = None;
for (si, sp) in self.subpaths.iter().enumerate() {
for seg in 0..sp.segment_count() {
let c = sp.segment(seg);
let n = c.nearest(p, 1e-6);
let q = c.eval(n.t);
let d = n.distance_sq.sqrt();
if best.is_none_or(|b| d < b.4) {
best = Some((si, seg, n.t, q, d));
}
}
}
best
}
/// Iterate (subpath index, anchor index, anchor).
pub fn anchors(&self) -> impl Iterator<Item = (usize, usize, &Anchor)> {
self.subpaths.iter().enumerate().flat_map(|(si, s)| s.anchors.iter().enumerate().map(move |(ai, a)| (si, ai, a)))
}
pub fn anchor_mut(&mut self, si: usize, ai: usize) -> Option<&mut Anchor> {
self.subpaths.get_mut(si)?.anchors.get_mut(ai)
}
pub fn reverse(&mut self) {
for sp in &mut self.subpaths {
sp.reverse();
}
}
/// Total length of all segments.
pub fn length(&self) -> f64 {
self.to_bezpath().segments().map(|s| kurbo::ParamCurveArclen::arclen(&s, 1e-6)).sum()
}
}
fn push_curve(cur: &mut Option<SubPath>, last: Point, c1: Point, c2: Point, p: Point) {
let sp = cur.get_or_insert_with(|| SubPath { anchors: vec![Anchor::corner(last)], closed: false });
if let Some(a) = sp.anchors.last_mut() {
a.h_out = c1;
}
sp.anchors.push(Anchor { p, h_in: c2, h_out: p, kind: AnchorKind::Corner });
}
#[cfg(test)]
mod tests {
use super::*;
fn square() -> PathData {
PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0), Point::new(10.0, 10.0), Point::new(0.0, 10.0)], true))
}
#[test]
fn square_bounds_and_area() {
let p = square();
assert_eq!(p.bounds(), Some(Rect::new(0.0, 0.0, 10.0, 10.0)));
assert!((p.subpaths[0].area().abs() - 100.0).abs() < 1e-9);
assert_eq!(p.subpaths[0].segment_count(), 4);
}
#[test]
fn bezpath_roundtrip_polyline() {
let p = square();
let bp = p.to_bezpath();
let back = PathData::from_bezpath(&bp);
assert_eq!(back.subpaths.len(), 1);
assert_eq!(back.subpaths[0].anchors.len(), 4);
assert!(back.subpaths[0].closed);
assert_eq!(back.bounds(), p.bounds());
}
#[test]
fn bezpath_roundtrip_curves() {
let c = kurbo::Circle::new((50.0, 50.0), 20.0);
let bp = c.to_path(1e-3);
let pd = PathData::from_bezpath(&bp);
assert_eq!(pd.subpaths.len(), 1);
assert!(pd.subpaths[0].closed);
let b = pd.bounds().unwrap();
assert!((b.width() - 40.0).abs() < 1e-3);
// All anchors of a circle are smooth.
assert!(pd.subpaths[0].anchors.iter().all(|a| a.kind == AnchorKind::Smooth));
}
#[test]
fn insert_anchor_on_line_keeps_shape() {
let mut p = square();
let before = p.bounds();
let idx = p.subpaths[0].insert_anchor(0, 0.5);
assert_eq!(idx, 1);
assert_eq!(p.subpaths[0].anchors[1].p, Point::new(5.0, 0.0));
assert_eq!(p.bounds(), before);
assert_eq!(p.anchor_count(), 5);
}
#[test]
fn insert_anchor_on_curve_keeps_shape() {
let bp = kurbo::Circle::new((0.0, 0.0), 10.0).to_path(1e-3);
let mut pd = PathData::from_bezpath(&bp);
let area0 = pd.subpaths[0].area();
pd.subpaths[0].insert_anchor(1, 0.3);
assert!((pd.subpaths[0].area() - area0).abs() < 1e-6);
}
#[test]
fn nearest_on_edge() {
let p = square();
let (_, seg, _, q, d) = p.nearest(Point::new(5.0, -3.0)).unwrap();
assert_eq!(seg, 0);
assert!((q.x - 5.0).abs() < 1e-6 && q.y.abs() < 1e-6);
assert!((d - 3.0).abs() < 1e-6);
}
#[test]
fn reverse_flips_area_sign() {
let mut p = square();
let a = p.subpaths[0].area();
p.reverse();
assert!((p.subpaths[0].area() + a).abs() < 1e-9);
}
#[test]
fn transform_moves_bounds() {
let p = square().transformed(Affine::translate((5.0, 5.0)));
assert_eq!(p.bounds(), Some(Rect::new(5.0, 5.0, 15.0, 15.0)));
}
#[test]
fn smooth_detection() {
let a = Anchor::with_handles(Point::new(0.0, 0.0), Point::new(-1.0, 0.0), Point::new(2.0, 0.0));
assert_eq!(a.kind, AnchorKind::Smooth);
let c = Anchor::with_handles(Point::new(0.0, 0.0), Point::new(-1.0, 0.0), Point::new(0.0, 2.0));
assert_eq!(c.kind, AnchorKind::Corner);
}
#[test]
fn length_of_square() {
assert!((square().length() - 40.0).abs() < 1e-6);
}
#[test]
fn serde_roundtrip() {
let p = square();
let s = serde_json::to_string(&p).unwrap();
let back: PathData = serde_json::from_str(&s).unwrap();
assert_eq!(p, back);
}
}
+246
View File
@@ -0,0 +1,246 @@
//! Shape generators matching Illustrator's anchor layouts (so later edits behave the same).
use std::f64::consts::{PI, TAU};
use kurbo::{Point, Rect, Vec2};
use crate::path::{Anchor, AnchorKind, PathData, SubPath};
/// Circle-approximation handle ratio for a quarter arc.
pub const KAPPA: f64 = 0.552_284_749_830_793_6;
/// Rectangle with 4 corner anchors, clockwise from the top-left.
pub fn rectangle(r: Rect) -> PathData {
let r = r.abs();
PathData::single(SubPath::polyline(&[Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)], true))
}
/// Rounded rectangle (8 anchors). The radius is clamped to half the shorter side.
pub fn rounded_rectangle(r: Rect, radius: f64) -> PathData {
let r = r.abs();
let rad = radius.max(0.0).min(r.width() / 2.0).min(r.height() / 2.0);
if rad <= 1e-9 {
return rectangle(r);
}
let k = rad * KAPPA;
let (x0, y0, x1, y1) = (r.x0, r.y0, r.x1, r.y1);
let a = |p: (f64, f64), hin: (f64, f64), hout: (f64, f64)| Anchor { p: p.into(), h_in: hin.into(), h_out: hout.into(), kind: AnchorKind::Corner };
let anchors = vec![
a((x0 + rad, y0), (x0 + rad - k, y0), (x0 + rad, y0)),
a((x1 - rad, y0), (x1 - rad, y0), (x1 - rad + k, y0)),
a((x1, y0 + rad), (x1, y0 + rad - k), (x1, y0 + rad)),
a((x1, y1 - rad), (x1, y1 - rad), (x1, y1 - rad + k)),
a((x1 - rad, y1), (x1 - rad + k, y1), (x1 - rad, y1)),
a((x0 + rad, y1), (x0 + rad, y1), (x0 + rad - k, y1)),
a((x0, y1 - rad), (x0, y1 - rad + k), (x0, y1 - rad)),
a((x0, y0 + rad), (x0, y0 + rad), (x0, y0 + rad - k)),
];
PathData::single(SubPath::new(anchors, true))
}
/// Ellipse inscribed in `r` with 4 smooth anchors (left, top, right, bottom).
pub fn ellipse(r: Rect) -> PathData {
let r = r.abs();
let c = r.center();
let rx = r.width() / 2.0;
let ry = r.height() / 2.0;
let kx = rx * KAPPA;
let ky = ry * KAPPA;
let s = |p: Point, hin: Vec2| Anchor { p, h_in: p + hin, h_out: p - hin, kind: AnchorKind::Smooth };
let anchors = vec![
s(Point::new(c.x - rx, c.y), Vec2::new(0.0, ky)),
s(Point::new(c.x, c.y - ry), Vec2::new(-kx, 0.0)),
s(Point::new(c.x + rx, c.y), Vec2::new(0.0, -ky)),
s(Point::new(c.x, c.y + ry), Vec2::new(kx, 0.0)),
];
PathData::single(SubPath::new(anchors, true))
}
/// Regular polygon with `sides` (≥ 3), first vertex straight up from the centre.
pub fn polygon(center: Point, radius: f64, sides: u32, rotation_deg: f64) -> PathData {
let n = sides.max(3);
let rot = rotation_deg.to_radians();
let pts: Vec<Point> = (0..n)
.map(|i| {
let a = -PI / 2.0 + rot + TAU * i as f64 / n as f64;
Point::new(center.x + radius * a.cos(), center.y + radius * a.sin())
})
.collect();
PathData::single(SubPath::polyline(&pts, true))
}
/// Star with `points` tips (≥ 2), outer radius `r1`, inner radius `r2`, first tip up.
pub fn star(center: Point, r1: f64, r2: f64, points: u32, rotation_deg: f64) -> PathData {
let n = points.max(2) * 2;
let rot = rotation_deg.to_radians();
let pts: Vec<Point> = (0..n)
.map(|i| {
let r = if i % 2 == 0 { r1 } else { r2 };
let a = -PI / 2.0 + rot + TAU * i as f64 / n as f64;
Point::new(center.x + r * a.cos(), center.y + r * a.sin())
})
.collect();
PathData::single(SubPath::polyline(&pts, true))
}
/// Inner radius of a "straight-armed" star (Illustrator's Alt-drag behaviour): shoulders aligned.
pub fn straight_star_inner_radius(r1: f64, points: u32) -> f64 {
let n = points.max(3) as f64;
r1 * (PI / n * 2.0).cos() / (PI / n).cos()
}
/// Open straight line.
pub fn line(a: Point, b: Point) -> PathData {
PathData::single(SubPath::polyline(&[a, b], false))
}
/// Quarter-ellipse arc from `a` to `b` (Arc tool default: concave, open, x-axis base).
pub fn arc(a: Point, b: Point, slope: f64, closed: bool) -> PathData {
// Corner of the arc's bounding box is at (a.x, b.y): the arc bows away from it.
let k = KAPPA * (1.0 + slope.clamp(-1.0, 1.0) * 0.0);
let corner = Point::new(b.x, a.y);
let h1 = a + (corner - a) * k;
let h2 = b + (corner - b) * k;
let mut anchors =
vec![Anchor { p: a, h_in: a, h_out: h1, kind: AnchorKind::Corner }, Anchor { p: b, h_in: h2, h_out: b, kind: AnchorKind::Corner }];
if closed {
let o = Point::new(a.x, b.y);
anchors.push(Anchor::corner(o));
}
PathData::single(SubPath::new(anchors, closed))
}
/// Archimedean-style spiral approximating Illustrator's Spiral tool (decay-based, `segments` quarter turns).
pub fn spiral(center: Point, radius: f64, decay_percent: f64, segments: u32, clockwise: bool) -> PathData {
let decay = (decay_percent / 100.0).clamp(0.05, 0.9999);
let dir = if clockwise { 1.0 } else { -1.0 };
let mut anchors = Vec::new();
let mut r = radius;
for i in 0..=segments {
let a = dir * (i as f64) * PI / 2.0;
let p = Point::new(center.x + r * a.cos(), center.y + r * a.sin());
// Tangent direction for a quarter arc of radius r.
let t = Vec2::new(-a.sin(), a.cos()) * dir;
let r_prev = r / decay;
let r_next = r * decay;
let h_in = p - t * (KAPPA * (r + r_prev) / 2.0);
let h_out = p + t * (KAPPA * (r + r_next) / 2.0);
anchors.push(Anchor { p, h_in: if i == 0 { p } else { h_in }, h_out: if i == segments { p } else { h_out }, kind: AnchorKind::Smooth });
r *= decay;
}
// Illustrator spirals wind inward from the outer point; reverse so the path starts at the centre end.
let mut sp = SubPath::new(anchors, false);
sp.reverse();
PathData::single(sp)
}
/// Rectangular grid as separate open lines (plus an optional frame rectangle).
pub fn rectangular_grid(r: Rect, h_dividers: u32, v_dividers: u32, frame: bool) -> Vec<PathData> {
let r = r.abs();
let mut out = Vec::new();
for i in 1..=h_dividers {
let y = r.y0 + r.height() * i as f64 / (h_dividers + 1) as f64;
out.push(line(Point::new(r.x0, y), Point::new(r.x1, y)));
}
for i in 1..=v_dividers {
let x = r.x0 + r.width() * i as f64 / (v_dividers + 1) as f64;
out.push(line(Point::new(x, r.y0), Point::new(x, r.y1)));
}
if frame {
out.push(rectangle(r));
}
out
}
/// Polar grid: concentric ellipses plus radial dividers.
pub fn polar_grid(r: Rect, concentric: u32, radial: u32) -> Vec<PathData> {
let r = r.abs();
let c = r.center();
let mut out = Vec::new();
for i in 1..=concentric + 1 {
let f = i as f64 / (concentric + 1) as f64;
out.push(ellipse(Rect::from_center_size(c, (r.width() * f, r.height() * f))));
}
for i in 0..radial.max(1) {
let a = -PI / 2.0 + TAU * i as f64 / radial.max(1) as f64;
out.push(line(c, Point::new(c.x + r.width() / 2.0 * a.cos(), c.y + r.height() / 2.0 * a.sin())));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rect_is_4_corners_clockwise() {
let p = rectangle(Rect::new(0.0, 0.0, 100.0, 50.0));
assert_eq!(p.anchor_count(), 4);
assert!(p.subpaths[0].area() > 0.0);
assert_eq!(p.bounds(), Some(Rect::new(0.0, 0.0, 100.0, 50.0)));
}
#[test]
fn rect_normalizes_negative() {
let p = rectangle(Rect::new(10.0, 10.0, 0.0, 0.0));
assert_eq!(p.bounds(), Some(Rect::new(0.0, 0.0, 10.0, 10.0)));
}
#[test]
fn ellipse_bounds_and_area() {
let p = ellipse(Rect::new(0.0, 0.0, 100.0, 60.0));
let b = p.bounds().unwrap();
assert!((b.width() - 100.0).abs() < 1e-6 && (b.height() - 60.0).abs() < 1e-6);
let area = p.subpaths[0].area().abs();
assert!((area - PI * 50.0 * 30.0).abs() / area < 0.001);
}
#[test]
fn rounded_rect_clamps() {
let p = rounded_rectangle(Rect::new(0.0, 0.0, 20.0, 10.0), 100.0);
assert_eq!(p.anchor_count(), 8);
assert_eq!(p.bounds(), Some(Rect::new(0.0, 0.0, 20.0, 10.0)));
assert_eq!(rounded_rectangle(Rect::new(0.0, 0.0, 20.0, 10.0), 0.0).anchor_count(), 4);
}
#[test]
fn polygon_first_vertex_up() {
let p = polygon(Point::new(0.0, 0.0), 10.0, 6, 0.0);
assert_eq!(p.anchor_count(), 6);
let a = p.subpaths[0].anchors[0].p;
assert!(a.x.abs() < 1e-9 && (a.y + 10.0).abs() < 1e-9);
}
#[test]
fn star_alternates_radii() {
let p = star(Point::ZERO, 10.0, 5.0, 5, 0.0);
assert_eq!(p.anchor_count(), 10);
let d: Vec<f64> = p.subpaths[0].anchors.iter().map(|a| a.p.to_vec2().hypot()).collect();
assert!((d[0] - 10.0).abs() < 1e-9 && (d[1] - 5.0).abs() < 1e-9);
}
#[test]
fn straight_star_five_points() {
let r2 = straight_star_inner_radius(100.0, 5);
assert!((r2 - 38.196_601).abs() < 1e-3);
}
#[test]
fn spiral_has_segments() {
let p = spiral(Point::ZERO, 100.0, 80.0, 10, true);
assert_eq!(p.anchor_count(), 11);
assert!(!p.subpaths[0].closed);
}
#[test]
fn grids() {
assert_eq!(rectangular_grid(Rect::new(0.0, 0.0, 10.0, 10.0), 5, 5, true).len(), 11);
assert_eq!(polar_grid(Rect::new(0.0, 0.0, 10.0, 10.0), 5, 5).len(), 11);
}
#[test]
fn arc_open_and_closed() {
assert_eq!(arc(Point::ZERO, Point::new(10.0, 10.0), 0.0, false).anchor_count(), 2);
assert!(arc(Point::ZERO, Point::new(10.0, 10.0), 0.0, true).subpaths[0].closed);
}
}
+46
View File
@@ -0,0 +1,46 @@
//! Snapping math shared by tools and smart guides.
use kurbo::Point;
/// Snap a value to a grid with the given spacing and origin.
pub fn snap_to_grid(v: f64, spacing: f64, origin: f64) -> f64 {
if spacing <= 0.0 {
return v;
}
((v - origin) / spacing).round() * spacing + origin
}
pub fn snap_point_to_grid(p: Point, spacing: f64) -> Point {
Point::new(snap_to_grid(p.x, spacing, 0.0), snap_to_grid(p.y, spacing, 0.0))
}
/// A candidate alignment line for smart guides.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Axis {
X,
Y,
}
/// Find the closest target within `tol` to `v`. Returns (target, delta).
pub fn closest(v: f64, targets: impl IntoIterator<Item = f64>, tol: f64) -> Option<(f64, f64)> {
targets.into_iter().map(|t| (t, t - v)).filter(|(_, d)| d.abs() <= tol).min_by(|a, b| a.1.abs().total_cmp(&b.1.abs()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn grid() {
assert_eq!(snap_to_grid(13.0, 10.0, 0.0), 10.0);
assert_eq!(snap_to_grid(16.0, 10.0, 0.0), 20.0);
assert_eq!(snap_to_grid(16.0, 10.0, 3.0), 13.0);
assert_eq!(snap_to_grid(16.0, 0.0, 0.0), 16.0);
}
#[test]
fn closest_target() {
assert_eq!(closest(10.0, [0.0, 11.0, 9.5], 2.0), Some((9.5, -0.5)));
assert_eq!(closest(10.0, [0.0, 20.0], 2.0), None);
}
}
+10
View File
@@ -0,0 +1,10 @@
[package]
name = "drawcraft-mcp"
version.workspace = true
edition.workspace = true
license.workspace = true
[lints]
workspace = true
[dependencies]
+1
View File
@@ -0,0 +1 @@
//! stub
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "drawcraft-pathops"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "DrawCraft path operations: booleans (Pathfinder), shape builder regions, offset, outline stroke, simplify"
[lints]
workspace = true
[dependencies]
drawcraft-geom = { workspace = true }
kurbo = { workspace = true }
thiserror = { workspace = true }
linesweeper = { workspace = true }
[dev-dependencies]
proptest = { workspace = true }
+81
View File
@@ -0,0 +1,81 @@
# drawcraft-pathops
Path operations for DrawCraft: booleans, the Pathfinder panel, Shape Builder regions, offset path, outline stroke, simplify and the other Object → Path commands. Everything takes and returns `drawcraft_geom::PathData`.
## Booleans (curve-preserving)
```rust
pub enum BoolOp { Union, Intersect, Difference, Xor }
pub fn boolean(a: &PathData, a_rule: FillRule, b: &PathData, b_rule: FillRule, op: BoolOp) -> PathData;
pub fn try_boolean(.., precision: f64) -> Result<PathData, PathOpsError>; // errors on NaN/inf
pub fn boolean_n(paths: &[(&PathData, FillRule)], pred: impl Fn(&[bool]) -> bool) -> PathData;
pub fn unite_all(paths: &[(&PathData, FillRule)]) -> PathData; // fast many-shape union
pub fn normalize(path: &PathData, rule: FillRule) -> PathData; // resolve self-overlaps
pub fn area(path: &PathData, rule: FillRule) -> f64; // exact filled area
```
The engine is `linesweeper` (a robust sweep line that works directly on cubic Béziers, so there's no polygon flattening). The sweep splits curves at intersections and y-extrema. Afterwards:
- pieces that lie on one input cubic are rebuilt exactly as a single sub-curve of that cubic;
- other smooth joints (for example, in stroker output) are refitted by least squares within `DEFAULT_PRECISION` (0.01 pt);
- collinear line pieces are merged;
- the inputs' own anchors are always kept;
- slivers narrower than the precision are dropped.
Output contours are simple and consistently oriented: outer contours and holes wind in opposite directions, so the result fills the same under either fill rule. Open subpaths are closed implicitly because they're treated as filled.
## Pathfinder / Shape Builder
```rust
pub struct Shape { pub path: PathData, pub rule: FillRule, pub key: u64 } // key = paint identity
pub enum PathfinderOp { Unite, MinusFront, Intersect, Exclude, Divide, Trim, Merge, Crop, Outline, MinusBack }
pub fn pathfinder(op: PathfinderOp, shapes: &[Shape] /* back → front */) -> Vec<Shape>;
pub struct Region { pub path: PathData, pub sources: Vec<usize> }
pub fn regions(shapes: &[Shape]) -> Vec<Region>; // all faces of the arrangement
pub fn region_at(shapes: &[Shape], p: Point) -> Option<Region>;
pub fn merge_regions(regions: &[&Region]) -> PathData; // Shape Builder merge = union
```
Each result takes its key as follows:
| Operation | Result |
|---|---|
| Unite, Intersect, Exclude, Minus Back | Takes the front-most key |
| Minus Front | Takes the back-most key |
| Divide | One shape per face, keyed by the front-most shape that covers the face |
| Trim | One shape for each object that is still visible |
| Merge | Joins touching visible parts that share a key; each connected component becomes one shape |
| Crop | Keeps the visible parts of the lower objects inside the front-most object |
| Outline | Returns open edge paths, split at every junction; coincident edges are de-duplicated, and each keeps the front-most key |
## Offset / stroke
```rust
pub use kurbo::{Cap, Join};
pub fn offset_path(path: &PathData, delta: f64, join: Join, miter_limit: f64) -> PathData; // delta < 0 insets
pub fn outline_stroke(path: &PathData, width: f64, cap: Cap, join: Join, miter_limit: f64) -> PathData;
```
`outline_stroke` passes the path to `kurbo::stroke` and then normalizes the result, which removes the overlaps at joins. `offset_path` computes `fill ∪ stroke(2|d|)` for a positive offset and `fill − stroke(2|d|)` for a negative one.
## Path editing
```rust
pub fn simplify(path: &PathData, tolerance: f64) -> PathData;
pub fn simplify_with(path: &PathData, opts: &SimplifyOptions) -> PathData; // tolerance, corner_angle_deg, straight_lines
pub fn smooth(path: &PathData, amount: f64) -> PathData;
pub fn remove_redundant_points(path: &PathData, tolerance: f64) -> PathData;
pub fn add_anchor_points(path: &PathData) -> PathData;
pub fn average(path: &PathData, selection: &[(usize, usize)], axis: AverageAxis) -> PathData;
pub fn join(paths: &[PathData], tolerance: f64) -> PathData;
pub fn split_into_grid(rect: Rect, rows: usize, cols: usize, gutter: f64) -> Vec<PathData>;
```
`simplify` fits least-squares cubics with fixed end tangents, then reparameterizes with Newton steps and splits at the point of worst error. Any anchor that turns more than the corner threshold (30° by default) stays a corner.
## Caveats
- linesweeper is still in beta. When two identical curves overlap, the sweep can leave a tiny lens between them; the sliver filter removes it.
- The Outline op finds split points by snapping to arrangement junctions with a small tolerance.
- The quality of offsets depends on kurbo's parallel-curve stroker. Its output is refitted, so offset results have more anchors than boolean results.
+513
View File
@@ -0,0 +1,513 @@
//! Curve-preserving boolean operations built on `linesweeper`'s robust sweep-line topology.
//!
//! Inputs are converted to closed [`BezPath`]s (open subpaths are implicitly closed, as a filled
//! open path is painted in Illustrator). The sweep splits curves at intersections and y-extrema;
//! [`tidy_segments`] then re-joins pieces that came from the same smooth curve by refitting, so
//! results have roughly as few anchors as the inputs.
use std::collections::BTreeSet;
use drawcraft_geom::{Anchor, FillRule, PathData, SubPath};
use kurbo::{BezPath, CubicBez, PathSeg, Point, Shape as _};
use linesweeper::topology::{ContourIdx, Contours, Topology, WindingNumber};
use crate::PathOpsError;
use crate::fit::{end_tangent, fit_single, is_straight, sample, start_tangent};
/// Default curve-refit tolerance (document points) used to merge split curve pieces back together.
/// Similar in spirit to Illustrator's Pathfinder "Precision" option (0.028 pt).
pub const DEFAULT_PRECISION: f64 = 0.01;
/// Binary boolean operation.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum BoolOp {
/// A ∪ B
Union,
/// A ∩ B
Intersect,
/// A − B
Difference,
/// A ⊕ B (symmetric difference)
Xor,
}
impl BoolOp {
/// Evaluate the operation on "inside A" / "inside B" flags.
pub fn apply(self, a: bool, b: bool) -> bool {
match self {
BoolOp::Union => a || b,
BoolOp::Intersect => a && b,
BoolOp::Difference => a && !b,
BoolOp::Xor => a != b,
}
}
}
pub(crate) fn inside(rule: FillRule, w: i32) -> bool {
match rule {
FillRule::NonZero => w != 0,
FillRule::EvenOdd => w % 2 != 0,
}
}
/// Winding numbers for an arbitrary number of input sets (tag = input index).
#[derive(Clone, Debug, Default)]
pub(crate) struct Multi(pub(crate) Vec<i32>);
impl Multi {
fn get(&self, i: usize) -> i32 {
self.0.get(i).copied().unwrap_or(0)
}
}
impl PartialEq for Multi {
fn eq(&self, other: &Self) -> bool {
let n = self.0.len().max(other.0.len());
(0..n).all(|i| self.get(i) == other.get(i))
}
}
impl Eq for Multi {}
impl std::ops::Add for Multi {
type Output = Multi;
fn add(mut self, rhs: Self) -> Multi {
self += rhs;
self
}
}
impl std::ops::AddAssign for Multi {
fn add_assign(&mut self, rhs: Self) {
if self.0.len() < rhs.0.len() {
self.0.resize(rhs.0.len(), 0);
}
for (a, b) in self.0.iter_mut().zip(rhs.0) {
*a += b;
}
}
}
impl WindingNumber for Multi {
type Tag = usize;
fn single(tag: usize, positive: bool) -> Self {
let mut v = vec![0; tag + 1];
v[tag] = if positive { 1 } else { -1 };
Multi(v)
}
fn of_tag(&self, tag: usize) -> Self {
let mut v = vec![0; tag + 1];
v[tag] = self.get(tag);
Multi(v)
}
}
/// Convert a (filled) path to a closed BezPath: every subpath with ≥ 2 anchors is closed.
pub(crate) fn fill_bezpath(p: &PathData) -> BezPath {
let mut bp = BezPath::new();
for sp in &p.subpaths {
if sp.anchors.len() < 2 {
continue;
}
if sp.closed {
sp.to_bezpath_into(&mut bp);
} else {
let mut c = sp.clone();
c.closed = true;
c.to_bezpath_into(&mut bp);
}
}
bp
}
/// Sweep tolerance for a set of paths (same scaling rule as linesweeper's own `binary_op`).
pub(crate) fn eps_for<'a>(paths: impl IntoIterator<Item = &'a BezPath>) -> Result<f64, PathOpsError> {
let mut m: f64 = 0.0;
for p in paths {
for el in p.elements() {
for pt in el_points(el) {
if !pt.x.is_finite() || !pt.y.is_finite() {
return Err(PathOpsError::NonFinite);
}
m = m.max(pt.x.abs()).max(pt.y.abs());
}
}
}
Ok((m * f64::EPSILON * 64.0).max(1e-6))
}
fn el_points(el: &kurbo::PathEl) -> impl Iterator<Item = Point> {
use kurbo::PathEl::*;
let v: [Option<Point>; 3] = match *el {
MoveTo(p) | LineTo(p) => [Some(p), None, None],
QuadTo(a, b) => [Some(a), Some(b), None],
CurveTo(a, b, c) => [Some(a), Some(b), Some(c)],
ClosePath => [None, None, None],
};
v.into_iter().flatten()
}
/// An arrangement (planar subdivision) of N filled paths.
pub(crate) struct Arrangement {
pub(crate) top: Topology<Multi>,
pub(crate) rules: Vec<FillRule>,
/// Output cleanup that keeps the inputs' anchors.
pub(crate) tidy: Tidy,
}
/// Output cleanup settings: refit `precision`, and anchor positions that must survive (the
/// inputs' own anchors — only joints the sweep introduced are merged away).
#[derive(Clone, Debug, Default)]
pub(crate) struct Tidy {
pub(crate) precision: f64,
keep: Vec<Point>,
/// Input curves (with inflated bounds) used to rebuild split pieces exactly.
curves: Vec<(kurbo::Rect, CubicBez)>,
}
impl Tidy {
/// Merge any smooth joint.
pub(crate) fn free(precision: f64) -> Self {
Self { precision, keep: Vec::new(), curves: Vec::new() }
}
/// Keep every on-curve point of `paths`.
pub(crate) fn keeping<'a>(precision: f64, paths: impl IntoIterator<Item = &'a BezPath>) -> Self {
let mut keep = Vec::new();
let mut curves = Vec::new();
for p in paths {
for s in p.segments() {
let seg = to_seg(s);
keep.push(seg.c.p0);
if !seg.line {
let r = seg.c.bounding_box().inflate(1e-6, 1e-6);
curves.push((r, seg.c));
}
}
}
keep.sort_by(|a, b| a.x.total_cmp(&b.x));
Self { precision, keep, curves }
}
fn is_kept(&self, p: Point) -> bool {
let tol = 1e-6 * (1.0 + p.x.abs().max(p.y.abs()));
let lo = self.keep.partition_point(|q| q.x < p.x - tol);
self.keep[lo..].iter().take_while(|q| q.x <= p.x + tol).any(|q| (q.y - p.y).abs() <= tol)
}
}
impl Arrangement {
pub(crate) fn new(paths: &[(BezPath, FillRule)]) -> Result<Self, PathOpsError> {
let eps = eps_for(paths.iter().map(|p| &p.0))?;
let top = Topology::<Multi>::from_paths(paths.iter().enumerate().map(|(i, (p, _))| (p, i)), eps).map_err(|_| PathOpsError::OpenPath)?;
let tidy = Tidy::keeping(DEFAULT_PRECISION, paths.iter().map(|p| &p.0));
Ok(Self { top, rules: paths.iter().map(|p| p.1).collect(), tidy })
}
pub(crate) fn mask(&self, w: &Multi) -> Vec<bool> {
self.rules.iter().enumerate().map(|(i, &r)| inside(r, w.get(i))).collect()
}
pub(crate) fn contours(&self, pred: impl Fn(&[bool]) -> bool) -> Contours {
self.top.contours(|w| pred(&self.mask(w)))
}
/// All distinct non-empty coverage masks that occur next to some edge, sorted.
pub(crate) fn distinct_masks(&self) -> Vec<Vec<bool>> {
let mut set = BTreeSet::new();
for s in self.top.segment_indices() {
let h = s.first_half();
for w in [self.top.winding_clockwise(h), self.top.winding_counter_clockwise(h)] {
let m = self.mask(w);
if m.iter().any(|&b| b) {
set.insert(m);
}
}
}
set.into_iter().collect()
}
/// Vertices where three or more edges meet (true intersections / touch points).
pub(crate) fn junctions(&self) -> Vec<Point> {
let mut count: std::collections::HashMap<(u64, u64), (usize, Point)> = std::collections::HashMap::new();
for s in self.top.segment_indices() {
for h in [s.first_half(), s.second_half()] {
let p = self.top.point(h).to_kurbo();
count.entry((p.x.to_bits(), p.y.to_bits())).or_insert((0, p)).0 += 1;
}
}
let mut v: Vec<Point> = count.into_values().filter(|(c, _)| *c >= 3).map(|(_, p)| p).collect();
v.sort_by(|a, b| a.x.total_cmp(&b.x).then(a.y.total_cmp(&b.y)));
v
}
}
/// Convert selected contours into a tidy compound path.
pub(crate) fn contours_to_path(c: &Contours, idx: impl IntoIterator<Item = ContourIdx>, tidy: &Tidy) -> PathData {
let mut subpaths = Vec::new();
for i in idx {
if is_sliver(&c[i].path, tidy.precision) {
continue;
}
if let Some(sp) = tidy_bezpath(&c[i].path, tidy) {
subpaths.push(sp);
}
}
PathData::new(subpaths)
}
pub(crate) fn all_contours_to_path(c: &Contours, tidy: &Tidy) -> PathData {
contours_to_path(c, (0..c.contours().count()).map(ContourIdx), tidy)
}
/// Contours whose mean width (2·area / perimeter) is below `precision` are numerical debris
/// (e.g. the lens left between two coincident curves) and are dropped, like Illustrator's
/// Pathfinder precision setting does.
fn is_sliver(bp: &BezPath, precision: f64) -> bool {
let a = bp.area().abs();
let per = bp.perimeter(1e-6);
per <= 0.0 || 2.0 * a / per < precision
}
/// One segment of a path: a cubic plus whether it is a straight line.
#[derive(Clone, Copy, Debug)]
pub(crate) struct Seg {
pub(crate) c: CubicBez,
pub(crate) line: bool,
}
impl Seg {
pub(crate) fn line(a: Point, b: Point) -> Self {
Self { c: CubicBez::new(a, a, b, b), line: true }
}
}
pub(crate) fn to_seg(s: PathSeg) -> Seg {
match s {
PathSeg::Line(l) => Seg::line(l.p0, l.p1),
PathSeg::Quad(q) => {
let c = q.raise();
Seg { c, line: is_straight(&c, 1e-9) }
}
PathSeg::Cubic(c) => Seg { c, line: is_straight(&c, 1e-9 * (1.0 + c.p0.distance(c.p3))) },
}
}
fn tidy_bezpath(bp: &BezPath, tidy: &Tidy) -> Option<SubPath> {
let segs: Vec<Seg> = bp.segments().map(to_seg).collect();
let segs = tidy_segments(segs, true, tidy);
segs_to_subpath(&segs, true)
}
/// Build a subpath from a chain of segments.
pub(crate) fn segs_to_subpath(segs: &[Seg], closed: bool) -> Option<SubPath> {
if segs.is_empty() {
return None;
}
let n = segs.len();
let mut anchors = Vec::with_capacity(n + 1);
for i in 0..n {
let s = segs[i];
let p = s.c.p0;
let h_out = if s.line { p } else { s.c.p1 };
let h_in = if i == 0 && !closed {
p
} else {
let prev = segs[(i + n - 1) % n];
if prev.line { p } else { prev.c.p2 }
};
anchors.push(Anchor::with_handles(p, h_in, h_out));
}
if !closed {
let last = segs[n - 1];
let p = last.c.p3;
let h_in = if last.line { p } else { last.c.p2 };
anchors.push(Anchor::with_handles(p, h_in, p));
}
if closed && anchors.len() < 2 {
return None;
}
Some(SubPath::new(anchors, closed))
}
/// Merge segments that were split from one curve (or collinear lines) back together, dropping
/// degenerate pieces. `closed` segments form a loop.
pub(crate) fn tidy_segments(segs: Vec<Seg>, closed: bool, tidy: &Tidy) -> Vec<Seg> {
let scale = segs.iter().map(|s| s.c.p0.distance(s.c.p3)).fold(0.0, f64::max);
let tiny = (scale * 1e-9).max(1e-9);
let mut segs: Vec<Seg> = segs
.into_iter()
.filter(|s| {
let r = kurbo::Rect::from_points(s.c.p0, s.c.p3).union_pt(s.c.p1).union_pt(s.c.p2);
r.width() + r.height() > tiny
})
.collect();
let n = segs.len();
if n < 2 {
return segs;
}
// Joint i sits at the start of segment i.
let mergeable = |a: &Seg, b: &Seg| -> bool {
if a.line != b.line || tidy.is_kept(b.c.p0) {
return false;
}
let ta = end_tangent(&a.c);
let tb = start_tangent(&b.c);
ta.dot(tb) > 0.9998 && ta.cross(tb).abs() < 0.02
};
if closed {
// Rotate so we start at a real corner (if there is one).
if let Some(k) = (0..n).find(|&i| !mergeable(&segs[(i + n - 1) % n], &segs[i])) {
segs.rotate_left(k);
}
}
let mut out: Vec<Seg> = Vec::with_capacity(n);
let mut i = 0;
while i < n {
let mut cur = segs[i];
let mut j = i + 1;
while j < n && mergeable(&segs[j - 1], &segs[j]) {
match try_merge(&segs[i..=j], tidy) {
Some(m) => {
cur = m;
j += 1;
}
None => break,
}
}
out.push(cur);
i = j;
}
out
}
fn try_merge(run: &[Seg], tidy: &Tidy) -> Option<Seg> {
let precision = tidy.precision;
let first = run[0].c;
let last = run[run.len() - 1].c;
if run[0].line {
let chord = last.p3 - first.p0;
let len = chord.hypot();
if len < 1e-12 {
return None;
}
let ok = run.iter().all(|s| ((s.c.p3 - first.p0).cross(chord) / len).abs() <= precision * 0.5);
return ok.then(|| Seg::line(first.p0, last.p3));
}
if let Some(c) = exact_merge(run, tidy) {
return Some(Seg { c, line: false });
}
let mut pts = Vec::with_capacity(run.len() * 12 + 1);
for (k, s) in run.iter().enumerate() {
sample(&s.c, 12, &mut pts, k == 0);
}
let (c, err, _) = fit_single(&pts, start_tangent(&first), end_tangent(&last));
if err > precision {
return None;
}
Some(Seg { c, line: false })
}
/// If every piece of `run` lies on one input cubic, return the exact sub-curve spanning the run.
fn exact_merge(run: &[Seg], tidy: &Tidy) -> Option<CubicBez> {
use kurbo::{ParamCurve, ParamCurveNearest};
let a = run[0].c.p0;
let b = run[run.len() - 1].c.p3;
let scale = 1.0 + a.x.abs().max(a.y.abs()).max(b.x.abs()).max(b.y.abs());
let tol = 1e-6 * scale;
for (r, c) in &tidy.curves {
if !r.contains(a) || !r.contains(b) {
continue;
}
let na = c.nearest(a, 1e-12);
let nb = c.nearest(b, 1e-12);
if na.distance_sq.sqrt() > tol || nb.distance_sq.sqrt() > tol {
continue;
}
let on_curve = run.iter().all(|s| c.nearest(s.c.eval(0.5), 1e-12).distance_sq.sqrt() <= tol * 10.0);
if !on_curve || (na.t - nb.t).abs() < 1e-12 {
continue;
}
let sub = if na.t < nb.t {
c.subsegment(na.t..nb.t)
} else {
let r = c.subsegment(nb.t..na.t);
CubicBez::new(r.p3, r.p2, r.p1, r.p0)
};
return Some(CubicBez::new(a, sub.p1, sub.p2, b));
}
None
}
/// Resolve self-intersections / overlaps of a single filled path into simple, consistently
/// oriented contours (outer contours and holes).
pub fn normalize(path: &PathData, rule: FillRule) -> PathData {
try_normalize(path, rule).unwrap_or_default()
}
/// Fallible [`normalize`].
pub fn try_normalize(path: &PathData, rule: FillRule) -> Result<PathData, PathOpsError> {
let bp = fill_bezpath(path);
let c = normalize_bez(&bp, rule)?;
Ok(all_contours_to_path(&c, &Tidy::keeping(DEFAULT_PRECISION, [&bp])))
}
pub(crate) fn normalize_bez(bp: &BezPath, rule: FillRule) -> Result<Contours, PathOpsError> {
let eps = eps_for([bp])?;
let top = Topology::<i32>::from_path(bp, eps).map_err(|_| PathOpsError::OpenPath)?;
Ok(top.contours(|w| inside(rule, *w)))
}
/// Boolean operation between two filled paths. Returns an empty path on invalid (non-finite) input.
pub fn boolean(a: &PathData, a_rule: FillRule, b: &PathData, b_rule: FillRule, op: BoolOp) -> PathData {
try_boolean(a, a_rule, b, b_rule, op, DEFAULT_PRECISION).unwrap_or_default()
}
/// Boolean operation with an explicit refit `precision`; errors on non-finite input.
pub fn try_boolean(a: &PathData, a_rule: FillRule, b: &PathData, b_rule: FillRule, op: BoolOp, precision: f64) -> Result<PathData, PathOpsError> {
let arr = Arrangement::new(&[(fill_bezpath(a), a_rule), (fill_bezpath(b), b_rule)])?;
let c = arr.contours(|m| op.apply(m[0], m[1]));
let tidy = Tidy { precision, ..arr.tidy };
Ok(all_contours_to_path(&c, &tidy))
}
/// N-ary boolean: the area where `pred(inside_flags)` holds, `inside_flags[i]` telling whether a
/// point is inside `paths[i]` under its fill rule.
pub fn boolean_n(paths: &[(&PathData, FillRule)], pred: impl Fn(&[bool]) -> bool) -> PathData {
let bps: Vec<(BezPath, FillRule)> = paths.iter().map(|(p, r)| (fill_bezpath(p), *r)).collect();
match Arrangement::new(&bps) {
Ok(arr) => all_contours_to_path(&arr.contours(pred), &arr.tidy),
Err(_) => PathData::default(),
}
}
/// Union of many filled paths. Each input is first normalised on its own, then all are swept
/// together with a single integer winding number, which keeps this fast for thousands of shapes.
pub fn unite_all(paths: &[(&PathData, FillRule)]) -> PathData {
let mut all = BezPath::new();
for (p, r) in paths {
let bp = fill_bezpath(p);
if bp.elements().is_empty() {
continue;
}
let Ok(c) = normalize_bez(&bp, *r) else { continue };
for ct in c.contours() {
all.extend(ct.path.iter());
}
}
if all.elements().is_empty() {
return PathData::default();
}
let tidy = Tidy::keeping(DEFAULT_PRECISION, paths.iter().map(|(p, _)| fill_bezpath(p)).collect::<Vec<_>>().iter());
match normalize_bez(&all, FillRule::NonZero) {
Ok(c) => all_contours_to_path(&c, &tidy),
Err(_) => PathData::default(),
}
}
/// Unsigned filled area of a path under a fill rule (normalises first, so it is exact for
/// self-overlapping input).
pub fn area(path: &PathData, rule: FillRule) -> f64 {
let bp = fill_bezpath(path);
match normalize_bez(&bp, rule) {
Ok(c) => c.contours().map(|ct| ct.path.area()).sum::<f64>().abs(),
Err(_) => 0.0,
}
}
+366
View File
@@ -0,0 +1,366 @@
//! Object → Path commands: Simplify, Smooth, Remove redundant points, Add Anchor Points, Average,
//! Join, Split Into Grid.
use drawcraft_geom::{Anchor, PathData, SubPath};
use kurbo::{ParamCurveArclen, Point, Rect, Vec2};
use crate::boolean::{Seg, segs_to_subpath};
use crate::fit::{end_tangent, fit_cubics, fit_single, is_straight, sample, start_tangent};
/// Options for [`simplify_with`].
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct SimplifyOptions {
/// Maximum distance (document points) between the original and simplified path.
pub tolerance: f64,
/// Anchors where the path turns by more than this many degrees stay corners.
pub corner_angle_deg: f64,
/// Produce only straight segments (Illustrator's "Convert to Straight Lines").
pub straight_lines: bool,
}
impl Default for SimplifyOptions {
fn default() -> Self {
Self { tolerance: 0.5, corner_angle_deg: 30.0, straight_lines: false }
}
}
/// Simplify with the default corner threshold (30°).
pub fn simplify(path: &PathData, tolerance: f64) -> PathData {
simplify_with(path, &SimplifyOptions { tolerance, ..Default::default() })
}
fn subpath_segs(sp: &SubPath) -> Vec<Seg> {
(0..sp.segment_count()).map(|i| Seg { c: sp.segment(i), line: sp.segment_is_line(i) }).collect()
}
fn turn_deg(a: &Seg, b: &Seg) -> f64 {
let d = end_tangent(&a.c).dot(start_tangent(&b.c)).clamp(-1.0, 1.0);
d.acos().to_degrees()
}
/// Least-squares Bézier refit of every subpath, keeping corners sharper than the threshold.
pub fn simplify_with(path: &PathData, opts: &SimplifyOptions) -> PathData {
let tol = opts.tolerance.max(1e-6);
let mut out = Vec::new();
for sp in &path.subpaths {
let mut segs = subpath_segs(sp);
let n = segs.len();
if n == 0 {
out.push(sp.clone());
continue;
}
let corner_at = |segs: &[Seg], i: usize| -> bool {
if !sp.closed && (i == 0 || i == n) {
return true;
}
turn_deg(&segs[(i + n - 1) % n], &segs[i % n]) > opts.corner_angle_deg
};
let mut corners: Vec<usize> = (0..n).filter(|&i| corner_at(&segs, i)).collect();
if sp.closed {
if let Some(&k) = corners.first() {
segs.rotate_left(k);
corners = corners.iter().map(|&c| c - k).collect();
} else {
corners = vec![0];
}
corners.push(n);
} else if corners.last() != Some(&n) {
corners.push(n);
}
let mut result: Vec<Seg> = Vec::new();
for w in corners.windows(2) {
let run = &segs[w[0]..w[1]];
if run.is_empty() {
continue;
}
let mut pts = Vec::new();
for (k, s) in run.iter().enumerate() {
let len = s.c.arclen(1e-6);
let m = ((len / (tol * 0.5)).ceil() as usize).clamp(4, 256);
sample(&s.c, m, &mut pts, k == 0);
}
if opts.straight_lines {
let keep = rdp(&pts, tol);
result.extend(keep.windows(2).map(|p| Seg::line(p[0], p[1])));
continue;
}
let t0 = start_tangent(&run[0].c);
let t1 = end_tangent(&run[run.len() - 1].c);
for c in fit_cubics(&pts, t0, t1, tol) {
let line = is_straight(&c, tol * 0.02);
result.push(if line { Seg::line(c.p0, c.p3) } else { Seg { c, line } });
}
}
if let Some(s) = segs_to_subpath(&result, sp.closed) {
out.push(s);
}
}
PathData::new(out)
}
/// Ramer–Douglas–Peucker polyline reduction.
fn rdp(pts: &[Point], tol: f64) -> Vec<Point> {
if pts.len() < 3 {
return pts.to_vec();
}
let mut keep = vec![false; pts.len()];
keep[0] = true;
keep[pts.len() - 1] = true;
let mut stack = vec![(0, pts.len() - 1)];
while let Some((a, b)) = stack.pop() {
let (pa, pb) = (pts[a], pts[b]);
let chord = pb - pa;
let len = chord.hypot();
let mut best = (0.0, a);
for (i, &p) in pts.iter().enumerate().take(b).skip(a + 1) {
let d = if len < 1e-12 { p.distance(pa) } else { (p - pa).cross(chord).abs() / len };
if d > best.0 {
best = (d, i);
}
}
if best.0 > tol {
keep[best.1] = true;
stack.push((a, best.1));
stack.push((best.1, b));
}
}
pts.iter().zip(keep).filter(|(_, k)| *k).map(|(p, _)| *p).collect()
}
/// Object → Path → Smooth: pull handles towards Catmull-Rom-like tangents. `amount` ∈ [0, 1]
/// (0 = unchanged, 1 = fully smooth). Endpoints of open paths keep their handles.
pub fn smooth(path: &PathData, amount: f64) -> PathData {
let t = amount.clamp(0.0, 1.0);
let mut out = path.clone();
for sp in &mut out.subpaths {
let n = sp.anchors.len();
if n < 3 {
continue;
}
let orig = sp.anchors.clone();
for i in 0..n {
if !sp.closed && (i == 0 || i == n - 1) {
continue;
}
let prev = orig[(i + n - 1) % n].p;
let next = orig[(i + 1) % n].p;
let p = orig[i].p;
let Some(dir) = crate::fit::unit(next - prev) else { continue };
let tin = p - dir * (p.distance(prev) / 3.0);
let tout = p + dir * (p.distance(next) / 3.0);
let a = &orig[i];
let h_in = a.h_in.lerp(tin, t);
let h_out = a.h_out.lerp(tout, t);
sp.anchors[i] = Anchor::with_handles(p, h_in, h_out);
}
}
out
}
/// Remove anchors that can be deleted without changing the shape by more than `tolerance`
/// (collinear corner points, duplicate points, and curve joints that one cubic can replace).
pub fn remove_redundant_points(path: &PathData, tolerance: f64) -> PathData {
let tol = tolerance.max(1e-9);
let mut out = Vec::new();
for sp in &path.subpaths {
let mut segs = subpath_segs(sp);
if segs.is_empty() {
out.push(sp.clone());
continue;
}
// Drop zero-length segments.
segs.retain(|s| !(s.c.p0.distance(s.c.p3) < 1e-9 && s.c.p1.distance(s.c.p0) < 1e-9 && s.c.p2.distance(s.c.p0) < 1e-9));
let min_segs = if sp.closed { 2 } else { 1 };
let mut changed = true;
while changed && segs.len() > min_segs {
changed = false;
let n = segs.len();
let joints = if sp.closed { n } else { n - 1 };
for j in 0..joints {
let (i0, i1) = if sp.closed { ((j + n - 1) % n, j) } else { (j, j + 1) };
if let Some(m) = merge_pair(&segs[i0], &segs[i1], tol) {
segs[i0] = m;
segs.remove(i1);
if sp.closed && i1 == 0 {
// The merged segment now starts the loop.
let last = segs.pop().expect("non-empty");
segs.insert(0, last);
}
changed = true;
break;
}
}
}
if let Some(s) = segs_to_subpath(&segs, sp.closed) {
out.push(s);
}
}
PathData::new(out)
}
fn merge_pair(a: &Seg, b: &Seg, tol: f64) -> Option<Seg> {
if a.line && b.line {
let chord = b.c.p3 - a.c.p0;
let len = chord.hypot();
if len < 1e-12 {
return None;
}
let mid = a.c.p3 - a.c.p0;
let along = mid.dot(chord) / len;
return ((mid.cross(chord) / len).abs() <= tol && along >= 0.0 && along <= len).then(|| Seg::line(a.c.p0, b.c.p3));
}
// Only merge across (near-)smooth joints.
if end_tangent(&a.c).dot(start_tangent(&b.c)) < 0.99 {
return None;
}
let mut pts = Vec::new();
sample(&a.c, 16, &mut pts, true);
sample(&b.c, 16, &mut pts, false);
let (c, err, _) = fit_single(&pts, start_tangent(&a.c), end_tangent(&b.c));
(err <= tol).then_some(Seg { c, line: false })
}
/// Object → Path → Add Anchor Points: one new anchor at the middle (t = 0.5) of every segment.
pub fn add_anchor_points(path: &PathData) -> PathData {
let mut out = path.clone();
for sp in &mut out.subpaths {
for seg in (0..sp.segment_count()).rev() {
sp.insert_anchor(seg, 0.5);
}
}
out
}
/// Axis for [`average`].
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum AverageAxis {
/// Align on a common horizontal line (same y).
Horizontal,
/// Align on a common vertical line (same x).
Vertical,
/// Collapse to the common centroid.
Both,
}
/// Object → Path → Average: move the selected anchors `(subpath, anchor)` (with their handles)
/// to their average position along `axis`.
pub fn average(path: &PathData, selection: &[(usize, usize)], axis: AverageAxis) -> PathData {
let mut out = path.clone();
let pts: Vec<Point> = selection.iter().filter_map(|&(s, a)| path.subpaths.get(s).and_then(|sp| sp.anchors.get(a)).map(|a| a.p)).collect();
if pts.is_empty() {
return out;
}
let c = pts.iter().fold(Vec2::ZERO, |acc, p| acc + p.to_vec2()) / pts.len() as f64;
for &(s, a) in selection {
if let Some(an) = out.anchor_mut(s, a) {
let d = match axis {
AverageAxis::Horizontal => Vec2::new(0.0, c.y - an.p.y),
AverageAxis::Vertical => Vec2::new(c.x - an.p.x, 0.0),
AverageAxis::Both => c - an.p.to_vec2(),
};
an.translate(d);
}
}
out
}
/// Object → Path → Join. Open subpaths of all inputs are joined nearest-endpoint-first into one
/// open path (endpoints closer than `tolerance` are merged into a single anchor, otherwise a
/// straight segment connects them). A single open subpath is closed instead. Closed subpaths
/// pass through unchanged.
pub fn join(paths: &[PathData], tolerance: f64) -> PathData {
let mut closed: Vec<SubPath> = Vec::new();
let mut open: Vec<SubPath> = Vec::new();
for p in paths {
for sp in &p.subpaths {
if sp.closed || sp.anchors.len() < 2 {
if !sp.anchors.is_empty() {
closed.push(sp.clone());
}
} else {
open.push(sp.clone());
}
}
}
if open.len() == 1 {
let mut sp = open.pop().expect("one");
let n = sp.anchors.len();
if n > 2 && sp.anchors[0].p.distance(sp.anchors[n - 1].p) <= tolerance {
let last = sp.anchors.pop().expect("n > 2");
let f = &mut sp.anchors[0];
*f = Anchor::with_handles(f.p, last.h_in, f.h_out);
} else {
let f = &mut sp.anchors[0];
f.h_in = f.p;
let l = sp.anchors.last_mut().expect("n >= 2");
l.h_out = l.p;
}
sp.closed = true;
closed.push(sp);
return PathData::new(closed);
}
while open.len() > 1 {
// Find the closest pair of endpoints on different subpaths.
let mut best = (f64::INFINITY, 0, 0, false, false);
for i in 0..open.len() {
for j in (i + 1)..open.len() {
for ei in [false, true] {
for ej in [false, true] {
let pi = if ei { open[i].anchors.last() } else { open[i].anchors.first() }.expect("len>=2").p;
let pj = if ej { open[j].anchors.last() } else { open[j].anchors.first() }.expect("len>=2").p;
let d = pi.distance(pj);
if d < best.0 {
best = (d, i, j, ei, ej);
}
}
}
}
}
let (d, i, j, ei, ej) = best;
let mut b = open.remove(j);
let a = &mut open[i];
if !ei {
a.reverse();
}
if ej {
b.reverse();
}
if d <= tolerance {
let first = b.anchors.remove(0);
let l = a.anchors.last_mut().expect("len>=2");
*l = Anchor::with_handles(l.p, l.h_in, first.h_out);
} else {
let l = a.anchors.last_mut().expect("len>=2");
l.h_out = l.p;
b.anchors[0].h_in = b.anchors[0].p;
}
a.anchors.extend(b.anchors);
}
closed.extend(open);
PathData::new(closed)
}
/// Object → Path → Split Into Grid: `rows × cols` rectangles filling `rect` with `gutter` spacing
/// (row-major, top-left first).
pub fn split_into_grid(rect: Rect, rows: usize, cols: usize, gutter: f64) -> Vec<PathData> {
if rows == 0 || cols == 0 {
return Vec::new();
}
let w = (rect.width() - gutter * (cols as f64 - 1.0)) / cols as f64;
let h = (rect.height() - gutter * (rows as f64 - 1.0)) / rows as f64;
if w <= 0.0 || h <= 0.0 {
return Vec::new();
}
let mut out = Vec::with_capacity(rows * cols);
for r in 0..rows {
for c in 0..cols {
let x = rect.x0 + c as f64 * (w + gutter);
let y = rect.y0 + r as f64 * (h + gutter);
out.push(PathData::single(SubPath::polyline(
&[Point::new(x, y), Point::new(x + w, y), Point::new(x + w, y + h), Point::new(x, y + h)],
true,
)));
}
}
out
}
+228
View File
@@ -0,0 +1,228 @@
//! Least-squares cubic Bézier fitting of point sequences (the classic "fit a cubic with fixed end
//! tangents, then reparameterise with Newton steps, then split at the worst point" approach from
//! Graphics Gems, written from the published description).
use kurbo::{CubicBez, ParamCurve, ParamCurveDeriv, Point, Vec2};
/// Maximum Newton reparameterisation rounds per fit.
const MAX_REPARAM: usize = 6;
/// Unit vector or `None` if (nearly) zero.
pub(crate) fn unit(v: Vec2) -> Option<Vec2> {
let l = v.hypot();
if l > 1e-12 && l.is_finite() { Some(v / l) } else { None }
}
/// Direction the curve leaves `p0` (robust to retracted handles).
pub(crate) fn start_tangent(c: &CubicBez) -> Vec2 {
unit(c.p1 - c.p0).or_else(|| unit(c.p2 - c.p0)).or_else(|| unit(c.p3 - c.p0)).unwrap_or(Vec2::new(1.0, 0.0))
}
/// Direction the curve arrives at `p3`, i.e. pointing *forward* along the travel direction.
pub(crate) fn end_tangent(c: &CubicBez) -> Vec2 {
unit(c.p3 - c.p2).or_else(|| unit(c.p3 - c.p1)).or_else(|| unit(c.p3 - c.p0)).unwrap_or(Vec2::new(1.0, 0.0))
}
/// Fit a chain of cubics through `pts` with maximum (parametric) error `tol`.
///
/// `t0` is the unit tangent leaving `pts[0]`; `t1` is the unit tangent arriving at the last point
/// (forward direction). Consecutive output cubics are G1 continuous.
pub(crate) fn fit_cubics(pts: &[Point], t0: Vec2, t1: Vec2, tol: f64) -> Vec<CubicBez> {
let mut out = Vec::new();
let pts = dedup(pts);
if pts.len() < 2 {
return out;
}
fit_rec(&pts, t0, t1, tol.max(1e-9), &mut out, 0);
out
}
fn dedup(pts: &[Point]) -> Vec<Point> {
let mut v: Vec<Point> = Vec::with_capacity(pts.len());
for &p in pts {
if v.last().is_none_or(|q: &Point| q.distance(p) > 1e-12) {
v.push(p);
}
}
v
}
fn fit_rec(pts: &[Point], t0: Vec2, t1: Vec2, tol: f64, out: &mut Vec<CubicBez>, depth: usize) {
let n = pts.len();
if n == 2 {
let d = pts[0].distance(pts[1]) / 3.0;
out.push(CubicBez::new(pts[0], pts[0] + t0 * d, pts[1] - t1 * d, pts[1]));
return;
}
let (c, err, split) = fit_single(pts, t0, t1);
if err <= tol || depth > 40 {
out.push(c);
return;
}
// Split at the worst point with a centre tangent estimated from its neighbours.
let split = split.clamp(1, n - 2);
let tc = unit(pts[split + 1] - pts[split - 1]).unwrap_or(t0);
fit_rec(&pts[..=split], t0, tc, tol, out, depth + 1);
fit_rec(&pts[split..], tc, t1, tol, out, depth + 1);
}
/// Best single cubic for `pts` with the given end tangents. Returns (curve, max error, index of
/// the worst point).
pub(crate) fn fit_single(pts: &[Point], t0: Vec2, t1: Vec2) -> (CubicBez, f64, usize) {
let mut u = chord_params(pts);
let mut best = generate(pts, &u, t0, t1);
let (mut best_err, mut best_split) = max_error(pts, &best, &u);
for _ in 0..MAX_REPARAM {
for (i, ui) in u.iter_mut().enumerate() {
*ui = newton(&best, pts[i], *ui);
}
let c = generate(pts, &u, t0, t1);
let (e, s) = max_error(pts, &c, &u);
if e < best_err {
best = c;
best_err = e;
best_split = s;
} else {
break;
}
}
(best, best_err, best_split)
}
fn chord_params(pts: &[Point]) -> Vec<f64> {
let mut u = Vec::with_capacity(pts.len());
let mut acc = 0.0;
u.push(0.0);
for w in pts.windows(2) {
acc += w[0].distance(w[1]);
u.push(acc);
}
if acc > 0.0 {
for x in &mut u {
*x /= acc;
}
}
u
}
fn bern(t: f64) -> [f64; 4] {
let s = 1.0 - t;
[s * s * s, 3.0 * s * s * t, 3.0 * s * t * t, t * t * t]
}
/// Solve the 2x2 normal equations for the handle lengths along the fixed tangents.
fn generate(pts: &[Point], u: &[f64], t0: Vec2, t1: Vec2) -> CubicBez {
let p0 = pts[0];
let p3 = *pts.last().unwrap();
let dir_in = -t1; // handle at the end points backwards
let (mut c00, mut c01, mut c11, mut x0, mut x1) = (0.0, 0.0, 0.0, 0.0, 0.0);
for (p, &t) in pts.iter().zip(u) {
let b = bern(t);
let a0 = t0 * b[1];
let a1 = dir_in * b[2];
c00 += a0.dot(a0);
c01 += a0.dot(a1);
c11 += a1.dot(a1);
let tmp = *p - (p0.to_vec2() * (b[0] + b[1]) + p3.to_vec2() * (b[2] + b[3])).to_point();
x0 += a0.dot(tmp);
x1 += a1.dot(tmp);
}
let det = c00 * c11 - c01 * c01;
let seg = p0.distance(p3);
let eps = 1e-6 * seg;
let (mut al, mut ar) = if det.abs() > 1e-12 { ((x0 * c11 - x1 * c01) / det, (c00 * x1 - c01 * x0) / det) } else { (seg / 3.0, seg / 3.0) };
if !al.is_finite() || !ar.is_finite() || al < eps || ar < eps {
al = seg / 3.0;
ar = seg / 3.0;
}
// Guard against wild handles on nearly-degenerate data.
let cap = seg * 4.0 + 1e-9;
al = al.min(cap);
ar = ar.min(cap);
CubicBez::new(p0, p0 + t0 * al, p3 + dir_in * ar, p3)
}
fn max_error(pts: &[Point], c: &CubicBez, u: &[f64]) -> (f64, usize) {
let mut e = 0.0;
let mut idx = pts.len() / 2;
for (i, (p, &t)) in pts.iter().zip(u).enumerate() {
let d = c.eval(t).distance(*p);
if d > e {
e = d;
idx = i;
}
}
(e, idx)
}
fn newton(c: &CubicBez, p: Point, t: f64) -> f64 {
let d1 = c.deriv();
let d2 = d1.deriv();
let q = c.eval(t) - p;
let q1 = d1.eval(t).to_vec2();
let q2 = d2.eval(t).to_vec2();
let num = q.dot(q1);
let den = q1.dot(q1) + q.dot(q2);
if den.abs() < 1e-12 {
return t;
}
let nt = t - num / den;
if nt.is_finite() { nt.clamp(0.0, 1.0) } else { t }
}
/// Is the cubic (within `tol`) a straight line from p0 to p3 with handles inside the chord?
pub(crate) fn is_straight(c: &CubicBez, tol: f64) -> bool {
let chord = c.p3 - c.p0;
let len = chord.hypot();
if len < 1e-12 {
return c.p1.distance(c.p0) <= tol && c.p2.distance(c.p0) <= tol;
}
let dir = chord / len;
[c.p1, c.p2].iter().all(|&h| {
let v = h - c.p0;
let along = v.dot(dir);
v.cross(dir).abs() <= tol && along >= -tol && along <= len + tol
})
}
/// Sample a cubic at `n+1` evenly spaced parameters (including both ends).
pub(crate) fn sample(c: &CubicBez, n: usize, out: &mut Vec<Point>, include_start: bool) {
let start = if include_start { 0 } else { 1 };
for i in start..=n {
out.push(c.eval(i as f64 / n as f64));
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fits_a_cubic_exactly() {
let c = CubicBez::new((0.0, 0.0), (30.0, 60.0), (70.0, 60.0), (100.0, 0.0));
let mut pts = Vec::new();
sample(&c, 40, &mut pts, true);
let out = fit_cubics(&pts, start_tangent(&c), end_tangent(&c), 0.05);
assert_eq!(out.len(), 1);
assert!(out[0].p1.distance(c.p1) < 1.0, "{:?}", out[0]);
}
#[test]
fn splits_when_needed() {
// A zig-zag cannot be fitted with one cubic.
let pts: Vec<Point> = (0..=40).map(|i| Point::new(i as f64, ((i as f64) * 0.5).sin() * 10.0)).collect();
let out = fit_cubics(&pts, Vec2::new(1.0, 0.0), Vec2::new(1.0, 0.0), 0.1);
assert!(out.len() > 1);
for w in out.windows(2) {
assert!(w[0].p3.distance(w[1].p0) < 1e-9);
}
}
#[test]
fn straight_detection() {
let c = CubicBez::new((0.0, 0.0), (3.0, 0.0), (6.0, 0.0), (9.0, 0.0));
assert!(is_straight(&c, 1e-6));
let c = CubicBez::new((0.0, 0.0), (3.0, 1.0), (6.0, 0.0), (9.0, 0.0));
assert!(!is_straight(&c, 1e-6));
}
}
+29
View File
@@ -0,0 +1,29 @@
//! DrawCraft path operations: booleans (Pathfinder), shape builder regions, offset, outline stroke,
//! simplify and the other Object → Path commands.
//!
//! Booleans are curve-preserving: `linesweeper`'s robust sweep-line works directly on cubic
//! Béziers, and the pieces it splits curves into are refitted afterwards so results carry few
//! anchors. See the crate README for the API overview.
#![forbid(unsafe_code)]
mod boolean;
mod edit;
mod fit;
mod offset;
mod pathfinder;
pub use boolean::{BoolOp, DEFAULT_PRECISION, area, boolean, boolean_n, normalize, try_boolean, try_normalize, unite_all};
pub use edit::{
AverageAxis, SimplifyOptions, add_anchor_points, average, join, remove_redundant_points, simplify, simplify_with, smooth, split_into_grid,
};
pub use offset::{Cap, Join, offset_path, outline_stroke};
pub use pathfinder::{PathfinderOp, Region, Shape, merge_regions, pathfinder, region_at, regions};
/// Errors from fallible operations.
#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
pub enum PathOpsError {
#[error("input contains NaN or infinite coordinates")]
NonFinite,
#[error("input path could not be closed")]
OpenPath,
}
+76
View File
@@ -0,0 +1,76 @@
//! Offset Path and Outline Stroke.
use drawcraft_geom::{FillRule, PathData, SubPath};
use kurbo::{BezPath, PathEl, Stroke, StrokeOpts};
pub use kurbo::{Cap, Join};
use crate::boolean::{Arrangement, DEFAULT_PRECISION, Tidy, all_contours_to_path, fill_bezpath, normalize_bez};
/// Flattening/fit tolerance handed to kurbo's stroker.
const STROKE_TOL: f64 = 1e-3;
fn stroke_bez(bp: &BezPath, width: f64, cap: Cap, join: Join, miter_limit: f64) -> BezPath {
let style = Stroke::new(width).with_join(join).with_caps(cap).with_miter_limit(miter_limit.max(1.0));
close_all(&kurbo::stroke(bp.iter(), &style, &StrokeOpts::default(), STROKE_TOL))
}
/// kurbo's stroker may leave the outline of an open path without a final `ClosePath`.
fn close_all(bp: &BezPath) -> BezPath {
let mut out = BezPath::new();
let mut open = false;
for el in bp.iter() {
match el {
PathEl::MoveTo(_) => {
if open {
out.close_path();
}
open = true;
}
PathEl::ClosePath => open = false,
_ => {}
}
out.push(el);
}
if open {
out.close_path();
}
out
}
/// Object → Path → Outline Stroke: the filled area painted by stroking `path` with `width`.
/// Overlaps produced by the stroker are removed, so the result is a clean compound path.
pub fn outline_stroke(path: &PathData, width: f64, cap: Cap, join: Join, miter_limit: f64) -> PathData {
if width <= 0.0 || !width.is_finite() || path.is_empty() {
return PathData::default();
}
let s = stroke_bez(&path.to_bezpath(), width, cap, join, miter_limit);
if s.elements().is_empty() {
return PathData::default();
}
normalize_bez(&s, FillRule::NonZero).map(|c| all_contours_to_path(&c, &Tidy::free(DEFAULT_PRECISION))).unwrap_or_default()
}
/// Object → Path → Offset Path. Positive `delta` grows the filled area, negative insets it.
/// Closed subpaths are treated as a non-zero filled region; open subpaths are outlined with a
/// stroke of width `2|delta|` (butt caps).
pub fn offset_path(path: &PathData, delta: f64, join: Join, miter_limit: f64) -> PathData {
if !delta.is_finite() || path.is_empty() {
return PathData::default();
}
let closed = PathData::new(path.subpaths.iter().filter(|s| s.closed && s.anchors.len() > 1).cloned().collect());
let open: Vec<SubPath> = path.subpaths.iter().filter(|s| !s.closed && s.anchors.len() > 1).cloned().collect();
let d = delta.abs();
let fill = fill_bezpath(&closed);
let ring = if d > 0.0 { stroke_bez(&closed.to_bezpath(), 2.0 * d, Cap::Butt, join, miter_limit) } else { BezPath::new() };
let open_bp = if d > 0.0 && !open.is_empty() {
stroke_bez(&PathData::new(open).to_bezpath(), 2.0 * d, Cap::Butt, join, miter_limit)
} else {
BezPath::new()
};
let Ok(arr) = Arrangement::new(&[(fill, FillRule::NonZero), (ring, FillRule::NonZero), (open_bp, FillRule::NonZero)]) else {
return PathData::default();
};
let c = if delta >= 0.0 { arr.contours(|m| m[0] || m[1] || m[2]) } else { arr.contours(|m| m[0] && !m[1]) };
all_contours_to_path(&c, &Tidy::free(DEFAULT_PRECISION))
}
+275
View File
@@ -0,0 +1,275 @@
//! Illustrator's Pathfinder panel and Shape Builder regions, over an ordered stack of shapes
//! (index 0 = back-most, last = front-most).
use drawcraft_geom::{FillRule, PathData};
use kurbo::{BezPath, ParamCurve, ParamCurveNearest, Point, Shape as _};
use crate::boolean::{Arrangement, Seg, all_contours_to_path, contours_to_path, fill_bezpath, segs_to_subpath, unite_all};
/// A filled shape in a Pathfinder stack. `key` identifies its paint (e.g. a hashed fill colour);
/// results carry the key of the object whose paint they keep.
#[derive(Clone, Debug, PartialEq)]
pub struct Shape {
pub path: PathData,
pub rule: FillRule,
pub key: u64,
}
impl Shape {
pub fn new(path: PathData, rule: FillRule, key: u64) -> Self {
Self { path, rule, key }
}
}
/// The ten Pathfinder panel operations.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum PathfinderOp {
/// Shape mode: union of everything; result keeps the front-most key.
Unite,
/// Shape mode: back-most minus everything in front; keeps the back-most key.
MinusFront,
/// Shape mode: area covered by *all* shapes; keeps the front-most key.
Intersect,
/// Shape mode: area covered by an odd number of shapes; keeps the front-most key.
Exclude,
/// Split into every non-overlapping face; each face keeps the key of the front-most shape
/// covering it.
Divide,
/// Remove hidden parts; one result per shape that remains visible (no merging).
Trim,
/// Trim, then merge touching/overlapping visible parts with the same key.
Merge,
/// Keep the visible parts of lower shapes that lie inside the front-most shape (which is removed).
Crop,
/// Split every edge at intersections; returns *open* paths keyed by the shape they bound.
Outline,
/// Front-most minus everything behind; keeps the front-most key.
MinusBack,
}
/// One face of the planar arrangement of a set of shapes (Shape Builder / Live Paint face).
#[derive(Clone, Debug, PartialEq)]
pub struct Region {
/// Face outline (outer contour plus holes, consistently oriented).
pub path: PathData,
/// Indices of the input shapes covering this face, ascending.
pub sources: Vec<usize>,
}
impl Region {
/// Front-most covering shape.
pub fn top(&self) -> usize {
*self.sources.last().expect("regions are never uncovered")
}
/// Does the face contain `p`?
pub fn contains(&self, p: Point) -> bool {
self.path.to_bezpath().winding(p) != 0
}
}
fn arrangement(shapes: &[Shape]) -> Option<Arrangement> {
let bps: Vec<(BezPath, FillRule)> = shapes.iter().map(|s| (fill_bezpath(&s.path), s.rule)).collect();
Arrangement::new(&bps).ok()
}
fn topmost(m: &[bool]) -> Option<usize> {
m.iter().rposition(|&b| b)
}
fn one(path: PathData, key: u64) -> Vec<Shape> {
if path.is_empty() { Vec::new() } else { vec![Shape::new(path, FillRule::NonZero, key)] }
}
/// Run a Pathfinder operation over `shapes` (back → front).
pub fn pathfinder(op: PathfinderOp, shapes: &[Shape]) -> Vec<Shape> {
let n = shapes.len();
if n == 0 {
return Vec::new();
}
let front_key = shapes[n - 1].key;
if op == PathfinderOp::Unite {
let v: Vec<(&PathData, FillRule)> = shapes.iter().map(|s| (&s.path, s.rule)).collect();
return one(unite_all(&v), front_key);
}
let Some(arr) = arrangement(shapes) else { return Vec::new() };
let p = &arr.tidy;
match op {
PathfinderOp::Unite => unreachable!(),
PathfinderOp::MinusFront => one(all_contours_to_path(&arr.contours(|m| m[0] && !m[1..].iter().any(|&b| b)), p), shapes[0].key),
PathfinderOp::MinusBack => one(all_contours_to_path(&arr.contours(|m| m[n - 1] && !m[..n - 1].iter().any(|&b| b)), p), front_key),
PathfinderOp::Intersect => one(all_contours_to_path(&arr.contours(|m| m.iter().all(|&b| b)), p), front_key),
PathfinderOp::Exclude => one(all_contours_to_path(&arr.contours(|m| m.iter().filter(|&&b| b).count() % 2 == 1), p), front_key),
PathfinderOp::Divide => regions_of(&arr)
.into_iter()
.map(|r| {
let key = shapes[r.top()].key;
Shape::new(r.path, FillRule::NonZero, key)
})
.collect(),
PathfinderOp::Trim => (0..n).flat_map(|i| one(all_contours_to_path(&arr.contours(|m| topmost(m) == Some(i)), p), shapes[i].key)).collect(),
PathfinderOp::Merge => {
let mut keys: Vec<u64> = Vec::new();
for s in shapes {
if !keys.contains(&s.key) {
keys.push(s.key);
}
}
let mut out = Vec::new();
for k in keys {
let c = arr.contours(|m| topmost(m).is_some_and(|t| shapes[t].key == k));
for g in c.grouped() {
out.extend(one(contours_to_path(&c, g, p), k));
}
}
out
}
PathfinderOp::Crop => {
if n < 2 {
return Vec::new();
}
(0..n - 1)
.flat_map(|i| {
let c = arr.contours(|m| m[n - 1] && topmost(&m[..n - 1]) == Some(i));
one(all_contours_to_path(&c, p), shapes[i].key)
})
.collect()
}
PathfinderOp::Outline => outline_edges(&arr, shapes),
}
}
fn regions_of(arr: &Arrangement) -> Vec<Region> {
let mut out = Vec::new();
for mask in arr.distinct_masks() {
let c = arr.contours(|m| m == mask.as_slice());
let sources: Vec<usize> = mask.iter().enumerate().filter(|(_, b)| **b).map(|(i, _)| i).collect();
for g in c.grouped() {
let path = contours_to_path(&c, g, &arr.tidy);
if !path.is_empty() {
out.push(Region { path, sources: sources.clone() });
}
}
}
out
}
/// All faces of the planar arrangement of `shapes` (every area covered by at least one shape,
/// split wherever coverage changes).
pub fn regions(shapes: &[Shape]) -> Vec<Region> {
arrangement(shapes).map(|a| regions_of(&a)).unwrap_or_default()
}
/// The face under `point`, if any (Shape Builder hover/click).
pub fn region_at(shapes: &[Shape], point: Point) -> Option<Region> {
regions(shapes).into_iter().find(|r| r.contains(point))
}
/// Merge Shape Builder regions into one path (their union).
pub fn merge_regions(regions: &[&Region]) -> PathData {
let v: Vec<(&PathData, FillRule)> = regions.iter().map(|r| (&r.path, FillRule::NonZero)).collect();
unite_all(&v)
}
/// Pathfinder Outline: split every shape's boundary at junctions of the arrangement.
fn outline_edges(arr: &Arrangement, shapes: &[Shape]) -> Vec<Shape> {
let junctions = arr.junctions();
let scale = shapes.iter().filter_map(|s| s.path.control_bounds()).map(|r| r.width().max(r.height())).fold(1.0, f64::max);
let tol = scale * 1e-7;
// (chain, key, source index)
let mut chains: Vec<(Vec<Seg>, u64)> = Vec::new();
for s in shapes {
for sp in &s.path.subpaths {
if sp.anchors.len() < 2 {
continue;
}
let mut sp = sp.clone();
sp.closed = true;
// Split each segment at junctions; record which piece starts are breaks.
let mut pieces: Vec<(Seg, bool)> = Vec::new();
for i in 0..sp.segment_count() {
let c = sp.segment(i);
let line = sp.segment_is_line(i);
let mut ts: Vec<f64> = junctions
.iter()
.filter_map(|&j| {
let nr = c.nearest(j, 1e-9);
(nr.distance_sq.sqrt() <= tol.max(1e-6)).then_some(nr.t)
})
.collect();
ts.sort_by(f64::total_cmp);
let start_is_junction = ts.first().is_some_and(|&t| t < 1e-9);
let mut cuts: Vec<f64> = vec![0.0];
cuts.extend(ts.into_iter().filter(|&t| t > 1e-9 && t < 1.0 - 1e-9));
cuts.push(1.0);
cuts.dedup_by(|a, b| (*a - *b).abs() < 1e-9);
for (k, w) in cuts.windows(2).enumerate() {
let sub = c.subsegment(w[0]..w[1]);
let seg = if line { Seg::line(sub.p0, sub.p3) } else { Seg { c: sub, line: false } };
pieces.push((seg, if k == 0 { start_is_junction } else { true }));
}
}
// Chain pieces between breaks.
let first_break = pieces.iter().position(|(_, b)| *b);
match first_break {
None => {
let segs: Vec<Seg> = pieces.into_iter().map(|(s, _)| s).collect();
chains.push((segs, s.key));
}
Some(k) => {
pieces.rotate_left(k);
let mut cur: Vec<Seg> = Vec::new();
for (seg, brk) in pieces {
if brk && !cur.is_empty() {
chains.push((std::mem::take(&mut cur), s.key));
}
cur.push(seg);
}
if !cur.is_empty() {
chains.push((cur, s.key));
}
}
}
}
}
// De-duplicate coincident chains, keeping the front-most key (later shapes overwrite).
let mut out: Vec<(Vec<Seg>, u64)> = Vec::new();
let sig = |c: &[Seg]| -> (Point, Point, Point) {
let a = c[0].c.p0;
let b = c[c.len() - 1].c.p3;
let mid = mid_point(c);
(a, b, mid)
};
for (chain, key) in chains {
let (a, b, m) = sig(&chain);
let near = |p: Point, q: Point| p.distance(q) <= tol.max(1e-6) * 10.0;
if let Some(e) = out.iter_mut().find(|(c, _)| {
let (a2, b2, m2) = sig(c);
near(m, m2) && ((near(a, a2) && near(b, b2)) || (near(a, b2) && near(b, a2)))
}) {
e.1 = key;
} else {
out.push((chain, key));
}
}
out.into_iter()
.filter_map(|(chain, key)| {
// Chain joints are the shapes' own anchors, so there is nothing to re-merge.
let sp = segs_to_subpath(&chain, false)?;
Some(Shape::new(PathData::single(sp), FillRule::NonZero, key))
})
.collect()
}
fn mid_point(c: &[Seg]) -> Point {
let lens: Vec<f64> = c.iter().map(|s| kurbo::ParamCurveArclen::arclen(&s.c, 1e-6)).collect();
let total: f64 = lens.iter().sum();
let mut acc = 0.0;
for (s, l) in c.iter().zip(&lens) {
if acc + l >= total / 2.0 && *l > 0.0 {
let t = kurbo::ParamCurveArclen::inv_arclen(&s.c, total / 2.0 - acc, 1e-6);
return s.c.eval(t);
}
acc += l;
}
c[0].c.p0
}
+437
View File
@@ -0,0 +1,437 @@
use std::f64::consts::PI;
use drawcraft_geom::{FillRule, PathData, SubPath};
use drawcraft_pathops::*;
use kurbo::{ParamCurve, Point, Rect, Shape as _};
const NZ: FillRule = FillRule::NonZero;
fn rect(x: f64, y: f64, w: f64, h: f64) -> PathData {
PathData::from_bezpath(&Rect::new(x, y, x + w, y + h).to_path(1e-6))
}
fn circle(cx: f64, cy: f64, r: f64) -> PathData {
PathData::from_bezpath(&kurbo::Circle::new((cx, cy), r).to_path(1e-4))
}
fn ar(p: &PathData) -> f64 {
area(p, NZ)
}
fn close(a: f64, b: f64, rel: f64) -> bool {
(a - b).abs() <= rel * a.abs().max(b.abs()).max(1.0)
}
fn shape(p: PathData, key: u64) -> Shape {
Shape::new(p, NZ, key)
}
fn has_curves(p: &PathData) -> bool {
p.anchors().any(|(_, _, a)| a.has_in() || a.has_out())
}
#[test]
fn union_of_rects() {
let u = boolean(&rect(0.0, 0.0, 10.0, 10.0), NZ, &rect(5.0, 5.0, 10.0, 10.0), NZ, BoolOp::Union);
assert!(close(ar(&u), 175.0, 1e-9));
assert_eq!(u.subpaths.len(), 1);
assert_eq!(u.anchor_count(), 8);
}
#[test]
fn intersect_of_rects() {
let i = boolean(&rect(0.0, 0.0, 10.0, 10.0), NZ, &rect(5.0, 5.0, 10.0, 10.0), NZ, BoolOp::Intersect);
assert!(close(ar(&i), 25.0, 1e-9));
assert_eq!(i.anchor_count(), 4);
}
#[test]
fn difference_of_rects() {
let d = boolean(&rect(0.0, 0.0, 10.0, 10.0), NZ, &rect(5.0, 5.0, 10.0, 10.0), NZ, BoolOp::Difference);
assert!(close(ar(&d), 75.0, 1e-9));
assert_eq!(d.anchor_count(), 6);
}
#[test]
fn xor_of_rects() {
let x = boolean(&rect(0.0, 0.0, 10.0, 10.0), NZ, &rect(5.0, 5.0, 10.0, 10.0), NZ, BoolOp::Xor);
assert!(close(ar(&x), 150.0, 1e-9));
}
#[test]
fn disjoint_union_keeps_both() {
let u = boolean(&rect(0.0, 0.0, 1.0, 1.0), NZ, &rect(5.0, 5.0, 1.0, 1.0), NZ, BoolOp::Union);
assert_eq!(u.subpaths.len(), 2);
assert!(close(ar(&u), 2.0, 1e-9));
}
#[test]
fn a_minus_a_is_empty() {
let c = circle(3.0, 4.0, 7.0);
assert!(boolean(&c, NZ, &c, NZ, BoolOp::Difference).is_empty());
}
#[test]
fn union_idempotent_rect() {
let r = rect(1.0, 2.0, 3.0, 4.0);
let u = boolean(&r, NZ, &r, NZ, BoolOp::Union);
assert_eq!(u.anchor_count(), 4);
assert!(close(ar(&u), 12.0, 1e-9));
}
#[test]
fn circle_union_is_curve_preserving_and_compact() {
let u = boolean(&circle(0.0, 0.0, 10.0), NZ, &circle(10.0, 0.0, 10.0), NZ, BoolOp::Union);
assert!(has_curves(&u));
assert!(u.anchor_count() <= 12, "anchors: {}", u.anchor_count());
// Lens area: 2 r² acos(d/2r) − (d/2)√(4r² − d²)
let (r, d): (f64, f64) = (10.0, 10.0);
let lens = 2.0 * r * r * (d / (2.0 * r)).acos() - d / 2.0 * (4.0 * r * r - d * d).sqrt();
assert!(close(ar(&u), 2.0 * PI * r * r - lens, 1e-3));
}
#[test]
fn circle_intersect_rect_keeps_curve() {
let i = boolean(&circle(0.0, 0.0, 10.0), NZ, &rect(0.0, -20.0, 20.0, 40.0), NZ, BoolOp::Intersect);
assert!(has_curves(&i));
assert!(close(ar(&i), PI * 50.0, 1e-3));
assert!(i.anchor_count() <= 5, "anchors: {}", i.anchor_count());
}
#[test]
fn hole_from_difference() {
let d = boolean(&rect(0.0, 0.0, 10.0, 10.0), NZ, &rect(3.0, 3.0, 4.0, 4.0), NZ, BoolOp::Difference);
assert_eq!(d.subpaths.len(), 2);
assert!(close(ar(&d), 84.0, 1e-9));
// Consistently oriented: works with both fill rules.
assert!(close(area(&d, FillRule::EvenOdd), 84.0, 1e-9));
}
#[test]
fn fill_rules_differ_for_nested_same_direction() {
let mut p = rect(0.0, 0.0, 10.0, 10.0);
p.subpaths.extend(rect(3.0, 3.0, 4.0, 4.0).subpaths);
assert!(close(area(&p, FillRule::NonZero), 100.0, 1e-9));
assert!(close(area(&p, FillRule::EvenOdd), 84.0, 1e-9));
let u = boolean(&p, FillRule::EvenOdd, &rect(20.0, 0.0, 1.0, 1.0), NZ, BoolOp::Union);
assert!(close(ar(&u), 85.0, 1e-9));
}
#[test]
fn open_paths_are_closed_for_fill() {
let tri = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0), Point::new(0.0, 10.0)], false));
assert!(close(ar(&tri), 50.0, 1e-9));
}
#[test]
fn non_finite_input_errors() {
let bad = PathData::single(SubPath::polyline(&[Point::new(f64::NAN, 0.0), Point::new(1.0, 0.0), Point::new(0.0, 1.0)], true));
let r = try_boolean(&bad, NZ, &rect(0.0, 0.0, 1.0, 1.0), NZ, BoolOp::Union, DEFAULT_PRECISION);
assert_eq!(r, Err(PathOpsError::NonFinite));
}
#[test]
fn normalize_figure_eight() {
let p = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(10.0, 10.0), Point::new(10.0, 0.0), Point::new(0.0, 10.0)], true));
let n = normalize(&p, NZ);
assert_eq!(n.subpaths.len(), 2);
assert!(close(ar(&n), 50.0, 1e-9));
}
fn stack() -> Vec<Shape> {
vec![shape(rect(0.0, 0.0, 10.0, 10.0), 1), shape(rect(5.0, 5.0, 10.0, 10.0), 2)]
}
#[test]
fn pf_unite_takes_front_key() {
let r = pathfinder(PathfinderOp::Unite, &stack());
assert_eq!(r.len(), 1);
assert_eq!(r[0].key, 2);
assert!(close(ar(&r[0].path), 175.0, 1e-9));
}
#[test]
fn pf_minus_front_and_back() {
let r = pathfinder(PathfinderOp::MinusFront, &stack());
assert_eq!(r[0].key, 1);
assert!(close(ar(&r[0].path), 75.0, 1e-9));
let r = pathfinder(PathfinderOp::MinusBack, &stack());
assert_eq!(r[0].key, 2);
assert!(close(ar(&r[0].path), 75.0, 1e-9));
}
#[test]
fn pf_intersect_and_exclude_three() {
let s = vec![shape(rect(0.0, 0.0, 10.0, 10.0), 1), shape(rect(5.0, 0.0, 10.0, 10.0), 2), shape(rect(8.0, 0.0, 10.0, 10.0), 3)];
let i = pathfinder(PathfinderOp::Intersect, &s);
assert!(close(ar(&i[0].path), 20.0, 1e-9));
// Odd coverage: [0,5)=1, [5,8)=2, [8,10)=3, [10,15)=2, [15,18)=1 → 5+2+3 = 10 wide.
let x = pathfinder(PathfinderOp::Exclude, &s);
assert!(close(ar(&x[0].path), 100.0, 1e-9));
}
#[test]
fn pf_divide_faces() {
let r = pathfinder(PathfinderOp::Divide, &stack());
assert_eq!(r.len(), 3);
let total: f64 = r.iter().map(|s| ar(&s.path)).sum();
assert!(close(total, 175.0, 1e-9));
let overlap = r.iter().find(|s| close(ar(&s.path), 25.0, 1e-9)).unwrap();
assert_eq!(overlap.key, 2);
}
#[test]
fn pf_trim_removes_hidden() {
let r = pathfinder(PathfinderOp::Trim, &stack());
assert_eq!(r.len(), 2);
assert!(close(ar(&r[0].path), 75.0, 1e-9));
assert!(close(ar(&r[1].path), 100.0, 1e-9));
}
#[test]
fn pf_merge_same_key() {
let mut s = stack();
s[1].key = 1;
let r = pathfinder(PathfinderOp::Merge, &s);
assert_eq!(r.len(), 1);
assert!(close(ar(&r[0].path), 175.0, 1e-9));
let r = pathfinder(PathfinderOp::Merge, &stack());
assert_eq!(r.len(), 2);
}
#[test]
fn pf_crop() {
let s = vec![shape(rect(0.0, 0.0, 10.0, 10.0), 1), shape(rect(10.0, 0.0, 10.0, 10.0), 2), shape(rect(5.0, 0.0, 10.0, 5.0), 3)];
let r = pathfinder(PathfinderOp::Crop, &s);
assert_eq!(r.len(), 2);
assert!(r.iter().all(|x| close(ar(&x.path), 25.0, 1e-9)));
assert_eq!(r[0].key, 1);
}
#[test]
fn pf_outline_edges() {
let r = pathfinder(PathfinderOp::Outline, &stack());
assert_eq!(r.len(), 4, "{r:?}");
assert!(r.iter().all(|s| s.path.subpaths.iter().all(|sp| !sp.closed)));
let len: f64 = r.iter().map(|s| s.path.length()).sum();
assert!(close(len, 80.0, 1e-9));
}
#[test]
fn pf_outline_dedups_shared_edges() {
// Two squares sharing an edge exactly.
let s = vec![shape(rect(0.0, 0.0, 10.0, 10.0), 1), shape(rect(10.0, 0.0, 10.0, 10.0), 2)];
let r = pathfinder(PathfinderOp::Outline, &s);
let len: f64 = r.iter().map(|s| s.path.length()).sum();
assert!(close(len, 70.0, 1e-9), "len {len}");
}
#[test]
fn regions_and_region_at() {
let s = stack();
let rs = regions(&s);
assert_eq!(rs.len(), 3);
let hit = region_at(&s, Point::new(7.0, 7.0)).unwrap();
assert_eq!(hit.sources, vec![0, 1]);
assert!(close(ar(&hit.path), 25.0, 1e-9));
assert!(region_at(&s, Point::new(100.0, 100.0)).is_none());
let refs: Vec<&Region> = rs.iter().collect();
assert!(close(ar(&merge_regions(&refs)), 175.0, 1e-9));
}
#[test]
fn offset_circle_grows() {
let o = offset_path(&circle(0.0, 0.0, 10.0), 3.0, Join::Round, 4.0);
assert!(close(ar(&o), PI * 169.0, 2e-3), "{}", ar(&o));
assert!(o.anchor_count() <= 16, "anchors {}", o.anchor_count());
}
#[test]
fn offset_circle_insets() {
let o = offset_path(&circle(0.0, 0.0, 10.0), -4.0, Join::Round, 4.0);
assert!(close(ar(&o), PI * 36.0, 2e-3), "{}", ar(&o));
}
#[test]
fn offset_rect_joins() {
let r = rect(0.0, 0.0, 20.0, 10.0);
assert!(close(ar(&offset_path(&r, 2.0, Join::Miter, 4.0)), 24.0 * 14.0, 1e-6));
assert_eq!(offset_path(&r, 2.0, Join::Miter, 4.0).anchor_count(), 4);
assert!(close(ar(&offset_path(&r, 2.0, Join::Bevel, 4.0)), 24.0 * 14.0 - 4.0 * 2.0, 1e-6));
assert!(close(ar(&offset_path(&r, 2.0, Join::Round, 4.0)), 200.0 + 2.0 * 2.0 * 30.0 + PI * 4.0, 1e-3));
assert!(close(ar(&offset_path(&r, -2.0, Join::Miter, 4.0)), 16.0 * 6.0, 1e-6));
assert!(offset_path(&r, -6.0, Join::Miter, 4.0).is_empty());
}
#[test]
fn outline_stroke_of_line() {
let l = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(30.0, 40.0)], false));
let o = outline_stroke(&l, 4.0, Cap::Butt, Join::Miter, 4.0);
assert!(close(ar(&o), 200.0, 1e-6));
let o = outline_stroke(&l, 4.0, Cap::Square, Join::Miter, 4.0);
assert!(close(ar(&o), 216.0, 1e-6));
}
#[test]
fn outline_stroke_of_square_is_ring() {
let o = outline_stroke(&rect(0.0, 0.0, 10.0, 10.0), 2.0, Cap::Butt, Join::Miter, 10.0);
assert_eq!(o.subpaths.len(), 2);
assert!(close(ar(&o), 144.0 - 64.0, 1e-6));
}
#[test]
fn outline_stroke_self_overlap_is_cleaned() {
// A zig-zag whose stroke overlaps itself at the joins.
let z = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0), Point::new(0.0, 1.0), Point::new(10.0, 2.0)], false));
let o = outline_stroke(&z, 3.0, Cap::Round, Join::Round, 4.0);
assert!(!o.is_empty());
// Normalised output: its signed area equals its fill area.
let signed: f64 = o.subpaths.iter().map(|s| s.area()).sum::<f64>().abs();
assert!(close(signed, ar(&o), 1e-6));
}
fn dense_circle(r: f64, n: usize) -> PathData {
let pts: Vec<Point> =
(0..n).map(|i| Point::new(r * (i as f64 / n as f64 * 2.0 * PI).cos(), r * (i as f64 / n as f64 * 2.0 * PI).sin())).collect();
PathData::single(SubPath::polyline(&pts, true))
}
fn max_deviation(orig: &PathData, simp: &PathData) -> f64 {
let bp = orig.to_bezpath();
let mut worst: f64 = 0.0;
for seg in bp.segments() {
for i in 0..=8 {
let p = seg.eval(i as f64 / 8.0);
worst = worst.max(simp.nearest(p).unwrap().4);
}
}
worst
}
#[test]
fn simplify_dense_circle() {
let c = dense_circle(50.0, 200);
let s = simplify(&c, 0.5);
assert!(s.anchor_count() <= 12, "anchors {}", s.anchor_count());
assert!(max_deviation(&c, &s) <= 0.5 + 1e-6);
assert!(s.subpaths[0].closed);
}
#[test]
fn simplify_keeps_square_corners() {
let mut pts = Vec::new();
for (a, b) in [((0.0, 0.0), (10.0, 0.0)), ((10.0, 0.0), (10.0, 10.0)), ((10.0, 10.0), (0.0, 10.0)), ((0.0, 10.0), (0.0, 0.0))] {
for i in 0..5 {
let t = i as f64 / 5.0;
pts.push(Point::new(a.0 + (b.0 - a.0) * t, a.1 + (b.1 - a.1) * t));
}
}
let sq = PathData::single(SubPath::polyline(&pts, true));
let s = simplify(&sq, 0.1);
assert_eq!(s.anchor_count(), 4);
assert!(!has_curves(&s));
}
#[test]
fn simplify_straight_lines_option() {
let c = dense_circle(50.0, 200);
let s = simplify_with(&c, &SimplifyOptions { tolerance: 1.0, straight_lines: true, ..Default::default() });
assert!(!has_curves(&s));
assert!(s.anchor_count() < 40);
assert!(max_deviation(&c, &s) <= 1.0 + 1e-6);
}
#[test]
fn smooth_rounds_corners() {
let s = smooth(&rect(0.0, 0.0, 10.0, 10.0), 1.0);
assert!(s.anchors().all(|(_, _, a)| a.has_in() && a.has_out()));
let same = smooth(&rect(0.0, 0.0, 10.0, 10.0), 0.0);
assert_eq!(same, rect(0.0, 0.0, 10.0, 10.0));
}
#[test]
fn remove_redundant_collinear_and_curve() {
let p = PathData::single(SubPath::polyline(
&[Point::new(0.0, 0.0), Point::new(5.0, 0.0), Point::new(10.0, 0.0), Point::new(10.0, 10.0), Point::new(0.0, 10.0)],
true,
));
assert_eq!(remove_redundant_points(&p, 1e-6).anchor_count(), 4);
let c = add_anchor_points(&circle(0.0, 0.0, 10.0));
let n0 = c.anchor_count();
let r = remove_redundant_points(&c, 1e-3);
assert!(r.anchor_count() < n0, "{} vs {}", r.anchor_count(), n0);
assert!(close(ar(&r), ar(&c), 1e-4));
}
#[test]
fn add_anchor_points_doubles() {
let c = circle(0.0, 0.0, 10.0);
let a = add_anchor_points(&c);
assert_eq!(a.anchor_count(), 2 * c.anchor_count());
assert!(close(ar(&a), ar(&c), 1e-9));
let r = add_anchor_points(&rect(0.0, 0.0, 10.0, 10.0));
assert!(r.anchors().any(|(_, _, a)| a.p == Point::new(5.0, 0.0)));
}
#[test]
fn average_axes() {
let p = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(10.0, 4.0), Point::new(5.0, 8.0)], false));
let h = average(&p, &[(0, 0), (0, 1)], AverageAxis::Horizontal);
assert_eq!(h.subpaths[0].anchors[0].p, Point::new(0.0, 2.0));
assert_eq!(h.subpaths[0].anchors[1].p, Point::new(10.0, 2.0));
let b = average(&p, &[(0, 0), (0, 1), (0, 2)], AverageAxis::Both);
assert!(b.subpaths[0].anchors.iter().all(|a| a.p.distance(Point::new(5.0, 4.0)) < 1e-12));
let v = average(&p, &[(0, 0), (0, 1)], AverageAxis::Vertical);
assert_eq!(v.subpaths[0].anchors[0].p.x, 5.0);
}
#[test]
fn join_two_open_paths() {
let a = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0)], false));
let b = PathData::single(SubPath::polyline(&[Point::new(20.0, 5.0), Point::new(10.0, 0.0)], false));
let j = join(&[a.clone(), b], 1e-6);
assert_eq!(j.subpaths.len(), 1);
assert_eq!(j.anchor_count(), 3);
assert!(!j.subpaths[0].closed);
let c = PathData::single(SubPath::polyline(&[Point::new(12.0, 0.0), Point::new(20.0, 0.0)], false));
let j = join(&[a, c], 1e-6);
assert_eq!(j.anchor_count(), 4);
}
#[test]
fn join_single_path_closes() {
let a = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0), Point::new(5.0, 5.0)], false));
let j = join(&[a], 1e-6);
assert!(j.subpaths[0].closed);
assert_eq!(j.anchor_count(), 3);
let b = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0), Point::new(5.0, 5.0), Point::new(0.0, 0.0)], false));
assert_eq!(join(&[b], 1e-6).anchor_count(), 3);
}
#[test]
fn grid_split() {
let g = split_into_grid(Rect::new(0.0, 0.0, 100.0, 50.0), 2, 3, 5.0);
assert_eq!(g.len(), 6);
let w = (100.0 - 10.0) / 3.0;
let h = (50.0 - 5.0) / 2.0;
assert!(g.iter().all(|c| close(ar(c), w * h, 1e-9)));
assert_eq!(g[5].bounds().unwrap().x1, 100.0);
assert!(split_into_grid(Rect::new(0.0, 0.0, 10.0, 10.0), 0, 3, 1.0).is_empty());
}
#[test]
#[ignore]
fn timing_unite_1000_circles() {
let mut seed = 0x1234_5678_u64;
let mut rnd = || {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
(seed % 1_000_000) as f64 / 1_000_000.0
};
let circles: Vec<Shape> = (0..1000).map(|i| shape(circle(rnd() * 1000.0, rnd() * 1000.0, 5.0 + rnd() * 30.0), i)).collect();
let t = std::time::Instant::now();
let r = pathfinder(PathfinderOp::Unite, &circles);
println!("unite 1000 circles: {} ms, {} anchors", t.elapsed().as_millis(), r[0].path.anchor_count());
assert!(!r.is_empty());
}
+128
View File
@@ -0,0 +1,128 @@
use std::f64::consts::PI;
use drawcraft_geom::{FillRule, PathData, SubPath};
use drawcraft_pathops::*;
use kurbo::{ParamCurve, Point, Rect, Shape as _};
use proptest::prelude::*;
const NZ: FillRule = FillRule::NonZero;
fn arb_shape() -> impl Strategy<Value = PathData> {
prop_oneof![
(0.0..50.0f64, 0.0..50.0f64, 1.0..40.0f64, 1.0..40.0f64)
.prop_map(|(x, y, w, h)| PathData::from_bezpath(&Rect::new(x, y, x + w, y + h).to_path(1e-6))),
(0.0..50.0f64, 0.0..50.0f64, 1.0..25.0f64).prop_map(|(x, y, r)| PathData::from_bezpath(&kurbo::Circle::new((x, y), r).to_path(1e-4))),
]
}
fn close(a: f64, b: f64, rel: f64) -> bool {
(a - b).abs() <= rel * a.abs().max(b.abs()).max(1.0)
}
proptest! {
#![proptest_config(ProptestConfig { cases: 48, failure_persistence: None, ..ProptestConfig::default() })]
#[test]
fn inclusion_exclusion(a in arb_shape(), b in arb_shape()) {
let u = area(&boolean(&a, NZ, &b, NZ, BoolOp::Union), NZ);
let i = area(&boolean(&a, NZ, &b, NZ, BoolOp::Intersect), NZ);
let (aa, ab) = (area(&a, NZ), area(&b, NZ));
prop_assert!(close(u + i, aa + ab, 2e-3), "u={u} i={i} a={aa} b={ab}");
}
#[test]
fn grid_rects_with_shared_edges(v in proptest::collection::vec((0u8..6, 0u8..6, 1u8..5, 1u8..5), 2..6)) {
// Integer rectangles: lots of coincident edges and touching corners.
let shapes: Vec<Shape> = v.iter().enumerate().map(|(i, &(x, y, w, h))| {
let r = Rect::new(x as f64, y as f64, (x + w) as f64, (y + h) as f64);
Shape::new(PathData::from_bezpath(&r.to_path(1e-6)), NZ, i as u64)
}).collect();
let faces = pathfinder(PathfinderOp::Divide, &shapes);
let sum: f64 = faces.iter().map(|f| area(&f.path, NZ)).sum();
let u = pathfinder(PathfinderOp::Unite, &shapes);
let ua = u.first().map(|s| area(&s.path, NZ)).unwrap_or(0.0);
// Exact cell count on the integer grid.
let mut cells = 0;
for cx in 0..12u8 { for cy in 0..12u8 {
if v.iter().any(|&(x, y, w, h)| cx >= x && cx < x + w && cy >= y && cy < y + h) { cells += 1; }
}}
prop_assert!(close(ua, cells as f64, 1e-9), "union {ua} cells {cells}");
prop_assert!(close(sum, cells as f64, 1e-9), "divide {sum} cells {cells}");
let trim: f64 = pathfinder(PathfinderOp::Trim, &shapes).iter().map(|f| area(&f.path, NZ)).sum();
prop_assert!(close(trim, cells as f64, 1e-9));
}
#[test]
fn difference_and_xor_consistent(a in arb_shape(), b in arb_shape()) {
let d1 = area(&boolean(&a, NZ, &b, NZ, BoolOp::Difference), NZ);
let d2 = area(&boolean(&b, NZ, &a, NZ, BoolOp::Difference), NZ);
let x = area(&boolean(&a, NZ, &b, NZ, BoolOp::Xor), NZ);
prop_assert!(close(d1 + d2, x, 2e-3));
}
#[test]
fn self_difference_empty(a in arb_shape()) {
prop_assert!(boolean(&a, NZ, &a, NZ, BoolOp::Difference).is_empty());
}
#[test]
fn unite_idempotent(a in arb_shape()) {
let u = boolean(&a, NZ, &a, NZ, BoolOp::Union);
prop_assert!(close(area(&u, NZ), area(&a, NZ), 1e-3));
prop_assert!(u.anchor_count() <= a.anchor_count() + 4, "{} vs {}", u.anchor_count(), a.anchor_count());
let uu = boolean(&u, NZ, &u, NZ, BoolOp::Union);
prop_assert!(close(area(&uu, NZ), area(&u, NZ), 1e-6));
}
#[test]
fn divide_sums_to_union(a in arb_shape(), b in arb_shape(), c in arb_shape()) {
let shapes = vec![Shape::new(a, NZ, 1), Shape::new(b, NZ, 2), Shape::new(c, NZ, 3)];
let faces = pathfinder(PathfinderOp::Divide, &shapes);
let sum: f64 = faces.iter().map(|f| area(&f.path, NZ)).sum();
let u = pathfinder(PathfinderOp::Unite, &shapes);
let ua = u.first().map(|s| area(&s.path, NZ)).unwrap_or(0.0);
prop_assert!(close(sum, ua, 2e-3), "sum={sum} union={ua}");
}
#[test]
fn offset_circle_area(r in 5.0..60.0f64, d in -4.0..10.0f64) {
let c = PathData::from_bezpath(&kurbo::Circle::new((0.0, 0.0), r).to_path(1e-5));
let o = offset_path(&c, d, Join::Round, 4.0);
let expect = PI * (r + d) * (r + d);
prop_assert!(close(area(&o, NZ), expect, 3e-3), "got {} expect {}", area(&o, NZ), expect);
}
#[test]
fn outline_stroke_line_area(x in -50.0..50.0f64, y in -50.0..50.0f64, len in 1.0..100.0f64, ang in 0.0..6.0f64, w in 0.5..10.0f64) {
let a = Point::new(x, y);
let b = Point::new(x + len * ang.cos(), y + len * ang.sin());
let l = PathData::single(SubPath::polyline(&[a, b], false));
let o = outline_stroke(&l, w, Cap::Butt, Join::Miter, 4.0);
prop_assert!(close(area(&o, NZ), len * w, 1e-6));
}
#[test]
fn simplify_within_tolerance(
amps in proptest::collection::vec(0.0..8.0f64, 3),
tol in 0.05..2.0f64,
) {
// A wobbly closed curve sampled as a dense polyline.
let n = 240;
let pts: Vec<Point> = (0..n).map(|i| {
let t = i as f64 / n as f64 * 2.0 * PI;
let r = 60.0 + amps[0] * (2.0 * t).sin() + amps[1] * (3.0 * t).cos() + amps[2] * (5.0 * t).sin();
Point::new(r * t.cos(), r * t.sin())
}).collect();
let p = PathData::single(SubPath::polyline(&pts, true));
let s = simplify(&p, tol);
prop_assert!(s.anchor_count() < n);
let bp = p.to_bezpath();
for seg in bp.segments() {
for k in 0..=4 {
let q = seg.eval(k as f64 / 4.0);
let d = s.nearest(q).unwrap().4;
prop_assert!(d <= tol * 1.01 + 1e-6, "deviation {d} > {tol}");
}
}
}
}
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "drawcraft-pdf"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "DrawCraft PDF export and import"
[lints]
workspace = true
[dependencies]
drawcraft-geom = { workspace = true }
drawcraft-doc = { workspace = true }
kurbo = { workspace = true }
thiserror = { workspace = true }
+2
View File
@@ -0,0 +1,2 @@
//! DrawCraft PDF export and import
#![forbid(unsafe_code)]
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "drawcraft-render"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "DrawCraft renderer: document to pixels (vello_cpu backend)"
[lints]
workspace = true
[dependencies]
drawcraft-geom = { workspace = true }
drawcraft-color = { workspace = true }
drawcraft-doc = { workspace = true }
drawcraft-text = { workspace = true }
vello_cpu = { workspace = true }
image = { workspace = true }
log = { workspace = true }
+513
View File
@@ -0,0 +1,513 @@
//! DrawCraft renderer: document → premultiplied RGBA pixels.
//!
//! The backend is `vello_cpu` (SIMD, sparse strips). Callers give a *view transform* mapping
//! document points to output pixels; the renderer culls by bounds, evaluates appearance stacks
//! (multiple fills/strokes, opacity, blend modes, stroke alignment, dashes), clip groups,
//! gradients, images and text (via `drawcraft-text` glyph outlines).
#![forbid(unsafe_code)]
mod paint;
use std::collections::HashMap;
use std::sync::Arc;
use drawcraft_doc::{AppearanceItem, Document, LineCap, LineJoin, Node, NodeId, NodeKind, StrokeAlign, StrokeLayer, TextObject};
use drawcraft_geom::{Affine, BezPath, FillRule, Rect, Shape};
use vello_cpu::kurbo;
use vello_cpu::peniko::{self, BlendMode, Compose, Mix};
use vello_cpu::{Pixmap, RenderContext, Resources};
pub use vello_cpu;
/// Rendering options.
#[derive(Clone, Debug)]
pub struct RenderOptions {
/// Outline (wireframe) view: 1 px black paths, no paint.
pub outline: bool,
/// Pasteboard colour behind everything (premultiplied RGBA8); `None` = transparent.
pub background: Option<[u8; 4]>,
/// Draw artboards as white rectangles (screen view). Export sets this to false.
pub artboards: bool,
/// Objects not to draw (e.g. the object being edited by a live drag preview).
pub hidden: Vec<NodeId>,
/// Draw template layers dimmed (50%).
pub dim_templates: bool,
}
impl Default for RenderOptions {
fn default() -> Self {
Self { outline: false, background: None, artboards: false, hidden: vec![], dim_templates: true }
}
}
/// A rendered image (premultiplied RGBA8, row-major).
pub struct Rendered {
pub width: u32,
pub height: u32,
pub pixels: Vec<u8>,
}
impl Rendered {
/// Un-premultiplied RGBA8 copy.
pub fn to_straight(&self) -> Vec<u8> {
let mut out = self.pixels.clone();
for px in out.chunks_exact_mut(4) {
let a = px[3] as u32;
if a != 0 && a != 255 {
for c in &mut px[..3] {
*c = ((*c as u32 * 255 + a / 2) / a).min(255) as u8;
}
}
}
out
}
/// Encode as PNG.
pub fn to_png(&self) -> Vec<u8> {
let mut buf = Vec::new();
let img = image::RgbaImage::from_raw(self.width, self.height, self.to_straight()).expect("size");
img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png).expect("png encode");
buf
}
/// Straight-alpha RGBA at (x, y).
pub fn pixel(&self, x: u32, y: u32) -> [u8; 4] {
let i = ((y * self.width + x) * 4) as usize;
let p = &self.pixels[i..i + 4];
let a = p[3] as u32;
if a == 0 {
return [0, 0, 0, 0];
}
let un = |c: u8| ((c as u32 * 255 + a / 2) / a).min(255) as u8;
[un(p[0]), un(p[1]), un(p[2]), p[3]]
}
}
/// Reusable renderer (keeps the render context, decoded images and glyph caches between frames).
pub struct Renderer {
ctx: Option<RenderContext>,
resources: Resources,
images: HashMap<String, Arc<Pixmap>>,
/// Statistics of the last frame.
pub stats: FrameStats,
}
#[derive(Clone, Copy, Debug, Default)]
pub struct FrameStats {
pub drawn: usize,
pub culled: usize,
pub micros: u64,
}
impl Default for Renderer {
fn default() -> Self {
Self::new()
}
}
struct Frame<'a> {
doc: &'a Document,
view: Affine,
/// Visible region in document coordinates (for culling).
visible: Rect,
/// Size of one output pixel in document units.
px: f64,
opts: &'a RenderOptions,
}
impl Renderer {
pub fn new() -> Self {
Self { ctx: None, resources: Resources::new(), images: HashMap::new(), stats: FrameStats::default() }
}
/// Render `doc` into a `width`×`height` image using `view` (document → pixel transform).
pub fn render(&mut self, doc: &Document, width: u32, height: u32, view: Affine, opts: &RenderOptions) -> Rendered {
let start = now();
let w = width.clamp(1, u16::MAX as u32) as u16;
let h = height.clamp(1, u16::MAX as u32) as u16;
let mut ctx = match self.ctx.take() {
Some(mut c) if c.width() == w && c.height() == h => {
c.reset();
c
}
_ => RenderContext::new(w, h),
};
self.stats = FrameStats::default();
let inv = view.inverse();
let visible = inv.transform_rect_bbox(Rect::new(0.0, 0.0, w as f64, h as f64));
let px = 1.0 / view.determinant().abs().sqrt().max(1e-12);
let frame = Frame { doc, view, visible, px, opts };
if let Some(bg) = opts.background {
ctx.set_transform(Affine::IDENTITY);
ctx.set_paint(peniko::Color::from_rgba8(bg[0], bg[1], bg[2], bg[3]));
ctx.fill_rect(&kurbo::Rect::new(0.0, 0.0, w as f64, h as f64));
}
if opts.artboards && !opts.outline {
ctx.set_transform(view);
ctx.set_paint(peniko::Color::WHITE);
for ab in &doc.artboards {
ctx.fill_rect(&ab.rect);
}
}
for layer in &doc.layers {
self.draw_node(&mut ctx, &frame, layer, false);
}
ctx.flush();
let mut pm = Pixmap::new(w, h);
ctx.render(&mut pm, &mut self.resources);
self.ctx = Some(ctx);
self.stats.micros = now().saturating_sub(start);
Rendered { width: w as u32, height: h as u32, pixels: pm.data_as_u8_slice().to_vec() }
}
/// Render one artboard (or any document rect) at `scale` pixels per point, transparent or on white.
pub fn render_region(&mut self, doc: &Document, region: Rect, scale: f64, white: bool) -> Rendered {
let w = (region.width() * scale).round().max(1.0) as u32;
let h = (region.height() * scale).round().max(1.0) as u32;
let view = Affine::scale(scale) * Affine::translate((-region.x0, -region.y0));
let opts = RenderOptions { background: white.then_some([255, 255, 255, 255]), ..Default::default() };
self.render(doc, w, h, view, &opts)
}
/// Render a single node (thumbnails, previews) fitted into `size`×`size` pixels.
pub fn render_thumbnail(&mut self, doc: &Document, id: NodeId, size: u32) -> Option<Rendered> {
let n = doc.node(id)?;
let b = n.visual_bounds()?;
let s = (size as f64 - 2.0) / b.width().max(b.height()).max(1e-6);
let view = Affine::translate((size as f64 / 2.0, size as f64 / 2.0)) * Affine::scale(s) * Affine::translate(-b.center().to_vec2());
let w = size.clamp(1, u16::MAX as u32) as u16;
let mut ctx = RenderContext::new(w, w);
let frame = Frame { doc, view, visible: b.inflate(1.0, 1.0), px: 1.0 / s, opts: &RenderOptions::default() };
self.draw_node(&mut ctx, &frame, n, true);
ctx.flush();
let mut pm = Pixmap::new(w, w);
ctx.render(&mut pm, &mut self.resources);
Some(Rendered { width: size, height: size, pixels: pm.data_as_u8_slice().to_vec() })
}
fn draw_node(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, force: bool) {
if !force && (!n.visible || f.opts.hidden.contains(&n.id)) {
return;
}
if let Some(b) = n.visual_bounds() {
let pad = f.px * 2.0;
if !rects_overlap(b.inflate(pad, pad), f.visible) {
self.stats.culled += n.count();
return;
}
} else if !n.is_container() {
return;
}
let outline = f.opts.outline;
let mut layers = 0;
let template_dim = matches!(n.kind, NodeKind::Layer { template: true, .. }) && f.opts.dim_templates;
let opacity = if template_dim { n.opacity * 0.5 } else { n.opacity };
if !outline && (opacity < 1.0 || n.blend != drawcraft_color::BlendMode::Normal || n.isolate) {
ctx.set_transform(Affine::IDENTITY);
ctx.push_layer(None, Some(blend_mode(n.blend)), Some(opacity), None, None);
layers += 1;
}
match &n.kind {
NodeKind::Layer { children, .. } | NodeKind::Group { children, clip: false } => {
for c in children {
self.draw_node(ctx, f, c, false);
}
}
NodeKind::Group { children, clip: true } => {
let clip = children.first().and_then(|c| c.path_data()).map(|p| p.to_bezpath());
match (clip, outline) {
(Some(clip), false) => {
ctx.set_transform(f.view);
ctx.set_fill_rule(peniko::Fill::NonZero);
ctx.push_clip_layer(&clip);
for c in children.iter().skip(1) {
self.draw_node(ctx, f, c, false);
}
ctx.pop_layer();
}
_ => {
for c in children {
self.draw_node(ctx, f, c, false);
}
}
}
}
NodeKind::Path { path, rule, guide, .. } => {
let bp = path.to_bezpath();
if *guide {
self.hairline(ctx, f, &bp, [0x4a, 0xd8, 0xff, 255]);
} else {
self.draw_shape(ctx, f, n, &bp, *rule);
}
}
NodeKind::Compound { children, rule } => {
let mut bp = BezPath::new();
for c in children {
if let Some(p) = c.path_data() {
bp.extend(p.to_bezpath());
}
}
self.draw_shape(ctx, f, n, &bp, *rule);
}
NodeKind::Text(t) => self.draw_text(ctx, f, n, t),
NodeKind::Image(im) => self.draw_image(ctx, f, im),
NodeKind::SymbolInstance { symbol, xf } => {
if let Some(sym) = f.doc.symbols.iter().find(|s| &s.name == symbol) {
let mut art = (*sym.art).clone();
art.transform(*xf, false);
self.draw_node(ctx, f, &art, true);
}
}
}
self.stats.drawn += 1;
for _ in 0..layers {
ctx.pop_layer();
}
}
fn hairline(&mut self, ctx: &mut RenderContext, f: &Frame, bp: &BezPath, rgba: [u8; 4]) {
ctx.set_transform(Affine::IDENTITY);
let mut screen = bp.clone();
screen.apply_affine(f.view);
ctx.set_stroke(kurbo::Stroke::new(1.0));
ctx.set_paint(peniko::Color::from_rgba8(rgba[0], rgba[1], rgba[2], rgba[3]));
ctx.stroke_path(&screen);
}
fn draw_shape(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, bp: &BezPath, rule: FillRule) {
if f.opts.outline {
self.hairline(ctx, f, bp, [0, 0, 0, 255]);
return;
}
let bounds = bp.bounding_box();
for item in &n.appearance.items {
match item {
AppearanceItem::Fill(fl) => {
if !fl.visible || fl.paint.is_none() {
continue;
}
let layered = fl.opacity < 1.0 || fl.blend != drawcraft_color::BlendMode::Normal;
if layered {
ctx.set_transform(Affine::IDENTITY);
ctx.push_layer(None, Some(blend_mode(fl.blend)), Some(fl.opacity), None, None);
}
ctx.set_transform(f.view);
if paint::set_paint(ctx, &fl.paint, bounds, f.doc) {
ctx.set_fill_rule(fill_rule(rule));
ctx.fill_path(bp);
}
if layered {
ctx.pop_layer();
}
}
AppearanceItem::Stroke(st) => {
if !st.visible || st.paint.is_none() || st.width <= 0.0 {
continue;
}
self.draw_stroke(ctx, f, bp, rule, st, bounds);
}
}
}
}
fn draw_stroke(&mut self, ctx: &mut RenderContext, f: &Frame, bp: &BezPath, rule: FillRule, st: &StrokeLayer, bounds: Rect) {
let layered = st.opacity < 1.0 || st.blend != drawcraft_color::BlendMode::Normal || st.align == StrokeAlign::Outside;
if layered {
ctx.set_transform(Affine::IDENTITY);
ctx.push_layer(None, Some(blend_mode(st.blend)), Some(st.opacity), None, None);
}
ctx.set_transform(f.view);
let closed = bp.elements().last().is_some_and(|e| matches!(e, kurbo::PathEl::ClosePath));
let width = match st.align {
StrokeAlign::Center => st.width,
_ if closed => st.width * 2.0,
_ => st.width,
};
// Keep hairlines visible when zoomed far out (Illustrator shows at least ~1 device pixel).
let width = width.max(f.px * 0.5);
let mut stroke = kurbo::Stroke::new(width)
.with_join(match st.join {
LineJoin::Miter => kurbo::Join::Miter,
LineJoin::Round => kurbo::Join::Round,
LineJoin::Bevel => kurbo::Join::Bevel,
})
.with_caps(match st.cap {
LineCap::Butt => kurbo::Cap::Butt,
LineCap::Round => kurbo::Cap::Round,
LineCap::Square => kurbo::Cap::Square,
})
.with_miter_limit(st.miter_limit);
if let Some(d) = &st.dash
&& d.pattern.iter().any(|v| *v > 0.0)
{
let mut pat = d.pattern.clone();
if pat.len() % 2 == 1 {
pat.extend(pat.clone());
}
stroke = stroke.with_dashes(d.offset, pat);
}
ctx.set_stroke(stroke);
let inside = st.align == StrokeAlign::Inside && closed;
if inside {
ctx.set_fill_rule(fill_rule(rule));
ctx.push_clip_layer(bp);
}
if paint::set_paint(ctx, &st.paint, bounds.inflate(st.width / 2.0, st.width / 2.0), f.doc) {
ctx.stroke_path(bp);
}
if inside {
ctx.pop_layer();
}
if st.align == StrokeAlign::Outside && closed {
// Punch out the interior.
ctx.set_blend_mode(BlendMode::new(Mix::Normal, Compose::DestOut));
ctx.set_paint(peniko::Color::BLACK);
ctx.set_fill_rule(fill_rule(rule));
ctx.fill_path(bp);
ctx.set_blend_mode(BlendMode::default());
}
// Arrowheads.
if st.start_arrow.is_some() || st.end_arrow.is_some() {
for (arrow, head) in paint::arrowheads(bp, st) {
let _ = arrow;
if paint::set_paint(ctx, &st.paint, bounds, f.doc) {
ctx.set_fill_rule(peniko::Fill::NonZero);
ctx.fill_path(&head);
}
}
}
if layered {
ctx.pop_layer();
}
}
fn draw_text(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, t: &TextObject) {
let layout = drawcraft_text::layout(drawcraft_text::FontDb::global(), t);
let xf = f.view * t.xf;
if f.opts.outline {
for g in &layout.glyphs {
let mut p = g.outline.clone();
p.apply_affine(xf);
ctx.set_transform(Affine::IDENTITY);
ctx.set_stroke(kurbo::Stroke::new(1.0));
ctx.set_paint(peniko::Color::BLACK);
ctx.stroke_path(&p);
}
return;
}
let tb = t.xf.transform_rect_bbox(layout.bounds);
// Object-level appearance fills/strokes apply on top of character fills (like Illustrator).
for (i, run) in t.runs.iter().enumerate() {
let mut path = BezPath::new();
for g in layout.glyphs.iter().filter(|g| g.run == i) {
path.extend(g.outline.iter());
}
if path.elements().is_empty() {
continue;
}
ctx.set_transform(xf);
ctx.set_fill_rule(peniko::Fill::NonZero);
if paint::set_paint(ctx, &run.style.fill, layout.bounds, f.doc) {
ctx.fill_path(&path);
}
if !run.style.stroke.is_none() && run.style.stroke_width > 0.0 && paint::set_paint(ctx, &run.style.stroke, layout.bounds, f.doc) {
ctx.set_stroke(kurbo::Stroke::new(run.style.stroke_width));
ctx.stroke_path(&path);
}
}
if !n.appearance.items.is_empty() {
let mut all = BezPath::new();
for g in &layout.glyphs {
all.extend(g.outline.iter());
}
all.apply_affine(t.xf);
for item in &n.appearance.items {
ctx.set_transform(f.view);
match item {
AppearanceItem::Fill(fl) if fl.visible && paint::set_paint(ctx, &fl.paint, tb, f.doc) => {
ctx.set_fill_rule(peniko::Fill::NonZero);
ctx.fill_path(&all);
}
AppearanceItem::Stroke(st) if st.visible && st.width > 0.0 && paint::set_paint(ctx, &st.paint, tb, f.doc) => {
ctx.set_stroke(kurbo::Stroke::new(st.width));
ctx.stroke_path(&all);
}
_ => {}
}
}
}
}
fn draw_image(&mut self, ctx: &mut RenderContext, f: &Frame, im: &drawcraft_doc::ImageObject) {
let rect = Rect::new(0.0, 0.0, im.width as f64, im.height as f64);
if f.opts.outline {
let mut p = rect.to_path(0.1);
p.apply_affine(im.xf);
self.hairline(ctx, f, &p, [0, 0, 0, 255]);
return;
}
let pm = match self.images.get(&im.key) {
Some(p) => p.clone(),
None => {
let Some(blob) = f.doc.images.get(&im.key) else { return };
let Some(pm) = paint::decode_pixmap(&blob.bytes) else { return };
let pm = Arc::new(pm);
self.images.insert(im.key.clone(), pm.clone());
pm
}
};
let sx = im.width as f64 / pm.width().max(1) as f64;
let sy = im.height as f64 / pm.height().max(1) as f64;
ctx.set_transform(f.view * im.xf);
ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(pm), sampler: peniko::ImageSampler::default() });
ctx.set_paint_transform(Affine::scale_non_uniform(sx, sy));
ctx.fill_rect(&rect);
ctx.reset_paint_transform();
}
}
fn rects_overlap(a: Rect, b: Rect) -> bool {
a.x0 <= b.x1 && b.x0 <= a.x1 && a.y0 <= b.y1 && b.y0 <= a.y1
}
fn fill_rule(r: FillRule) -> peniko::Fill {
match r {
FillRule::NonZero => peniko::Fill::NonZero,
FillRule::EvenOdd => peniko::Fill::EvenOdd,
}
}
pub(crate) fn blend_mode(b: drawcraft_color::BlendMode) -> BlendMode {
use drawcraft_color::BlendMode as B;
let mix = match b {
B::Normal => Mix::Normal,
B::Darken => Mix::Darken,
B::Multiply => Mix::Multiply,
B::ColorBurn => Mix::ColorBurn,
B::Lighten => Mix::Lighten,
B::Screen => Mix::Screen,
B::ColorDodge => Mix::ColorDodge,
B::Overlay => Mix::Overlay,
B::SoftLight => Mix::SoftLight,
B::HardLight => Mix::HardLight,
B::Difference => Mix::Difference,
B::Exclusion => Mix::Exclusion,
B::Hue => Mix::Hue,
B::Saturation => Mix::Saturation,
B::Color => Mix::Color,
B::Luminosity => Mix::Luminosity,
};
BlendMode::new(mix, Compose::SrcOver)
}
#[cfg(not(target_arch = "wasm32"))]
fn now() -> u64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_micros() as u64).unwrap_or(0)
}
#[cfg(target_arch = "wasm32")]
fn now() -> u64 {
0
}
#[cfg(test)]
mod tests;
+157
View File
@@ -0,0 +1,157 @@
//! Paint conversion (solid, gradients, patterns), arrowheads and image decoding.
use drawcraft_color::{GradientKind, Paint};
use drawcraft_doc::{Arrowhead, Document, StrokeLayer};
use drawcraft_geom::{Affine, BezPath, Point, Rect, Shape, Vec2};
use vello_cpu::RenderContext;
use vello_cpu::kurbo::{self, ParamCurve, ParamCurveDeriv};
use vello_cpu::peniko::{self, ColorStop};
fn color(c: &drawcraft_color::Color, alpha: f32) -> peniko::Color {
let [r, g, b, a] = c.to_rgba8(alpha);
peniko::Color::from_rgba8(r, g, b, a)
}
/// Set the context paint. Returns false if nothing should be drawn.
pub fn set_paint(ctx: &mut RenderContext, p: &Paint, bounds: Rect, _doc: &Document) -> bool {
match p {
Paint::None => false,
Paint::Solid { color: c, .. } => {
ctx.set_paint(color(c, 1.0));
true
}
Paint::Gradient(g) => {
let geom = g.resolve(bounds);
let stops: Vec<ColorStop> = g.gradient.expanded_stops().iter().map(|(o, c, a)| ColorStop::from((*o, color(c, *a)))).collect();
if stops.is_empty() {
return false;
}
let grad = match g.gradient.kind {
GradientKind::Radial => {
let r = geom.length().max(1e-6) as f32;
let angle = (geom.end - geom.start).atan2();
// Aspect ratio squashes the circle along the perpendicular of the gradient vector.
ctx.set_paint_transform(
Affine::translate(geom.start.to_vec2())
* Affine::rotate(angle)
* Affine::scale_non_uniform(1.0, geom.aspect.max(1e-3))
* Affine::rotate(-angle)
* Affine::translate(-geom.start.to_vec2()),
);
peniko::Gradient::new_radial(geom.start, r).with_stops(stops.as_slice())
}
_ => {
ctx.reset_paint_transform();
let (s, e) =
if geom.start.distance(geom.end) < 1e-9 { (geom.start, geom.start + Vec2::new(1.0, 0.0)) } else { (geom.start, geom.end) };
peniko::Gradient::new_linear(s, e).with_stops(stops.as_slice())
}
};
ctx.set_paint(grad);
true
}
Paint::Pattern { .. } => {
// Patterns render as a neutral mid-grey until pattern swatches land (M10.5).
ctx.set_paint(peniko::Color::from_rgba8(128, 128, 128, 255));
true
}
}
}
/// Arrowhead geometry for a stroke: (which, filled outline) in document coordinates.
pub fn arrowheads(bp: &BezPath, st: &StrokeLayer) -> Vec<(Arrowhead, BezPath)> {
let segs: Vec<kurbo::PathSeg> = bp.segments().collect();
let mut out = Vec::new();
let (Some(first), Some(last)) = (segs.first(), segs.last()) else { return out };
if let Some(a) = st.start_arrow {
let p = first.eval(0.0);
let d = tangent(first, 0.0) * -1.0;
out.push((a, head(a, p, d, st.width * st.arrow_scale.0 / 100.0)));
}
if let Some(a) = st.end_arrow {
let p = last.eval(1.0);
let d = tangent(last, 1.0);
out.push((a, head(a, p, d, st.width * st.arrow_scale.1 / 100.0)));
}
out
}
fn tangent(s: &kurbo::PathSeg, t: f64) -> Vec2 {
let d = match s {
kurbo::PathSeg::Line(l) => l.p1 - l.p0,
kurbo::PathSeg::Quad(q) => q.deriv().eval(t).to_vec2(),
kurbo::PathSeg::Cubic(c) => {
let v = c.deriv().eval(t).to_vec2();
if v.hypot() < 1e-9 { c.p3 - c.p0 } else { v }
}
};
let l = d.hypot();
if l < 1e-12 { Vec2::new(1.0, 0.0) } else { d / l }
}
/// Arrowhead outline pointing along `dir` with its tip at `tip`, sized for stroke weight `w`.
fn head(kind: Arrowhead, tip: Point, dir: Vec2, w: f64) -> BezPath {
let w = w.max(0.25);
let n = Vec2::new(-dir.y, dir.x);
let len = 4.0 * w;
let half = 2.0 * w;
let base = tip - dir * len;
let mut p = BezPath::new();
match kind {
Arrowhead::Triangle | Arrowhead::TriangleOpen | Arrowhead::Arrow | Arrowhead::ArrowOpen => {
p.move_to(tip);
p.line_to(base + n * half);
if matches!(kind, Arrowhead::Arrow | Arrowhead::ArrowOpen) {
p.line_to(tip - dir * (len * 0.7));
}
p.line_to(base - n * half);
p.close_path();
}
Arrowhead::Circle | Arrowhead::CircleOpen => {
p = kurbo::Circle::new(tip - dir * half, half).to_path(0.01);
}
Arrowhead::Square | Arrowhead::SquareOpen => {
let c = tip - dir * half;
p.move_to(c + dir * half + n * half);
p.line_to(c - dir * half + n * half);
p.line_to(c - dir * half - n * half);
p.line_to(c + dir * half - n * half);
p.close_path();
}
Arrowhead::Diamond => {
let c = tip - dir * half;
p.move_to(tip);
p.line_to(c + n * half);
p.line_to(c - dir * half);
p.line_to(c - n * half);
p.close_path();
}
Arrowhead::Bar => {
let t = w * 0.75;
p.move_to(tip + n * half);
p.line_to(tip + n * half - dir * t);
p.line_to(tip - n * half - dir * t);
p.line_to(tip - n * half);
p.close_path();
}
}
p
}
/// Decode encoded image bytes into a premultiplied pixmap.
pub fn decode_pixmap(bytes: &[u8]) -> Option<vello_cpu::Pixmap> {
let img = image::load_from_memory(bytes).ok()?.to_rgba8();
let (w, h) = img.dimensions();
if w == 0 || h == 0 || w > u16::MAX as u32 || h > u16::MAX as u32 {
return None;
}
let data: Vec<vello_cpu::color::PremulRgba8> = img
.pixels()
.map(|p| {
let a = p[3] as u16;
let m = |c: u8| ((c as u16 * a + 127) / 255) as u8;
vello_cpu::color::PremulRgba8 { r: m(p[0]), g: m(p[1]), b: m(p[2]), a: p[3] }
})
.collect();
Some(vello_cpu::Pixmap::from_parts(data, w as u16, h as u16))
}
+197
View File
@@ -0,0 +1,197 @@
use super::*;
use drawcraft_color::{Color, Gradient, GradientPaint, Paint};
use drawcraft_doc::{Appearance, Node};
use drawcraft_geom::shapes;
fn rect_node(d: &mut Document, r: Rect, fill: Paint, stroke: Paint, w: f64) -> Node {
let id = d.alloc_id();
Node::path(id, shapes::rectangle(r), Appearance::basic(fill, stroke, w))
}
fn render(d: &Document) -> Rendered {
Renderer::new().render(d, 100, 100, Affine::IDENTITY, &RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() })
}
#[test]
fn fills_red_rect() {
let mut d = Document::new(100.0, 100.0);
let n = rect_node(&mut d, Rect::new(10.0, 10.0, 50.0, 50.0), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
let d = {
let mut d2 = d.clone();
d2.insert(Some(d.layers[0].id), 0, n).unwrap();
d2
};
let r = render(&d);
assert_eq!(r.pixel(30, 30), [255, 0, 0, 255]);
assert_eq!(r.pixel(70, 70), [255, 255, 255, 255]);
}
#[test]
fn stroke_draws_outline_only() {
let mut d = Document::new(100.0, 100.0);
let n = rect_node(&mut d, Rect::new(10.0, 10.0, 90.0, 90.0), Paint::None, Paint::solid(Color::BLACK), 4.0);
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
let r = render(&d);
assert_eq!(r.pixel(10, 50), [0, 0, 0, 255]);
assert_eq!(r.pixel(50, 50), [255, 255, 255, 255]);
}
#[test]
fn inside_stroke_stays_inside() {
let mut d = Document::new(100.0, 100.0);
let mut n = rect_node(&mut d, Rect::new(20.0, 20.0, 80.0, 80.0), Paint::None, Paint::solid(Color::BLACK), 10.0);
n.appearance.stroke_mut().unwrap().align = StrokeAlign::Inside;
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
let r = render(&d);
assert_eq!(r.pixel(17, 50), [255, 255, 255, 255]);
assert_eq!(r.pixel(25, 50), [0, 0, 0, 255]);
}
#[test]
fn outside_stroke_stays_outside() {
let mut d = Document::new(100.0, 100.0);
let mut n = rect_node(&mut d, Rect::new(20.0, 20.0, 80.0, 80.0), Paint::solid(Color::WHITE), Paint::solid(Color::BLACK), 10.0);
n.appearance.stroke_mut().unwrap().align = StrokeAlign::Outside;
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
let r = render(&d);
assert_eq!(r.pixel(15, 50), [0, 0, 0, 255]);
assert_eq!(r.pixel(23, 50), [255, 255, 255, 255]);
}
#[test]
fn opacity_blends() {
let mut d = Document::new(100.0, 100.0);
let mut n = rect_node(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), Paint::solid(Color::BLACK), Paint::None, 0.0);
n.opacity = 0.5;
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
let p = render(&d).pixel(50, 50);
assert!((p[0] as i32 - 128).abs() <= 2, "{p:?}");
}
#[test]
fn multiply_blend() {
let mut d = Document::new(100.0, 100.0);
let a = rect_node(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), Paint::solid(Color::rgb(1.0, 1.0, 0.0)), Paint::None, 0.0);
let mut b = rect_node(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), Paint::solid(Color::rgb(0.0, 1.0, 1.0)), Paint::None, 0.0);
b.blend = drawcraft_color::BlendMode::Multiply;
let l = d.layers[0].id;
d.insert(Some(l), 0, a).unwrap();
d.insert(Some(l), 1, b).unwrap();
assert_eq!(render(&d).pixel(50, 50), [0, 255, 0, 255]);
}
#[test]
fn linear_gradient_goes_white_to_black() {
let mut d = Document::new(100.0, 100.0);
let n =
rect_node(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), Paint::Gradient(Box::new(GradientPaint::new(Gradient::default()))), Paint::None, 0.0);
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
let r = render(&d);
assert!(r.pixel(2, 50)[0] > 240);
assert!(r.pixel(97, 50)[0] < 15);
let m = r.pixel(50, 50)[0] as i32;
assert!((m - 128).abs() < 12, "{m}");
}
#[test]
fn clip_group_clips() {
let mut d = Document::new(100.0, 100.0);
let mut clip = rect_node(&mut d, Rect::new(0.0, 0.0, 50.0, 100.0), Paint::None, Paint::None, 0.0);
if let NodeKind::Path { clipping, .. } = &mut clip.kind {
*clipping = true;
}
let art = rect_node(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), Paint::solid(Color::BLACK), Paint::None, 0.0);
let gid = d.alloc_id();
let g = Node::new(gid, NodeKind::Group { children: vec![Arc::new(clip), Arc::new(art)], clip: true });
let l = d.layers[0].id;
d.insert(Some(l), 0, g).unwrap();
let r = render(&d);
assert_eq!(r.pixel(25, 50), [0, 0, 0, 255]);
assert_eq!(r.pixel(75, 50), [255, 255, 255, 255]);
}
#[test]
fn hidden_and_culled() {
let mut d = Document::new(100.0, 100.0);
let mut n = rect_node(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), Paint::solid(Color::BLACK), Paint::None, 0.0);
n.visible = false;
let far = rect_node(&mut d, Rect::new(1000.0, 1000.0, 1100.0, 1100.0), Paint::solid(Color::BLACK), Paint::None, 0.0);
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
d.insert(Some(l), 1, far).unwrap();
let mut rr = Renderer::new();
let r = rr.render(&d, 100, 100, Affine::IDENTITY, &RenderOptions::default());
assert_eq!(r.pixel(50, 50), [0, 0, 0, 0]);
assert_eq!(rr.stats.culled, 1);
}
#[test]
fn view_transform_zooms() {
let mut d = Document::new(100.0, 100.0);
let n = rect_node(&mut d, Rect::new(0.0, 0.0, 10.0, 10.0), Paint::solid(Color::BLACK), Paint::None, 0.0);
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
let r = Renderer::new().render(&d, 100, 100, Affine::scale(5.0), &RenderOptions::default());
assert_eq!(r.pixel(45, 45)[3], 255);
assert_eq!(r.pixel(55, 55)[3], 0);
}
#[test]
fn outline_mode_no_fill() {
let mut d = Document::new(100.0, 100.0);
let n = rect_node(&mut d, Rect::new(10.0, 10.0, 90.0, 90.0), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
let r =
Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &RenderOptions { outline: true, background: Some([255; 4]), ..Default::default() });
assert_eq!(r.pixel(50, 50), [255, 255, 255, 255]);
assert!(r.pixel(10, 50)[0] < 200);
}
#[test]
fn artboards_white_on_pasteboard() {
let d = Document::new(50.0, 50.0);
let r = Renderer::new().render(
&d,
100,
100,
Affine::IDENTITY,
&RenderOptions { artboards: true, background: Some([30, 30, 30, 255]), ..Default::default() },
);
assert_eq!(r.pixel(25, 25), [255, 255, 255, 255]);
assert_eq!(r.pixel(75, 75), [30, 30, 30, 255]);
}
#[test]
fn region_export_png() {
let mut d = Document::new(100.0, 100.0);
let n = rect_node(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), Paint::solid(Color::BLACK), Paint::None, 0.0);
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
let r = Renderer::new().render_region(&d, d.artboards[0].rect, 2.0, true);
assert_eq!((r.width, r.height), (200, 200));
let png = r.to_png();
assert_eq!(&png[1..4], b"PNG");
}
#[test]
fn arrowheads_render() {
let mut d = Document::new(100.0, 100.0);
let id = d.alloc_id();
let mut n =
Node::path(id, shapes::line(Point::new(10.0, 50.0), Point::new(80.0, 50.0)), Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 2.0));
n.appearance.stroke_mut().unwrap().end_arrow = Some(Arrowhead::Triangle);
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
let r = render(&d);
// Arrowhead is wider than the 2 pt line.
assert!(r.pixel(73, 47)[0] < 128);
}
use drawcraft_doc::Arrowhead;
use drawcraft_geom::Point;
+23
View File
@@ -0,0 +1,23 @@
[package]
name = "drawcraft-svg"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "DrawCraft SVG import and export"
[lints]
workspace = true
[dependencies]
drawcraft-geom = { workspace = true }
drawcraft-color = { workspace = true }
drawcraft-doc = { workspace = true }
kurbo = { workspace = true }
usvg = { workspace = true }
thiserror = { workspace = true }
image = { workspace = true }
# Colour / transform / length parsing for the <text> fallback (MIT OR Apache-2.0; already used by usvg).
svgtypes = "0.16"
[dev-dependencies]
image = { workspace = true }
+680
View File
@@ -0,0 +1,680 @@
//! The SVG writer.
use std::collections::{HashMap, HashSet};
use drawcraft_color::{BlendMode, GradientKind, GradientPaint, Paint};
use drawcraft_doc::{AppearanceItem, Document, FillLayer, LineCap, LineJoin, Node, NodeId, NodeKind, StrokeAlign, StrokeLayer, TextKind, TextObject};
use drawcraft_doc::{CharStyle, Justify};
use drawcraft_geom::{Affine, FillRule, PathData, Point, Rect, Vec2};
use crate::{ExportOptions, Styling, base64_encode, fmt_num, xml_escape};
type Props = Vec<(&'static str, String)>;
pub(crate) fn export(doc: &Document, opts: &ExportOptions) -> String {
let rect = opts
.artboard
.and_then(|i| doc.artboards.get(i))
.map(|a| a.rect)
.or_else(|| doc.art_bounds())
.or_else(|| doc.artboards.first().map(|a| a.rect))
.unwrap_or(Rect::new(0.0, 0.0, 1.0, 1.0));
let mut w = Writer {
doc,
opts,
xf: Affine::translate((-rect.x0, -rect.y0)),
body: String::new(),
defs: String::new(),
classes: Vec::new(),
used_ids: HashSet::new(),
names: HashMap::new(),
depth: 1,
};
w.assign_name_ids();
for l in &doc.layers {
w.node(l);
}
let nl = if opts.minify { "" } else { "\n" };
let mut out = String::new();
if !opts.minify {
out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
}
let (ww, hh) = (w.num(rect.width()), w.num(rect.height()));
out.push_str("<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\"");
if !opts.responsive {
out.push_str(&format!(" width=\"{ww}\" height=\"{hh}\""));
}
out.push_str(&format!(" viewBox=\"0 0 {ww} {hh}\">{nl}"));
if !w.defs.is_empty() || !w.classes.is_empty() {
out.push_str(&w.indent(1));
out.push_str(&format!("<defs>{nl}"));
if !w.classes.is_empty() {
out.push_str(&w.indent(2));
out.push_str("<style>");
for (i, c) in w.classes.iter().enumerate() {
out.push_str(&format!("{nl}{}.cls-{}{{{}}}", w.indent(3), i + 1, xml_escape(c)));
}
out.push_str(&format!("{nl}{}</style>{nl}", w.indent(2)));
}
out.push_str(&w.defs);
out.push_str(&w.indent(1));
out.push_str(&format!("</defs>{nl}"));
}
if !doc.title.is_empty() {
out.push_str(&w.indent(1));
out.push_str(&format!("<title>{}</title>{nl}", xml_escape(&doc.title)));
}
out.push_str(&w.body);
out.push_str("</svg>");
out.push_str(nl);
out
}
struct Writer<'a> {
doc: &'a Document,
opts: &'a ExportOptions,
/// Document → SVG user space (artboard origin translation, symbol instance transforms).
xf: Affine,
body: String,
defs: String,
/// Unique CSS declaration blocks; class name is `cls-{index + 1}`.
classes: Vec<String>,
used_ids: HashSet<String>,
names: HashMap<NodeId, String>,
depth: usize,
}
pub(crate) fn blend_css(b: BlendMode) -> &'static str {
match b {
BlendMode::Normal => "normal",
BlendMode::Darken => "darken",
BlendMode::Multiply => "multiply",
BlendMode::ColorBurn => "color-burn",
BlendMode::Lighten => "lighten",
BlendMode::Screen => "screen",
BlendMode::ColorDodge => "color-dodge",
BlendMode::Overlay => "overlay",
BlendMode::SoftLight => "soft-light",
BlendMode::HardLight => "hard-light",
BlendMode::Difference => "difference",
BlendMode::Exclusion => "exclusion",
BlendMode::Hue => "hue",
BlendMode::Saturation => "saturation",
BlendMode::Color => "color",
BlendMode::Luminosity => "luminosity",
}
}
/// Turn an object name into a valid, readable XML id.
fn sanitize_id(name: &str) -> String {
let mut s: String = name.trim().chars().map(|c| if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' { c } else { '_' }).collect();
if s.is_empty() || !s.starts_with(|c: char| c.is_alphabetic() || c == '_') {
s.insert(0, '_');
}
s
}
impl Writer<'_> {
fn num(&self, v: f64) -> String {
fmt_num(v, self.opts.decimals)
}
fn indent(&self, depth: usize) -> String {
if self.opts.minify { String::new() } else { " ".repeat(depth) }
}
fn line(&mut self, s: &str) {
let ind = self.indent(self.depth);
self.body.push_str(&ind);
self.body.push_str(s);
if !self.opts.minify {
self.body.push('\n');
}
}
fn def(&mut self, depth: usize, s: &str) {
let ind = self.indent(depth + 1);
self.defs.push_str(&ind);
self.defs.push_str(s);
if !self.opts.minify {
self.defs.push('\n');
}
}
fn fresh_id(&mut self, prefix: &str) -> String {
let mut i = 1;
loop {
let id = format!("{prefix}-{i}");
if self.used_ids.insert(id.clone()) {
return id;
}
i += 1;
}
}
/// Reserve ids for every named object up front so generated def ids never collide with them.
fn assign_name_ids(&mut self) {
if !self.opts.object_ids {
return;
}
let mut named: Vec<(NodeId, String)> = Vec::new();
self.doc.walk(|n| {
if let Some(name) = &n.name {
named.push((n.id, sanitize_id(name)));
}
});
for (id, base) in named {
let mut cand = base.clone();
let mut i = 2;
while !self.used_ids.insert(cand.clone()) {
cand = format!("{base}-{i}");
i += 1;
}
self.names.insert(id, cand);
}
}
fn id_attr(&self, n: &Node) -> String {
self.names.get(&n.id).map(|id| format!(" id=\"{}\"", xml_escape(id))).unwrap_or_default()
}
fn matrix(&self, m: Affine) -> String {
let c = m.as_coeffs();
format!("matrix({} {} {} {} {} {})", self.num(c[0]), self.num(c[1]), self.num(c[2]), self.num(c[3]), self.num(c[4]), self.num(c[5]))
}
/// Style properties as attributes, an inline style, or a CSS class, per the styling option.
fn attrs(&mut self, props: &Props) -> String {
if props.is_empty() {
return String::new();
}
let css = |ps: &[&(&str, String)]| ps.iter().map(|(k, v)| format!("{k}:{v}")).collect::<Vec<_>>().join(";");
match self.opts.styling {
Styling::PresentationAttributes => {
// CSS-only properties go in a style attribute.
let (style, attrs): (Vec<_>, Vec<_>) = props.iter().partition(|(k, _)| matches!(*k, "mix-blend-mode" | "isolation"));
let mut s: String = attrs.iter().map(|(k, v)| format!(" {k}=\"{}\"", xml_escape(v))).collect();
if !style.is_empty() {
s.push_str(&format!(" style=\"{}\"", xml_escape(&css(&style))));
}
s
}
Styling::InlineStyle => format!(" style=\"{}\"", xml_escape(&css(&props.iter().collect::<Vec<_>>()))),
Styling::InternalCss => {
let decl = css(&props.iter().collect::<Vec<_>>());
let i = match self.classes.iter().position(|c| *c == decl) {
Some(i) => i,
None => {
self.classes.push(decl);
self.classes.len() - 1
}
};
format!(" class=\"cls-{}\"", i + 1)
}
}
}
fn node_props(n: &Node) -> Props {
let mut p = Props::new();
if n.opacity < 1.0 {
p.push(("opacity", fmt_num(n.opacity as f64, 3)));
}
if n.blend != BlendMode::Normal {
p.push(("mix-blend-mode", blend_css(n.blend).into()));
}
if n.isolate {
p.push(("isolation", "isolate".into()));
}
p
}
fn path_d(&self, p: &PathData, m: Affine) -> String {
let mut s = String::new();
let pt = |p: Point| {
let p = m * p;
format!("{} {}", self.num(p.x), self.num(p.y))
};
for sp in &p.subpaths {
let n = sp.anchors.len();
if n == 0 {
continue;
}
if !s.is_empty() {
s.push(' ');
}
s.push('M');
s.push_str(&pt(sp.anchors[0].p));
for i in 0..sp.segment_count() {
let (a, b) = (&sp.anchors[i], &sp.anchors[(i + 1) % n]);
if sp.segment_is_line(i) {
if sp.closed && i == n - 1 {
break;
}
s.push_str(&format!(" L{}", pt(b.p)));
} else {
s.push_str(&format!(" C{} {} {}", pt(a.h_out), pt(b.h_in), pt(b.p)));
}
}
if sp.closed {
s.push_str(" Z");
}
}
s
}
fn paint(&mut self, p: &Paint, bounds: Option<Rect>) -> String {
match p {
Paint::None | Paint::Pattern { .. } => "none".into(),
Paint::Solid { color, .. } => color.to_hex(),
Paint::Gradient(g) => format!("url(#{})", self.gradient_def(g, bounds.unwrap_or(Rect::new(0.0, 0.0, 1.0, 1.0)))),
}
}
fn gradient_def(&mut self, g: &GradientPaint, bounds: Rect) -> String {
let mut geom = g.resolve(bounds);
geom.transform(self.xf);
let (s, e) = (geom.start, geom.end);
let head = match g.gradient.kind {
GradientKind::Radial => {
let id = self.fresh_id("radial-gradient");
let r = (e - s).hypot();
if (geom.aspect - 1.0).abs() < 1e-9 {
(
id.clone(),
format!(
"<radialGradient id=\"{id}\" cx=\"{}\" cy=\"{}\" r=\"{}\" gradientUnits=\"userSpaceOnUse\">",
self.num(s.x),
self.num(s.y),
self.num(r)
),
"radialGradient",
)
} else {
let v = e - s;
let m = Affine::translate(s.to_vec2()) * Affine::rotate(v.y.atan2(v.x)) * Affine::scale_non_uniform(1.0, geom.aspect);
(
id.clone(),
format!(
"<radialGradient id=\"{id}\" cx=\"0\" cy=\"0\" r=\"{}\" gradientTransform=\"{}\" gradientUnits=\"userSpaceOnUse\">",
self.num(r),
self.matrix(m)
),
"radialGradient",
)
}
}
GradientKind::Linear | GradientKind::Freeform => {
let id = self.fresh_id("linear-gradient");
(
id.clone(),
format!(
"<linearGradient id=\"{id}\" x1=\"{}\" y1=\"{}\" x2=\"{}\" y2=\"{}\" gradientUnits=\"userSpaceOnUse\">",
self.num(s.x),
self.num(s.y),
self.num(e.x),
self.num(e.y)
),
"linearGradient",
)
}
};
let (id, open, tag) = head;
self.def(1, &open);
for (off, c, o) in g.gradient.expanded_stops() {
let op = if o < 1.0 { format!(" stop-opacity=\"{}\"", fmt_num(o as f64, 3)) } else { String::new() };
self.def(2, &format!("<stop offset=\"{}\" stop-color=\"{}\"{op}/>", fmt_num(off as f64, 4), c.to_hex()));
}
self.def(1, &format!("</{tag}>"));
id
}
fn fill_props(&mut self, f: &FillLayer, rule: FillRule, bounds: Option<Rect>, p: &mut Props) {
let paint = self.paint(&f.paint, bounds);
p.push(("fill", paint));
if f.opacity < 1.0 {
p.push(("fill-opacity", fmt_num(f.opacity as f64, 3)));
}
if rule == FillRule::EvenOdd {
p.push(("fill-rule", "evenodd".into()));
}
}
fn stroke_props(&mut self, s: &StrokeLayer, width: f64, bounds: Option<Rect>, p: &mut Props) {
let paint = self.paint(&s.paint, bounds);
p.push(("stroke", paint));
if s.opacity < 1.0 {
p.push(("stroke-opacity", fmt_num(s.opacity as f64, 3)));
}
if (width - 1.0).abs() > 1e-9 {
p.push(("stroke-width", self.num(width)));
}
match s.cap {
LineCap::Butt => {}
LineCap::Round => p.push(("stroke-linecap", "round".into())),
LineCap::Square => p.push(("stroke-linecap", "square".into())),
}
match s.join {
LineJoin::Miter => {
if (s.miter_limit - 4.0).abs() > 1e-9 {
p.push(("stroke-miterlimit", self.num(s.miter_limit)));
}
}
LineJoin::Round => p.push(("stroke-linejoin", "round".into())),
LineJoin::Bevel => p.push(("stroke-linejoin", "bevel".into())),
}
if let Some(d) = &s.dash
&& !d.pattern.is_empty()
&& d.pattern.iter().any(|v| *v > 0.0)
{
p.push(("stroke-dasharray", d.pattern.iter().map(|v| self.num(*v)).collect::<Vec<_>>().join(" ")));
if d.offset != 0.0 {
p.push(("stroke-dashoffset", self.num(d.offset)));
}
}
}
/// Paint a shape (`d`) with a node's appearance stack.
fn shape(&mut self, n: &Node, d: &str, rule: FillRule, bounds: Option<Rect>) {
let id = self.id_attr(n);
let items: Vec<&AppearanceItem> = n
.appearance
.items
.iter()
.filter(|i| match i {
AppearanceItem::Fill(f) => f.visible && !f.paint.is_none(),
AppearanceItem::Stroke(s) => s.visible && !s.paint.is_none() && s.width > 0.0,
})
.collect();
let fills = items.iter().filter(|i| matches!(i, AppearanceItem::Fill(_))).count();
let strokes = items.len() - fills;
let simple = fills <= 1
&& strokes <= 1
&& items.iter().all(|i| match i {
AppearanceItem::Fill(f) => f.blend == BlendMode::Normal,
AppearanceItem::Stroke(s) => s.blend == BlendMode::Normal && s.align == StrokeAlign::Center,
});
if simple {
let mut p = Props::new();
match items.iter().find_map(|i| if let AppearanceItem::Fill(f) = i { Some(f) } else { None }) {
Some(f) => self.fill_props(f, rule, bounds, &mut p),
None => {
p.push(("fill", "none".into()));
if rule == FillRule::EvenOdd {
p.push(("fill-rule", "evenodd".into()));
}
}
}
if let Some(s) = items.iter().find_map(|i| if let AppearanceItem::Stroke(s) = i { Some(s) } else { None }) {
self.stroke_props(s, s.width, bounds, &mut p);
if matches!(items.first(), Some(AppearanceItem::Stroke(_))) && fills == 1 {
p.push(("paint-order", "stroke".into()));
}
}
p.extend(Self::node_props(n));
let a = self.attrs(&p);
self.line(&format!("<path{id} d=\"{d}\"{a}/>"));
return;
}
let a = self.attrs(&Self::node_props(n));
self.line(&format!("<g{id}{a}>"));
self.depth += 1;
for it in items {
let mut p = Props::new();
match it {
AppearanceItem::Fill(f) => {
self.fill_props(f, rule, bounds, &mut p);
if f.blend != BlendMode::Normal {
p.push(("mix-blend-mode", blend_css(f.blend).into()));
}
let a = self.attrs(&p);
self.line(&format!("<path d=\"{d}\"{a}/>"));
}
AppearanceItem::Stroke(s) => {
p.push(("fill", "none".into()));
let width = if s.align == StrokeAlign::Center { s.width } else { s.width * 2.0 };
self.stroke_props(s, width, bounds, &mut p);
if s.blend != BlendMode::Normal {
p.push(("mix-blend-mode", blend_css(s.blend).into()));
}
let rule_attr = if rule == FillRule::EvenOdd { " clip-rule=\"evenodd\"" } else { "" };
let extra = match s.align {
StrokeAlign::Center => String::new(),
StrokeAlign::Inside => {
let cid = self.fresh_id("clip-path");
self.def(1, &format!("<clipPath id=\"{cid}\">"));
self.def(2, &format!("<path d=\"{d}\"{rule_attr}/>"));
self.def(1, "</clipPath>");
format!(" clip-path=\"url(#{cid})\"")
}
StrokeAlign::Outside => {
let mid = self.fresh_id("mask");
let b = self
.xf
.transform_rect_bbox(bounds.unwrap_or_default())
.inflate(width * s.miter_limit.max(1.0) + 1.0, width * s.miter_limit.max(1.0) + 1.0);
let (x, y, w, h) = (self.num(b.x0), self.num(b.y0), self.num(b.width()), self.num(b.height()));
let fr = if rule == FillRule::EvenOdd { " fill-rule=\"evenodd\"" } else { "" };
self.def(
1,
&format!("<mask id=\"{mid}\" maskUnits=\"userSpaceOnUse\" x=\"{x}\" y=\"{y}\" width=\"{w}\" height=\"{h}\">"),
);
self.def(2, &format!("<rect x=\"{x}\" y=\"{y}\" width=\"{w}\" height=\"{h}\" fill=\"#fff\"/>"));
self.def(2, &format!("<path d=\"{d}\" fill=\"#000\"{fr}/>"));
self.def(1, "</mask>");
format!(" mask=\"url(#{mid})\"")
}
};
let a = self.attrs(&p);
self.line(&format!("<path d=\"{d}\"{a}{extra}/>"));
}
}
}
self.depth -= 1;
self.line("</g>");
}
/// `(d, rule)` of every path in a clipping object.
fn clip_shapes(&self, n: &Node, out: &mut Vec<(String, FillRule)>) {
match &n.kind {
NodeKind::Path { path, rule, .. } => out.push((self.path_d(path, self.xf), *rule)),
NodeKind::Compound { children, rule } => {
let d: Vec<String> = children.iter().filter_map(|c| c.path_data()).map(|p| self.path_d(p, self.xf)).collect();
out.push((d.join(" "), *rule));
}
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => {
for c in children {
self.clip_shapes(c, out);
}
}
NodeKind::Image(im) => {
let r = Rect::new(0.0, 0.0, im.width as f64, im.height as f64);
let p = drawcraft_geom::shapes::rectangle(r).transformed(im.xf);
out.push((self.path_d(&p, self.xf), FillRule::NonZero));
}
NodeKind::Text(_) | NodeKind::SymbolInstance { .. } => {}
}
}
fn node(&mut self, n: &Node) {
if !n.visible {
return;
}
match &n.kind {
NodeKind::Layer { template: true, .. } => {}
NodeKind::Layer { children, .. } | NodeKind::Group { children, clip: false } => {
let id = self.id_attr(n);
let a = self.attrs(&Self::node_props(n));
self.line(&format!("<g{id}{a}>"));
self.depth += 1;
for c in children {
self.node(c);
}
self.depth -= 1;
self.line("</g>");
}
NodeKind::Group { children, clip: true } => {
let Some((clip, rest)) = children.split_first() else { return };
let mut shapes = Vec::new();
self.clip_shapes(clip, &mut shapes);
let cid = self.fresh_id("clip-path");
self.def(1, &format!("<clipPath id=\"{cid}\">"));
for (d, rule) in shapes {
let r = if rule == FillRule::EvenOdd { " clip-rule=\"evenodd\"" } else { "" };
self.def(2, &format!("<path d=\"{d}\"{r}/>"));
}
self.def(1, "</clipPath>");
let id = self.id_attr(n);
let a = self.attrs(&Self::node_props(n));
self.line(&format!("<g{id} clip-path=\"url(#{cid})\"{a}>"));
self.depth += 1;
for c in rest {
self.node(c);
}
self.depth -= 1;
self.line("</g>");
}
NodeKind::Path { guide: true, .. } => {}
NodeKind::Path { path, rule, .. } => {
if path.is_empty() {
return;
}
let d = self.path_d(path, self.xf);
self.shape(n, &d, *rule, n.geometric_bounds());
}
NodeKind::Compound { children, rule } => {
let d: Vec<String> = children
.iter()
.filter(|c| c.visible)
.filter_map(|c| c.path_data())
.filter(|p| !p.is_empty())
.map(|p| self.path_d(p, self.xf))
.collect();
if d.is_empty() {
return;
}
self.shape(n, &d.join(" "), *rule, n.geometric_bounds());
}
NodeKind::Text(t) => self.text(n, t),
NodeKind::Image(im) => {
let href = match self.doc.images.get(&im.key) {
Some(b) if !b.bytes.is_empty() => format!("data:{};base64,{}", b.mime, base64_encode(&b.bytes)),
_ => match &im.link {
Some(l) => l.clone(),
None => return,
},
};
let id = self.id_attr(n);
let m = self.matrix(self.xf * im.xf);
let a = self.attrs(&Self::node_props(n));
self.line(&format!(
"<image{id} width=\"{}\" height=\"{}\" transform=\"{m}\" preserveAspectRatio=\"none\"{a} xlink:href=\"{}\"/>",
im.width,
im.height,
xml_escape(&href)
));
}
NodeKind::SymbolInstance { symbol, xf } => {
let Some(sym) = self.doc.symbols.iter().find(|s| s.name == *symbol) else { return };
let saved = self.xf;
self.xf = saved * *xf;
let id = self.id_attr(n);
let a = self.attrs(&Self::node_props(n));
self.line(&format!("<g{id}{a}>"));
self.depth += 1;
self.node(&sym.art.clone());
self.depth -= 1;
self.line("</g>");
self.xf = saved;
}
}
}
fn char_props(&mut self, st: &CharStyle) -> Props {
let mut p = Props::new();
let fam =
if st.font_family.contains(|c: char| c.is_whitespace() || c == ',') { format!("'{}'", st.font_family) } else { st.font_family.clone() };
p.push(("font-family", fam));
p.push(("font-size", self.num(st.size)));
let fs = st.font_style.to_ascii_lowercase();
if fs.contains("bold") || fs.contains("black") || fs.contains("heavy") {
p.push(("font-weight", "bold".into()));
}
if fs.contains("italic") || fs.contains("oblique") {
p.push(("font-style", "italic".into()));
}
let fill = match &st.fill {
Paint::Gradient(g) => g.gradient.stops.first().map(|s| s.color.to_hex()).unwrap_or_else(|| "none".into()),
p => self.paint(p, None),
};
p.push(("fill", fill));
if !st.stroke.is_none() && st.stroke_width > 0.0 {
let s = match &st.stroke {
Paint::Gradient(g) => g.gradient.stops.first().map(|s| s.color.to_hex()).unwrap_or_else(|| "none".into()),
p => self.paint(p, None),
};
p.push(("stroke", s));
p.push(("stroke-width", self.num(st.stroke_width)));
}
if st.tracking != 0.0 {
p.push(("letter-spacing", self.num(st.tracking / 1000.0 * st.size)));
}
match (st.underline, st.strikethrough) {
(true, true) => p.push(("text-decoration", "underline line-through".into())),
(true, false) => p.push(("text-decoration", "underline".into())),
(false, true) => p.push(("text-decoration", "line-through".into())),
_ => {}
}
p
}
fn text(&mut self, n: &Node, t: &TextObject) {
let first = t.first_style();
let lead = first.effective_leading();
let id = self.id_attr(n);
let (m, on_path) = match &t.kind {
TextKind::Point => (self.xf * t.xf, None),
TextKind::Area { frame } => {
let b = frame.bounds().unwrap_or_default();
(self.xf * t.xf * Affine::translate(Vec2::new(b.x0 + t.para.left_indent, b.y0 + first.size)), None)
}
TextKind::OnPath { path, start } => {
let pid = self.fresh_id("text-path");
let d = self.path_d(path, self.xf * t.xf);
self.def(1, &format!("<path id=\"{pid}\" d=\"{d}\"/>"));
(Affine::IDENTITY, Some((pid, *start)))
}
};
let mut props = self.char_props(&first);
match t.para.justify {
Justify::Center | Justify::JustifyCenter => props.push(("text-anchor", "middle".into())),
Justify::Right | Justify::JustifyRight => props.push(("text-anchor", "end".into())),
_ => {}
}
props.extend(Self::node_props(n));
let a = self.attrs(&props);
let tr = if m == Affine::IDENTITY { String::new() } else { format!(" transform=\"{}\"", self.matrix(m)) };
let mut s = format!("<text{id}{tr} xml:space=\"preserve\"{a}>");
if let Some((pid, start)) = &on_path {
s.push_str(&format!("<textPath xlink:href=\"#{pid}\" startOffset=\"{}%\">", fmt_num(start * 100.0, 3)));
}
let base = self.char_props(&first);
let mut line = 0usize;
let mut pending = false;
for run in &t.runs {
let rp = self.char_props(&run.style);
let diff: Props = rp.into_iter().filter(|kv| !base.contains(kv)).collect();
for (i, piece) in run.text.split('\n').enumerate() {
if i > 0 {
line += 1;
pending = true;
}
if piece.is_empty() {
continue;
}
let pos = if pending && on_path.is_none() { format!(" x=\"0\" y=\"{}\"", self.num(line as f64 * lead)) } else { String::new() };
pending = false;
let a = self.attrs(&diff);
s.push_str(&format!("<tspan{pos}{a}>{}</tspan>", xml_escape(piece)));
}
}
if on_path.is_some() {
s.push_str("</textPath>");
}
s.push_str("</text>");
self.line(&s);
}
}
+698
View File
@@ -0,0 +1,698 @@
//! usvg tree → document conversion.
use std::collections::HashMap;
use std::str::FromStr;
use std::sync::Arc;
use drawcraft_color::{BlendMode, Color, Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop, Paint};
use drawcraft_doc::{
Appearance, AppearanceItem, CharStyle, Dash, Document, FillLayer, ImageBlob, ImageObject, Justify, LayerColor, LineCap, LineJoin, Node, NodeKind,
StrokeLayer, TextKind, TextObject, TextRun,
};
use drawcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Vec2};
use usvg::roxmltree;
use crate::SvgError;
pub(crate) fn import(svg: &str) -> Result<(Document, Vec<String>), SvgError> {
let opt = usvg::Options::default();
let tree = usvg::Tree::from_str(svg, &opt).map_err(|e| SvgError::Parse(e.to_string()))?;
let size = tree.size();
let doc = Document::new(size.width() as f64, size.height() as f64);
let mut im = Importer { doc, warnings: Vec::new() };
// usvg wraps everything in an id-less group carrying the viewBox transform when needed.
let mut top = tree.root();
let mut base = aff(top.transform());
if let [usvg::Node::Group(g)] = top.children()
&& g.id().is_empty()
&& is_plain(g)
{
base *= aff(g.transform());
top = g;
}
// Top-level `<g id>` elements become layers (Illustrator does the same); otherwise all art goes
// into "Layer 1".
let layer_mode =
!top.children().is_empty() && top.children().iter().all(|c| matches!(c, usvg::Node::Group(g) if !g.id().is_empty() && is_plain(g)));
if layer_mode {
im.doc.layers.clear();
for (i, c) in top.children().iter().enumerate() {
let usvg::Node::Group(g) = c else { continue };
let children = im.children(g, base * aff(g.transform()));
let id = im.doc.alloc_id();
let mut l = Node::layer(id, g.id(), LayerColor::Preset((i % drawcraft_doc::LAYER_COLORS.len()) as u8));
if let Some(ch) = l.children_mut() {
*ch = children;
}
im.doc.layers.push(Arc::new(l));
}
} else {
let children = im.children(top, base);
if let Some(l) = im.doc.layers.first_mut()
&& let Some(ch) = Arc::make_mut(l).children_mut()
{
*ch = children;
}
}
text_fallback(&mut im, svg, size.width() as f64, size.height() as f64, layer_mode);
Ok((im.doc, im.warnings))
}
struct Importer {
doc: Document,
warnings: Vec<String>,
}
fn aff(t: usvg::Transform) -> Affine {
Affine::new([t.sx as f64, t.ky as f64, t.kx as f64, t.sy as f64, t.tx as f64, t.ty as f64])
}
/// Linear part of `m` applied to `v`.
fn lin(m: Affine, v: Vec2) -> Vec2 {
(m * v.to_point()) - (m * Point::ZERO)
}
fn is_plain(g: &usvg::Group) -> bool {
g.clip_path().is_none() && g.mask().is_none() && g.filters().is_empty() && g.opacity().get() >= 1.0 && g.blend_mode() == usvg::BlendMode::Normal
}
fn blend(b: usvg::BlendMode) -> BlendMode {
use usvg::BlendMode as B;
match b {
B::Normal => BlendMode::Normal,
B::Multiply => BlendMode::Multiply,
B::Screen => BlendMode::Screen,
B::Overlay => BlendMode::Overlay,
B::Darken => BlendMode::Darken,
B::Lighten => BlendMode::Lighten,
B::ColorDodge => BlendMode::ColorDodge,
B::ColorBurn => BlendMode::ColorBurn,
B::HardLight => BlendMode::HardLight,
B::SoftLight => BlendMode::SoftLight,
B::Difference => BlendMode::Difference,
B::Exclusion => BlendMode::Exclusion,
B::Hue => BlendMode::Hue,
B::Saturation => BlendMode::Saturation,
B::Color => BlendMode::Color,
B::Luminosity => BlendMode::Luminosity,
}
}
fn bezpath(p: &usvg::tiny_skia_path::Path, m: Affine) -> BezPath {
use usvg::tiny_skia_path::PathSegment as S;
let pt = |p: usvg::tiny_skia_path::Point| m * Point::new(p.x as f64, p.y as f64);
let mut bp = BezPath::new();
for s in p.segments() {
match s {
S::MoveTo(p) => bp.move_to(pt(p)),
S::LineTo(p) => bp.line_to(pt(p)),
S::QuadTo(a, p) => bp.quad_to(pt(a), pt(p)),
S::CubicTo(a, b, p) => bp.curve_to(pt(a), pt(b), pt(p)),
S::Close => bp.close_path(),
}
}
bp
}
fn fnv1a(bytes: &[u8]) -> u64 {
let mut h: u64 = 0xcbf29ce484222325;
for b in bytes {
h ^= *b as u64;
h = h.wrapping_mul(0x100000001b3);
}
h
}
fn rule(r: usvg::FillRule) -> FillRule {
match r {
usvg::FillRule::NonZero => FillRule::NonZero,
usvg::FillRule::EvenOdd => FillRule::EvenOdd,
}
}
impl Importer {
fn warn(&mut self, s: String) {
if !self.warnings.contains(&s) {
self.warnings.push(s);
}
}
fn children(&mut self, g: &usvg::Group, acc: Affine) -> Vec<Arc<Node>> {
g.children().iter().filter_map(|c| self.node(c, acc)).map(Arc::new).collect()
}
fn node(&mut self, n: &usvg::Node, acc: Affine) -> Option<Node> {
match n {
usvg::Node::Group(g) => self.group(g, acc),
usvg::Node::Path(p) => self.path(p, acc),
usvg::Node::Image(i) => self.image(i, acc),
// Only present when usvg had fonts; text is read from the XML instead.
usvg::Node::Text(_) => None,
}
}
fn named(&mut self, id: &str, kind: NodeKind) -> Node {
let nid = self.doc.alloc_id();
let mut n = Node::new(nid, kind);
if !id.is_empty() {
n.name = Some(id.to_string());
}
n
}
fn group(&mut self, g: &usvg::Group, acc: Affine) -> Option<Node> {
let ts = acc * aff(g.transform());
let label = if g.id().is_empty() { "a group".to_string() } else { format!("'{}'", g.id()) };
if g.mask().is_some() {
self.warn(format!("mask on {label} ignored"));
}
if !g.filters().is_empty() {
self.warn(format!("filter on {label} ignored"));
}
let children = self.children(g, ts);
let mut n = if let Some(cp) = g.clip_path() {
if cp.clip_path().is_some() {
self.warn(format!("nested clip path on {label} approximated by its outer clip"));
}
let clip = self.clip_node(cp, ts)?;
let mut ch = vec![Arc::new(clip)];
ch.extend(children);
self.named(g.id(), NodeKind::Group { children: ch, clip: true })
} else {
if children.is_empty() {
return None;
}
// An id-less wrapper around a single object (usvg adds these for opacity/transform on
// shapes): fold its opacity and blend into the object.
if g.id().is_empty() && children.len() == 1 && (g.blend_mode() == usvg::BlendMode::Normal || children[0].blend == BlendMode::Normal) {
let mut c = Arc::unwrap_or_clone(children.into_iter().next().unwrap());
c.opacity *= g.opacity().get();
if g.blend_mode() != usvg::BlendMode::Normal {
c.blend = blend(g.blend_mode());
}
c.isolate |= g.isolate();
return Some(c);
}
self.named(g.id(), NodeKind::Group { children, clip: false })
};
n.opacity = g.opacity().get();
n.blend = blend(g.blend_mode());
n.isolate = g.isolate();
Some(n)
}
fn clip_node(&mut self, cp: &usvg::ClipPath, ts: Affine) -> Option<Node> {
fn collect(g: &usvg::Group, m: Affine, out: &mut Vec<(BezPath, FillRule)>) {
for c in g.children() {
match c {
usvg::Node::Group(g) => collect(g, m * aff(g.transform()), out),
usvg::Node::Path(p) => out.push((bezpath(p.data(), m), p.fill().map(|f| rule(f.rule())).unwrap_or_default())),
_ => {}
}
}
}
let mut shapes = Vec::new();
collect(cp.root(), ts * aff(cp.transform()), &mut shapes);
if shapes.is_empty() {
return None;
}
let r = shapes[0].1;
let mut subs = Vec::new();
for (bp, _) in &shapes {
subs.extend(PathData::from_bezpath(bp).subpaths);
}
let id = cp.id().to_string();
if subs.len() == 1 {
let path = PathData::new(subs);
return Some(self.named(&id, NodeKind::Path { path, rule: r, live: None, clipping: true, guide: false }));
}
let children = subs
.into_iter()
.map(|sp| {
let nid = self.doc.alloc_id();
Arc::new(Node::new(nid, NodeKind::Path { path: PathData::single(sp), rule: r, live: None, clipping: false, guide: false }))
})
.collect();
Some(self.named(&id, NodeKind::Compound { children, rule: r }))
}
fn paint(&mut self, p: &usvg::Paint, m: Affine) -> Paint {
let stops = |g: &usvg::BaseGradient| {
g.stops()
.iter()
.map(|s| {
let c = s.color();
GradientStop { offset: s.offset().get(), color: Color::rgb8(c.red, c.green, c.blue), opacity: s.opacity().get(), midpoint: 0.5 }
})
.collect::<Vec<_>>()
};
let gp = |kind, stops, geom: GradientGeom| {
Paint::Gradient(Box::new(GradientPaint { gradient: Gradient { kind, stops }, geom: Some(geom), angle: geom.angle_deg(), swatch: None }))
};
match p {
usvg::Paint::Color(c) => Paint::solid(Color::rgb8(c.red, c.green, c.blue)),
usvg::Paint::LinearGradient(lg) => {
if lg.spread_method() != usvg::SpreadMethod::Pad {
self.warn(format!("gradient '{}': spreadMethod approximated as pad", lg.id()));
}
let t = m * aff(lg.transform());
let (p1, p2) = (Point::new(lg.x1() as f64, lg.y1() as f64), Point::new(lg.x2() as f64, lg.y2() as f64));
let start = t * p1;
let far = t * p2;
// Isolines (perpendicular to the vector in gradient space) stay parallel under an
// affine map; the document gradient vector is the normal to their image.
let dir = p2 - p1;
let iso = lin(t, Vec2::new(-dir.y, dir.x));
let nrm = Vec2::new(-iso.y, iso.x);
let end = if nrm.hypot2() > 1e-18 { start + nrm * ((far - start).dot(nrm) / nrm.hypot2()) } else { far };
gp(GradientKind::Linear, stops(lg), GradientGeom { start, end, aspect: 1.0 })
}
usvg::Paint::RadialGradient(rg) => {
if rg.spread_method() != usvg::SpreadMethod::Pad {
self.warn(format!("gradient '{}': spreadMethod approximated as pad", rg.id()));
}
if (rg.fx() - rg.cx()).abs() > 1e-4 || (rg.fy() - rg.cy()).abs() > 1e-4 {
self.warn(format!("gradient '{}': focal point ignored", rg.id()));
}
let t = m * aff(rg.transform());
let r = rg.r().get() as f64;
let c = Point::new(rg.cx() as f64, rg.cy() as f64);
let vx = lin(t, Vec2::new(r, 0.0));
let vy = lin(t, Vec2::new(0.0, r));
let start = t * c;
let aspect = if vx.hypot() > 1e-12 { vy.hypot() / vx.hypot() } else { 1.0 };
gp(GradientKind::Radial, stops(rg), GradientGeom { start, end: start + vx, aspect })
}
usvg::Paint::Pattern(pt) => {
self.warn(format!("pattern '{}' not supported; painted as none", pt.id()));
Paint::None
}
}
}
fn appearance(&mut self, p: &usvg::Path, m: Affine) -> (Appearance, FillRule) {
let mut fill = None;
let mut r = FillRule::NonZero;
if let Some(f) = p.fill() {
let paint = self.paint(f.paint(), m);
let mut fl = FillLayer::new(paint);
fl.opacity = f.opacity().get();
r = rule(f.rule());
fill = Some(AppearanceItem::Fill(fl));
}
let mut stroke = None;
if let Some(s) = p.stroke() {
let scale = m.determinant().abs().sqrt();
let paint = self.paint(s.paint(), m);
let mut sl = StrokeLayer::new(paint, s.width().get() as f64 * scale);
sl.opacity = s.opacity().get();
sl.cap = match s.linecap() {
usvg::LineCap::Butt => LineCap::Butt,
usvg::LineCap::Round => LineCap::Round,
usvg::LineCap::Square => LineCap::Square,
};
sl.join = match s.linejoin() {
usvg::LineJoin::Miter | usvg::LineJoin::MiterClip => LineJoin::Miter,
usvg::LineJoin::Round => LineJoin::Round,
usvg::LineJoin::Bevel => LineJoin::Bevel,
};
sl.miter_limit = s.miterlimit().get() as f64;
if let Some(d) = s.dasharray() {
sl.dash = Some(Dash {
pattern: d.iter().map(|v| *v as f64 * scale).collect(),
offset: s.dashoffset() as f64 * scale,
align_corners: false,
});
}
stroke = Some(AppearanceItem::Stroke(sl));
}
let items = match p.paint_order() {
usvg::PaintOrder::FillAndStroke => [fill, stroke],
usvg::PaintOrder::StrokeAndFill => [stroke, fill],
};
(Appearance { items: items.into_iter().flatten().collect(), effects: vec![] }, r)
}
fn path(&mut self, p: &usvg::Path, acc: Affine) -> Option<Node> {
if !p.is_visible() {
return None;
}
let path = PathData::from_bezpath(&bezpath(p.data(), acc));
if path.is_empty() {
return None;
}
let (appearance, r) = self.appearance(p, acc);
let mut n = if path.subpaths.len() > 1 {
// Multi-subpath SVG paths are compound paths (as in Illustrator).
let children = path
.subpaths
.into_iter()
.map(|sp| {
let id = self.doc.alloc_id();
let mut c = Node::new(id, NodeKind::Path { path: PathData::single(sp), rule: r, live: None, clipping: false, guide: false });
c.appearance = appearance.clone();
Arc::new(c)
})
.collect();
self.named(p.id(), NodeKind::Compound { children, rule: r })
} else {
self.named(p.id(), NodeKind::Path { path, rule: r, live: None, clipping: false, guide: false })
};
n.appearance = appearance;
Some(n)
}
fn image(&mut self, i: &usvg::Image, acc: Affine) -> Option<Node> {
if !i.is_visible() {
return None;
}
let (bytes, mime) = match i.kind() {
usvg::ImageKind::JPEG(d) => (d, "image/jpeg"),
usvg::ImageKind::PNG(d) => (d, "image/png"),
usvg::ImageKind::GIF(d) => (d, "image/gif"),
usvg::ImageKind::WEBP(d) => (d, "image/webp"),
usvg::ImageKind::SVG(_) => {
self.warn("embedded SVG image skipped".into());
return None;
}
};
let size = i.size();
let (pw, ph) = image::ImageReader::new(std::io::Cursor::new(&bytes[..]))
.with_guessed_format()
.ok()
.and_then(|r| r.into_dimensions().ok())
.unwrap_or((size.width().round().max(1.0) as u32, size.height().round().max(1.0) as u32));
let key = format!("img-{:016x}", fnv1a(bytes));
self.doc.images.entry(key.clone()).or_insert_with(|| ImageBlob { mime: mime.into(), bytes: Arc::new(bytes.to_vec()) });
let xf = acc * Affine::scale_non_uniform(size.width() as f64 / pw as f64, size.height() as f64 / ph as f64);
Some(self.named(i.id(), NodeKind::Image(ImageObject { key, width: pw, height: ph, xf, link: None })))
}
}
// ---------------------------------------------------------------------------------------------
// <text> fallback: read text elements straight from the XML as live point type.
type XNode<'a, 'i> = roxmltree::Node<'a, 'i>;
struct TextCtx {
/// `.class` → declarations from `<style>` elements.
classes: HashMap<String, Vec<(String, String)>>,
}
fn parse_decls(s: &str) -> Vec<(String, String)> {
s.split(';')
.filter_map(|d| {
let (k, v) = d.split_once(':')?;
Some((k.trim().to_string(), v.trim().trim_end_matches("!important").trim().to_string()))
})
.collect()
}
impl TextCtx {
fn new(doc: &roxmltree::Document) -> Self {
let mut classes: HashMap<String, Vec<(String, String)>> = HashMap::new();
for st in doc.descendants().filter(|n| n.has_tag_name("style") || n.tag_name().name() == "style") {
let css: String = st.children().filter_map(|c| c.text()).collect();
for rule in css.split('}') {
let Some((sel, body)) = rule.split_once('{') else { continue };
let decls = parse_decls(body);
for s in sel.split(',') {
if let Some(c) = s.trim().strip_prefix('.')
&& c.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_')
{
classes.entry(c.to_string()).or_default().extend(decls.clone());
}
}
}
}
Self { classes }
}
/// A property on this element only: style attribute > class rule > presentation attribute.
fn own(&self, n: XNode, name: &str) -> Option<String> {
if let Some(st) = n.attribute("style")
&& let Some((_, v)) = parse_decls(st).into_iter().rev().find(|(k, _)| k == name)
{
return Some(v);
}
if let Some(cls) = n.attribute("class") {
for c in cls.split_whitespace().rev() {
if let Some((_, v)) = self.classes.get(c).and_then(|d| d.iter().rev().find(|(k, _)| k == name)) {
return Some(v.clone());
}
}
}
n.attribute(name).map(str::to_string)
}
/// An inherited property.
fn prop(&self, n: XNode, name: &str) -> Option<String> {
n.ancestors().filter(|a| a.is_element()).find_map(|a| self.own(a, name).filter(|v| v != "inherit"))
}
}
fn first_number(s: Option<&str>) -> f64 {
s.and_then(|s| s.split(|c: char| c.is_whitespace() || c == ',').find(|t| !t.is_empty()).map(str::to_string))
.and_then(|t| svgtypes::Length::from_str(&t).ok())
.map(|l| l.number)
.unwrap_or(0.0)
}
fn parse_transform(s: Option<&str>) -> Affine {
s.and_then(|s| svgtypes::Transform::from_str(s).ok()).map(|t| Affine::new([t.a, t.b, t.c, t.d, t.e, t.f])).unwrap_or(Affine::IDENTITY)
}
fn parse_color_paint(v: &str) -> Paint {
let v = v.trim();
if v == "none" {
return Paint::None;
}
if let Ok(c) = svgtypes::Color::from_str(v) {
return Paint::solid(Color::rgb8(c.red, c.green, c.blue));
}
// url(#…) and other paints: fall back to black (or the fallback colour after the url).
if let Some(rest) = v.strip_prefix("url(").and_then(|r| r.split_once(')')).map(|(_, r)| r.trim())
&& let Ok(c) = svgtypes::Color::from_str(rest)
{
return Paint::solid(Color::rgb8(c.red, c.green, c.blue));
}
Paint::solid(Color::BLACK)
}
fn char_style(ctx: &TextCtx, n: XNode) -> CharStyle {
let mut st = CharStyle::default();
if let Some(f) = ctx.prop(n, "font-family") {
let fam = f.split(',').next().unwrap_or("").trim().trim_matches(|c| c == '\'' || c == '"').to_string();
if !fam.is_empty() {
st.font_family = fam;
}
}
if let Some(s) = ctx.prop(n, "font-size")
&& let Ok(l) = svgtypes::Length::from_str(s.trim())
&& l.number > 0.0
{
st.size = match l.unit {
svgtypes::LengthUnit::Em => l.number * 12.0,
svgtypes::LengthUnit::Percent => l.number / 100.0 * 12.0,
_ => l.number,
};
}
let bold = ctx.prop(n, "font-weight").is_some_and(|w| w == "bold" || w == "bolder" || w.parse::<u32>().is_ok_and(|v| v >= 600));
let italic = ctx.prop(n, "font-style").is_some_and(|s| s == "italic" || s == "oblique");
st.font_style = match (bold, italic) {
(true, true) => "Bold Italic",
(true, false) => "Bold",
(false, true) => "Italic",
_ => "Regular",
}
.into();
if let Some(f) = ctx.prop(n, "fill") {
st.fill = parse_color_paint(&f);
}
if let Some(s) = ctx.prop(n, "stroke") {
st.stroke = parse_color_paint(&s);
st.stroke_width = ctx.prop(n, "stroke-width").map(|w| first_number(Some(&w))).unwrap_or(1.0);
}
if let Some(ls) = ctx.prop(n, "letter-spacing") {
let v = first_number(Some(&ls));
if v != 0.0 {
st.tracking = v / st.size * 1000.0;
}
}
if let Some(d) = ctx.prop(n, "text-decoration") {
st.underline = d.contains("underline");
st.strikethrough = d.contains("line-through");
}
st
}
fn view_box_transform(root: XNode, w: f64, h: f64) -> Affine {
let Some(vb) = root.attribute("viewBox").and_then(|v| svgtypes::ViewBox::from_str(v).ok()) else { return Affine::IDENTITY };
if vb.w <= 0.0 || vb.h <= 0.0 {
return Affine::IDENTITY;
}
let ar = root.attribute("preserveAspectRatio").and_then(|v| svgtypes::AspectRatio::from_str(v).ok()).unwrap_or_default();
let (sx, sy) = (w / vb.w, h / vb.h);
use svgtypes::Align as A;
if ar.align == A::None {
return Affine::scale_non_uniform(sx, sy) * Affine::translate((-vb.x, -vb.y));
}
let s = if ar.slice { sx.max(sy) } else { sx.min(sy) };
let (fx, fy) = match ar.align {
A::XMinYMin => (0.0, 0.0),
A::XMidYMin => (0.5, 0.0),
A::XMaxYMin => (1.0, 0.0),
A::XMinYMid => (0.0, 0.5),
A::XMinYMax => (0.0, 1.0),
A::XMidYMax => (0.5, 1.0),
A::XMaxYMid => (1.0, 0.5),
A::XMaxYMax => (1.0, 1.0),
A::XMidYMid | A::None => (0.5, 0.5),
};
Affine::translate(((w - vb.w * s) * fx, (h - vb.h * s) * fy)) * Affine::scale(s) * Affine::translate((-vb.x, -vb.y))
}
struct RunBuilder {
runs: Vec<TextRun>,
last_y: f64,
preserve: bool,
}
impl RunBuilder {
fn push(&mut self, text: &str, style: CharStyle) {
if text.is_empty() {
return;
}
if let Some(l) = self.runs.last_mut()
&& l.style == style
{
l.text.push_str(text);
return;
}
self.runs.push(TextRun { text: text.into(), style });
}
fn newline(&mut self, n: usize) {
if let Some(l) = self.runs.last_mut() {
for _ in 0..n {
l.text.push('\n');
}
}
}
}
fn collect_runs(ctx: &TextCtx, el: XNode, rb: &mut RunBuilder) {
for c in el.children() {
if c.is_text() {
let raw = c.text().unwrap_or("");
let t = if rb.preserve {
raw.replace(['\n', '\r', '\t'], " ")
} else {
let s: String = raw.chars().filter(|c| *c != '\n' && *c != '\r').map(|c| if c == '\t' { ' ' } else { c }).collect();
let mut out = String::new();
for ch in s.chars() {
if ch == ' '
&& (out.ends_with(' ') || (out.is_empty() && rb.runs.last().is_none_or(|r| r.text.ends_with(' ') || r.text.ends_with('\n'))))
{
continue;
}
out.push(ch);
}
out
};
rb.push(&t, char_style(ctx, el));
} else if c.is_element() && matches!(c.tag_name().name(), "tspan" | "textPath" | "a") {
if ctx.own(c, "display").as_deref() == Some("none") {
continue;
}
if !rb.runs.is_empty() {
let lead = char_style(ctx, c).effective_leading().max(1e-6);
if let Some(y) = c.attribute("y") {
let y = first_number(Some(y));
let n = ((y - rb.last_y) / lead).round().max(1.0) as usize;
rb.newline(n);
rb.last_y = y;
} else if c.attribute("dy").is_some_and(|dy| first_number(Some(dy)) > 1e-9) {
rb.last_y += first_number(c.attribute("dy"));
rb.newline(1);
}
}
collect_runs(ctx, c, rb);
}
}
}
fn text_fallback(im: &mut Importer, svg: &str, w: f64, h: f64, layer_mode: bool) {
let Ok(xml) = roxmltree::Document::parse_with_options(svg, roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() }) else { return };
let ctx = TextCtx::new(&xml);
let root = xml.root_element();
let vb = view_box_transform(root, w, h);
const SKIP: [&str; 8] = ["defs", "clipPath", "mask", "pattern", "symbol", "marker", "title", "desc"];
for t in root.descendants().filter(|n| n.is_element() && n.tag_name().name() == "text") {
if t.ancestors().skip(1).any(|a| a.is_element() && SKIP.contains(&a.tag_name().name())) {
continue;
}
if t.ancestors().filter(|a| a.is_element()).any(|a| ctx.own(a, "display").as_deref() == Some("none")) {
continue;
}
if ctx.prop(t, "visibility").is_some_and(|v| v == "hidden" || v == "collapse") {
continue;
}
// Transform: viewBox, then each ancestor's transform from the root down.
let mut acc = vb;
let chain: Vec<XNode> = t.ancestors().filter(|a| a.is_element()).collect();
for a in chain.iter().rev() {
if *a != root {
acc *= parse_transform(a.attribute("transform"));
}
}
let y0 = first_number(t.attribute("y"));
let xf = acc * Affine::translate((first_number(t.attribute("x")), y0));
let preserve = t.ancestors().filter(|a| a.is_element()).find_map(|a| a.attribute((roxmltree::NS_XML_URI, "space"))) == Some("preserve");
// tspan y values are absolute in the text's user space, like the text's own y.
let mut rb = RunBuilder { runs: Vec::new(), last_y: y0, preserve };
collect_runs(&ctx, t, &mut rb);
if !preserve {
if let Some(f) = rb.runs.first_mut() {
f.text = f.text.trim_start().to_string();
}
if let Some(l) = rb.runs.last_mut() {
l.text = l.text.trim_end().to_string();
}
rb.runs.retain(|r| !r.text.is_empty());
}
if rb.runs.is_empty() {
continue;
}
let justify = match ctx.prop(t, "text-anchor").as_deref() {
Some("middle") => Justify::Center,
Some("end") => Justify::Right,
_ => Justify::Left,
};
let mut obj = TextObject { kind: TextKind::Point, xf, runs: rb.runs, para: Default::default(), cached_bounds: None };
obj.para.justify = justify;
let mut node = im.named(t.attribute("id").unwrap_or(""), NodeKind::Text(Box::new(obj)));
if let Some(o) = ctx.own(t, "opacity").and_then(|o| o.trim().parse::<f32>().ok()) {
node.opacity = o.clamp(0.0, 1.0);
}
// Place on the layer made from the top-level <g> containing the text, if any.
let top_id = if layer_mode { chain.iter().rev().nth(1).filter(|g| g.tag_name().name() == "g").and_then(|g| g.attribute("id")) } else { None };
let li = top_id.and_then(|id| im.doc.layers.iter().position(|l| l.name.as_deref() == Some(id)));
let li = match (li, top_id) {
(Some(i), _) => i,
(None, Some(id)) => {
im.doc.add_layer(Some(id));
im.doc.layers.len() - 1
}
(None, None) => im.doc.layers.len().saturating_sub(1),
};
if im.doc.layers.is_empty() {
im.doc.add_layer(Some("Layer 1"));
}
if let Some(ch) = Arc::make_mut(&mut im.doc.layers[li]).children_mut() {
ch.push(Arc::new(node));
}
}
}
+154
View File
@@ -0,0 +1,154 @@
//! DrawCraft SVG import and export.
//!
//! * [`export`] writes a [`Document`] as SVG 1.1 with our own writer (presentation attributes,
//! inline styles or internal CSS classes; gradients in `<defs>` with `userSpaceOnUse`; clip groups as
//! `<clipPath>`; embedded images as `data:` URIs; text as `<text>`/`<tspan>`).
//! * [`import`] / [`import_with_report`] parse SVG with `usvg` and convert its normalized tree into
//! document nodes with all transforms baked into the geometry.
//!
//! ## Export approximations
//!
//! * **Stroke alignment** is not expressible in SVG 1.1. An *inside* stroke is written as a stroke of
//! double width clipped to the path's own shape (`<clipPath>`); an *outside* stroke as a stroke of
//! double width masked by a `<mask>` that hides the path's interior. Both are visually exact for
//! closed paths but re-import as a group (clip group / masked stroke) rather than an aligned stroke.
//! * **Multiple fills/strokes** (or per-fill/stroke blend modes) are written as a `<g>` holding one
//! `<path>` per appearance item, in paint order.
//! * Effects, arrowheads, width profiles, brushes, patterns and freeform gradients are not
//! expanded (patterns export as `none`, freeform gradients as linear).
//!
//! ## Import approximations
//!
//! * Masks and filters are ignored (reported as warnings); nested clip paths use the outer clip only.
//! * usvg only keeps `<text>` when fonts are loaded; we don't load a font database (too expensive and
//! unavailable on wasm), so `<text>` elements are read directly from the XML as live point-type
//! [`TextObject`]s and placed on top of their layer.
#![forbid(unsafe_code)]
mod export;
mod import;
pub use drawcraft_doc::Document;
pub use drawcraft_doc::TextObject;
/// How style properties are written (Illustrator's "Styling" option).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Styling {
/// `fill="#ff0000"` attributes.
#[default]
PresentationAttributes,
/// `style="fill:#ff0000"`.
InlineStyle,
/// `class="cls-1"` with a `<style>` element in `<defs>`.
InternalCss,
}
/// SVG export options (Export As → SVG).
#[derive(Clone, Debug, PartialEq)]
pub struct ExportOptions {
/// Artboard index to export (sets the viewBox); `None` = the bounds of all art.
pub artboard: Option<usize>,
pub styling: Styling,
/// Decimal places for coordinates (1–7 in the dialog; default 3).
pub decimals: u8,
/// Write `id` attributes from layer and object names.
pub object_ids: bool,
/// No indentation or newlines, no XML declaration.
pub minify: bool,
/// Omit `width`/`height` so the SVG scales to its container.
pub responsive: bool,
}
impl Default for ExportOptions {
fn default() -> Self {
Self { artboard: Some(0), styling: Styling::PresentationAttributes, decimals: 3, object_ids: true, minify: false, responsive: false }
}
}
#[derive(Debug, thiserror::Error)]
pub enum SvgError {
#[error("SVG parse error: {0}")]
Parse(String),
}
/// Export a document as an SVG string.
pub fn export(doc: &Document, opts: &ExportOptions) -> String {
export::export(doc, opts)
}
/// Import an SVG document.
pub fn import(svg: &str) -> Result<Document, SvgError> {
import_with_report(svg).map(|(d, _)| d)
}
/// Import an SVG document, also returning warnings about unsupported or approximated features.
pub fn import_with_report(svg: &str) -> Result<(Document, Vec<String>), SvgError> {
import::import(svg)
}
/// Standard base64 (RFC 4648, with padding).
pub fn base64_encode(data: &[u8]) -> String {
const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
for c in data.chunks(3) {
let b = [c[0], *c.get(1).unwrap_or(&0), *c.get(2).unwrap_or(&0)];
let n = (b[0] as u32) << 16 | (b[1] as u32) << 8 | b[2] as u32;
out.push(T[(n >> 18) as usize & 63] as char);
out.push(T[(n >> 12) as usize & 63] as char);
out.push(if c.len() > 1 { T[(n >> 6) as usize & 63] as char } else { '=' });
out.push(if c.len() > 2 { T[n as usize & 63] as char } else { '=' });
}
out
}
/// Escape text for XML content and attribute values.
pub fn xml_escape(s: &str) -> String {
let mut o = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => o.push_str("&amp;"),
'<' => o.push_str("&lt;"),
'>' => o.push_str("&gt;"),
'"' => o.push_str("&quot;"),
'\'' => o.push_str("&apos;"),
_ => o.push(c),
}
}
o
}
/// Format a number with at most `decimals` places, trimming trailing zeros.
pub fn fmt_num(v: f64, decimals: u8) -> String {
let v = if v.is_finite() { v } else { 0.0 };
let s = format!("{:.*}", decimals as usize, v);
let s = if s.contains('.') { s.trim_end_matches('0').trim_end_matches('.').to_string() } else { s };
if s == "-0" || s.is_empty() { "0".into() } else { s }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn base64_vectors() {
assert_eq!(base64_encode(b""), "");
assert_eq!(base64_encode(b"f"), "Zg==");
assert_eq!(base64_encode(b"fo"), "Zm8=");
assert_eq!(base64_encode(b"foo"), "Zm9v");
assert_eq!(base64_encode(b"foobar"), "Zm9vYmFy");
}
#[test]
fn numbers() {
assert_eq!(fmt_num(1.0, 3), "1");
assert_eq!(fmt_num(1.23456, 3), "1.235");
assert_eq!(fmt_num(-0.0001, 3), "0");
assert_eq!(fmt_num(10.5, 1), "10.5");
assert_eq!(fmt_num(100.0, 0), "100");
}
#[test]
fn escaping() {
assert_eq!(xml_escape("a<b>&\"'"), "a&lt;b&gt;&amp;&quot;&apos;");
}
}
+640
View File
@@ -0,0 +1,640 @@
use std::sync::Arc;
use drawcraft_color::{BlendMode, Color, Gradient, GradientGeom, GradientKind, GradientPaint, Paint};
use drawcraft_doc::{
Appearance, AppearanceItem, CharStyle, Dash, Document, FillLayer, ImageBlob, ImageObject, LineCap, LineJoin, Node, NodeId, NodeKind, StrokeAlign,
StrokeLayer, TextObject,
};
use drawcraft_geom::{Affine, FillRule, PathData, Point, Rect, shapes};
use drawcraft_svg::{ExportOptions, Styling, export, import, import_with_report};
fn doc_with(nodes: Vec<Node>) -> Document {
let mut d = Document::new(200.0, 200.0);
let l = d.layers[0].id;
for n in nodes {
d.insert(Some(l), usize::MAX, n).unwrap();
}
d
}
fn rect_node(d: &mut Document, r: Rect, ap: Appearance) -> Node {
Node::path(d.alloc_id(), shapes::rectangle(r), ap)
}
fn roundtrip(d: &Document) -> Document {
let s = export(d, &ExportOptions::default());
import(&s).unwrap_or_else(|e| panic!("{e}\n{s}"))
}
/// All non-layer nodes in paint order.
fn art(d: &Document) -> Vec<&Node> {
let mut v = Vec::new();
d.walk(|n| {
if !n.is_layer() {
v.push(n)
}
});
v
}
fn close_rect(a: Rect, b: Rect, tol: f64) -> bool {
(a.x0 - b.x0).abs() < tol && (a.y0 - b.y0).abs() < tol && (a.x1 - b.x1).abs() < tol && (a.y1 - b.y1).abs() < tol
}
fn solid(hex: &str) -> Paint {
Paint::solid(Color::from_hex(hex).unwrap())
}
#[test]
fn rect_roundtrip_bounds_and_colours() {
let mut d = Document::new(200.0, 200.0);
let n = rect_node(&mut d, Rect::new(10.0, 20.0, 110.0, 70.0), Appearance::basic(solid("#ff8000"), solid("#0000ff"), 2.0));
let d = doc_with(vec![n]);
let r = roundtrip(&d);
assert_eq!(r.node_count(), d.node_count());
let a = art(&r);
assert!(close_rect(a[0].geometric_bounds().unwrap(), Rect::new(10.0, 20.0, 110.0, 70.0), 0.01));
assert_eq!(a[0].appearance.fill_paint().color().unwrap().to_hex(), "#ff8000");
assert_eq!(a[0].appearance.stroke_paint().color().unwrap().to_hex(), "#0000ff");
assert!((a[0].appearance.stroke_width() - 2.0).abs() < 1e-6);
assert_eq!(r.artboards[0].rect, Rect::new(0.0, 0.0, 200.0, 200.0));
}
#[test]
fn curves_roundtrip() {
let mut d = Document::new(200.0, 200.0);
let id = d.alloc_id();
let n = Node::path(id, shapes::ellipse(Rect::new(20.0, 30.0, 120.0, 90.0)), Appearance::default_art());
let d = doc_with(vec![n]);
let s = export(&d, &ExportOptions::default());
assert!(s.contains(" C"), "{s}");
let r = roundtrip(&d);
let a = art(&r);
assert!(close_rect(a[0].geometric_bounds().unwrap(), Rect::new(20.0, 30.0, 120.0, 90.0), 0.01));
assert_eq!(a[0].path_data().unwrap().anchor_count(), 4);
assert!(a[0].path_data().unwrap().is_closed());
}
#[test]
fn groups_and_names_roundtrip() {
let mut d = Document::new(200.0, 200.0);
let a = rect_node(&mut d, Rect::new(0.0, 0.0, 10.0, 10.0), Appearance::default_art());
let mut b = rect_node(&mut d, Rect::new(20.0, 0.0, 30.0, 10.0), Appearance::default_art());
b.name = Some("Box B".into());
let gid = d.alloc_id();
let mut g = Node::group(gid, vec![Arc::new(a), Arc::new(b)]);
g.name = Some("My Group".into());
let d = doc_with(vec![g]);
let s = export(&d, &ExportOptions::default());
assert!(s.contains("id=\"My_Group\"") && s.contains("id=\"Box_B\"") && s.contains("id=\"Layer_1\""), "{s}");
let r = roundtrip(&d);
assert_eq!(r.node_count(), d.node_count());
assert_eq!(r.layers[0].name.as_deref(), Some("Layer_1"));
let a = art(&r);
assert!(matches!(a[0].kind, NodeKind::Group { clip: false, .. }));
assert_eq!(a[0].name.as_deref(), Some("My_Group"));
assert!(close_rect(a[0].geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 30.0, 10.0), 0.01));
}
#[test]
fn multiple_layers_roundtrip() {
let mut d = Document::new(100.0, 100.0);
let n1 = rect_node(&mut d, Rect::new(0.0, 0.0, 10.0, 10.0), Appearance::default_art());
let l1 = d.layers[0].id;
d.insert(Some(l1), 0, n1).unwrap();
let l2 = d.add_layer(Some("Top"));
let n2 = rect_node(&mut d, Rect::new(5.0, 5.0, 50.0, 50.0), Appearance::default_art());
d.insert(Some(l2), 0, n2).unwrap();
let r = roundtrip(&d);
assert_eq!(r.layers.len(), 2);
assert_eq!(r.layers[1].name.as_deref(), Some("Top"));
assert_eq!(r.node_count(), d.node_count());
}
#[test]
fn clip_group_roundtrip() {
let mut d = Document::new(200.0, 200.0);
let cid = d.alloc_id();
let clip = Node::new(
cid,
NodeKind::Path { path: shapes::ellipse(Rect::new(0.0, 0.0, 50.0, 50.0)), rule: FillRule::NonZero, live: None, clipping: true, guide: false },
);
let a = rect_node(&mut d, Rect::new(-20.0, -20.0, 80.0, 80.0), Appearance::basic(solid("#00ff00"), Paint::None, 1.0));
let gid = d.alloc_id();
let g = Node::new(gid, NodeKind::Group { children: vec![Arc::new(clip), Arc::new(a)], clip: true });
let d = doc_with(vec![g]);
let s = export(&d, &ExportOptions::default());
assert!(s.contains("<clipPath") && s.contains("clip-path=\"url(#"), "{s}");
let r = roundtrip(&d);
assert_eq!(r.node_count(), d.node_count());
let a = art(&r);
assert!(matches!(a[0].kind, NodeKind::Group { clip: true, .. }));
assert!(matches!(a[1].kind, NodeKind::Path { clipping: true, .. }));
assert!(close_rect(a[0].geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 50.0, 50.0), 0.01));
assert_eq!(a[2].appearance.fill_paint().color().unwrap().to_hex(), "#00ff00");
}
#[test]
fn compound_evenodd_roundtrip() {
let mut d = Document::new(200.0, 200.0);
let o = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default());
let i = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(25.0, 25.0, 75.0, 75.0)), Appearance::default());
let cid = d.alloc_id();
let mut c = Node::new(cid, NodeKind::Compound { children: vec![Arc::new(o), Arc::new(i)], rule: FillRule::EvenOdd });
c.appearance = Appearance::basic(solid("#123456"), Paint::None, 1.0);
let d = doc_with(vec![c]);
let s = export(&d, &ExportOptions::default());
assert_eq!(s.matches("<path").count(), 1, "{s}");
assert!(s.contains("fill-rule=\"evenodd\""));
let r = roundtrip(&d);
assert_eq!(r.node_count(), d.node_count());
let a = art(&r);
assert!(matches!(a[0].kind, NodeKind::Compound { rule: FillRule::EvenOdd, .. }));
assert_eq!(a[0].appearance.fill_paint().color().unwrap().to_hex(), "#123456");
assert!(close_rect(a[0].geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 100.0, 100.0), 0.01));
}
#[test]
fn opacity_and_blend_roundtrip() {
let mut d = Document::new(200.0, 200.0);
let mut n = rect_node(&mut d, Rect::new(0.0, 0.0, 10.0, 10.0), Appearance::basic(solid("#ff0000"), Paint::None, 1.0));
n.opacity = 0.5;
n.blend = BlendMode::Multiply;
let d = doc_with(vec![n]);
let s = export(&d, &ExportOptions::default());
assert!(s.contains("opacity=\"0.5\"") && s.contains("mix-blend-mode:multiply"), "{s}");
let r = roundtrip(&d);
assert_eq!(r.node_count(), d.node_count());
let a = art(&r);
assert!((a[0].opacity - 0.5).abs() < 1e-3);
assert_eq!(a[0].blend, BlendMode::Multiply);
}
#[test]
fn fill_and_stroke_opacity_roundtrip() {
let mut d = Document::new(200.0, 200.0);
let mut ap = Appearance::basic(solid("#ff0000"), solid("#000000"), 3.0);
if let AppearanceItem::Fill(f) = &mut ap.items[0] {
f.opacity = 0.25;
}
ap.stroke_mut().unwrap().opacity = 0.75;
let n = rect_node(&mut d, Rect::new(0.0, 0.0, 10.0, 10.0), ap);
let r = roundtrip(&doc_with(vec![n]));
let a = art(&r);
assert!((a[0].appearance.fill().unwrap().opacity - 0.25).abs() < 1e-3);
assert!((a[0].appearance.stroke().unwrap().opacity - 0.75).abs() < 1e-3);
}
#[test]
fn stroke_attributes_roundtrip() {
let mut d = Document::new(200.0, 200.0);
let mut s = StrokeLayer::new(solid("#336699"), 4.0);
s.cap = LineCap::Round;
s.join = LineJoin::Bevel;
s.dash = Some(Dash { pattern: vec![6.0, 3.0], offset: 1.5, align_corners: false });
let ap = Appearance { items: vec![AppearanceItem::Stroke(s)], effects: vec![] };
let id = d.alloc_id();
let n = Node::path(id, shapes::line(Point::new(0.0, 0.0), Point::new(100.0, 50.0)), ap);
let d = doc_with(vec![n]);
let out = export(&d, &ExportOptions::default());
assert!(out.contains("fill=\"none\""), "{out}");
let r = roundtrip(&d);
let a = art(&r);
let st = a[0].appearance.stroke().unwrap();
assert_eq!(st.cap, LineCap::Round);
assert_eq!(st.join, LineJoin::Bevel);
assert!((st.width - 4.0).abs() < 1e-6);
let dash = st.dash.as_ref().unwrap();
assert_eq!(dash.pattern, vec![6.0, 3.0]);
assert!((dash.offset - 1.5).abs() < 1e-6);
assert!(a[0].appearance.fill().is_none());
let mut m = StrokeLayer::new(solid("#000000"), 1.0);
m.miter_limit = 7.0;
let n = Node::path(
NodeId(99),
shapes::rectangle(Rect::new(0.0, 0.0, 5.0, 5.0)),
Appearance { items: vec![AppearanceItem::Stroke(m)], effects: vec![] },
);
let r = roundtrip(&doc_with(vec![n]));
assert!((art(&r)[0].appearance.stroke().unwrap().miter_limit - 7.0).abs() < 1e-6);
}
#[test]
fn linear_gradient_roundtrip() {
let mut d = Document::new(200.0, 200.0);
let mut g = Gradient::default();
g.stops[1].opacity = 0.5;
let gp = GradientPaint {
gradient: g,
geom: Some(GradientGeom { start: Point::new(10.0, 20.0), end: Point::new(90.0, 60.0), aspect: 1.0 }),
angle: 0.0,
swatch: None,
};
let n = rect_node(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), Appearance::basic(Paint::Gradient(Box::new(gp)), Paint::None, 1.0));
let d = doc_with(vec![n]);
let s = export(&d, &ExportOptions::default());
assert!(s.contains("<linearGradient") && s.contains("gradientUnits=\"userSpaceOnUse\"") && s.contains("stop-opacity=\"0.5\""), "{s}");
let r = roundtrip(&d);
let Paint::Gradient(g) = art(&r)[0].appearance.fill_paint() else { panic!() };
assert_eq!(g.gradient.kind, GradientKind::Linear);
let geom = g.geom.unwrap();
assert!(geom.start.distance(Point::new(10.0, 20.0)) < 0.01 && geom.end.distance(Point::new(90.0, 60.0)) < 0.01);
assert_eq!(g.gradient.stops.len(), 2);
assert_eq!(g.gradient.stops[0].color.to_hex(), "#ffffff");
assert!((g.gradient.stops[1].opacity - 0.5).abs() < 1e-3);
}
#[test]
fn radial_gradient_and_fit_roundtrip() {
let mut d = Document::new(200.0, 200.0);
let g = Gradient { kind: GradientKind::Radial, ..Gradient::default() };
// geom None: resolved against the object bounds.
let n = rect_node(
&mut d,
Rect::new(0.0, 0.0, 100.0, 50.0),
Appearance::basic(Paint::Gradient(Box::new(GradientPaint::new(g.clone()))), Paint::None, 1.0),
);
// Elliptical radial via gradientTransform.
let gp = GradientPaint {
gradient: g,
geom: Some(GradientGeom { start: Point::new(150.0, 150.0), end: Point::new(150.0, 180.0), aspect: 0.5 }),
angle: 0.0,
swatch: None,
};
let n2 = rect_node(&mut d, Rect::new(100.0, 100.0, 200.0, 200.0), Appearance::basic(Paint::Gradient(Box::new(gp)), Paint::None, 1.0));
let r = roundtrip(&doc_with(vec![n, n2]));
let a = art(&r);
let Paint::Gradient(g) = a[0].appearance.fill_paint() else { panic!() };
let geom = g.geom.unwrap();
assert_eq!(g.gradient.kind, GradientKind::Radial);
assert!(geom.start.distance(Point::new(50.0, 25.0)) < 0.01, "{geom:?}");
assert!((geom.length() - 50.0).abs() < 0.01);
let Paint::Gradient(g) = a[1].appearance.fill_paint() else { panic!() };
let geom = g.geom.unwrap();
assert!(geom.start.distance(Point::new(150.0, 150.0)) < 0.01 && geom.end.distance(Point::new(150.0, 180.0)) < 0.01, "{geom:?}");
assert!((geom.aspect - 0.5).abs() < 1e-3);
}
#[test]
fn multiple_fills_stack_in_paint_order() {
let mut d = Document::new(200.0, 200.0);
let ap = Appearance {
items: vec![
AppearanceItem::Fill(FillLayer::new(solid("#ff0000"))),
AppearanceItem::Fill(FillLayer::new(solid("#00ff00"))),
AppearanceItem::Stroke(StrokeLayer::new(solid("#0000ff"), 2.0)),
],
effects: vec![],
};
let n = rect_node(&mut d, Rect::new(0.0, 0.0, 10.0, 10.0), ap);
let s = export(&doc_with(vec![n]), &ExportOptions::default());
let red = s.find("#ff0000").unwrap();
let green = s.find("#00ff00").unwrap();
let blue = s.find("#0000ff").unwrap();
assert!(red < green && green < blue, "{s}");
assert_eq!(s.matches("<path").count(), 3);
let r = import(&s).unwrap();
let a = art(&r);
assert!(matches!(a[0].kind, NodeKind::Group { .. }));
assert_eq!(a.len(), 4);
}
#[test]
fn stroke_alignment_approximations() {
let mut d = Document::new(200.0, 200.0);
let mut ap = Appearance::basic(solid("#ffffff"), solid("#000000"), 4.0);
ap.stroke_mut().unwrap().align = StrokeAlign::Inside;
let a = rect_node(&mut d, Rect::new(0.0, 0.0, 50.0, 50.0), ap.clone());
ap.stroke_mut().unwrap().align = StrokeAlign::Outside;
let b = rect_node(&mut d, Rect::new(60.0, 0.0, 110.0, 50.0), ap);
let s = export(&doc_with(vec![a, b]), &ExportOptions::default());
assert!(s.contains("<clipPath") && s.contains("<mask") && s.contains("stroke-width=\"8\""), "{s}");
let (_, warnings) = import_with_report(&s).unwrap();
assert!(warnings.iter().any(|w| w.contains("mask")));
}
#[test]
fn hidden_and_guides_omitted() {
let mut d = Document::new(200.0, 200.0);
let mut a = rect_node(&mut d, Rect::new(0.0, 0.0, 10.0, 10.0), Appearance::default_art());
a.visible = false;
let b = rect_node(&mut d, Rect::new(0.0, 0.0, 20.0, 20.0), Appearance::default_art());
let s = export(&doc_with(vec![a, b]), &ExportOptions::default());
assert_eq!(s.matches("<path").count(), 1);
let r = import(&s).unwrap();
assert_eq!(art(&r).len(), 1);
}
#[test]
fn image_roundtrip() {
let mut png = Vec::new();
let img = image::RgbaImage::from_pixel(4, 2, image::Rgba([255, 0, 0, 255]));
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
let mut d = Document::new(200.0, 200.0);
let id = d.alloc_id();
let n = Node::new(
id,
NodeKind::Image(ImageObject { key: "k".into(), width: 4, height: 2, xf: Affine::translate((10.0, 20.0)) * Affine::scale(10.0), link: None }),
);
let mut d = doc_with(vec![n]);
d.images.insert("k".into(), ImageBlob { mime: "image/png".into(), bytes: Arc::new(png.clone()) });
let s = export(&d, &ExportOptions::default());
assert!(s.contains("data:image/png;base64,"), "{s}");
let r = roundtrip(&d);
assert_eq!(r.node_count(), d.node_count());
let a = art(&r);
let NodeKind::Image(im) = &a[0].kind else { panic!("{:?}", a[0].kind) };
assert_eq!((im.width, im.height), (4, 2));
assert_eq!(*r.images[&im.key].bytes, png);
assert!(close_rect(a[0].geometric_bounds().unwrap(), Rect::new(10.0, 20.0, 50.0, 40.0), 0.01));
}
#[test]
fn text_roundtrip_and_escaping() {
let mut d = Document::new(200.0, 200.0);
let st = CharStyle { font_family: "Source Sans 3".into(), size: 18.0, fill: solid("#ff0000"), ..CharStyle::default() };
let t = TextObject::point(Point::new(20.0, 50.0), "A & B <C>\n\"q\"", st);
let id = d.alloc_id();
let mut n = Node::new(id, NodeKind::Text(Box::new(t)));
n.name = Some("a<b>".into());
let d = doc_with(vec![n]);
let s = export(&d, &ExportOptions::default());
assert!(s.contains("A &amp; B &lt;C&gt;") && s.contains("&quot;q&quot;") && s.contains("xml:space=\"preserve\""), "{s}");
assert!(!s.contains("a<b>"));
let r = roundtrip(&d);
assert_eq!(r.node_count(), d.node_count());
let a = art(&r);
let NodeKind::Text(t) = &a[0].kind else { panic!() };
assert_eq!(t.plain_text(), "A & B <C>\n\"q\"");
let f = t.first_style();
assert_eq!(f.font_family, "Source Sans 3");
assert!((f.size - 18.0).abs() < 1e-6);
assert_eq!(f.fill.color().unwrap().to_hex(), "#ff0000");
assert!(t.xf.translation().to_point().distance(Point::new(20.0, 50.0)) < 0.01);
}
#[test]
fn styling_modes_roundtrip() {
for styling in [Styling::PresentationAttributes, Styling::InlineStyle, Styling::InternalCss] {
let mut d = Document::new(200.0, 200.0);
let a = rect_node(&mut d, Rect::new(0.0, 0.0, 10.0, 10.0), Appearance::basic(solid("#abcdef"), solid("#010203"), 2.0));
let b = rect_node(&mut d, Rect::new(20.0, 0.0, 30.0, 10.0), Appearance::basic(solid("#abcdef"), solid("#010203"), 2.0));
let t = TextObject::point(Point::new(0.0, 50.0), "Hi", CharStyle { fill: solid("#00ff00"), size: 20.0, ..CharStyle::default() });
let tn = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t)));
let d = doc_with(vec![a, b, tn]);
let opts = ExportOptions { styling, ..Default::default() };
let s = export(&d, &opts);
match styling {
Styling::PresentationAttributes => assert!(s.contains("fill=\"#abcdef\"")),
Styling::InlineStyle => assert!(s.contains("style=\"fill:#abcdef")),
Styling::InternalCss => {
assert!(s.contains("<style>") && s.contains("class=\"cls-1\""));
assert_eq!(s.matches(".cls-").count(), 2, "{s}");
}
}
let r = import(&s).unwrap();
let a = art(&r);
assert_eq!(a.len(), 3);
assert_eq!(a[1].appearance.fill_paint().color().unwrap().to_hex(), "#abcdef");
assert_eq!(a[1].appearance.stroke_paint().color().unwrap().to_hex(), "#010203");
let NodeKind::Text(t) = &a[2].kind else { panic!() };
assert_eq!(t.first_style().fill.color().unwrap().to_hex(), "#00ff00", "{styling:?}\n{s}");
assert!((t.first_style().size - 20.0).abs() < 1e-6);
}
}
#[test]
fn options_decimals_minify_responsive_artboard() {
let mut d = Document::new(200.0, 200.0);
let n = rect_node(&mut d, Rect::new(110.123456, 120.0, 150.0, 150.0), Appearance::default_art());
let mut d = doc_with(vec![n]);
d.artboards[0].rect = Rect::new(100.0, 100.0, 300.0, 200.0);
let s = export(&d, &ExportOptions { decimals: 2, minify: true, responsive: true, ..Default::default() });
assert!(!s.trim_end().contains('\n'), "{s}");
assert!(!s.contains("<?xml"));
assert!(!s.contains(" width=\"200\""));
assert!(s.contains("viewBox=\"0 0 200 100\""), "{s}");
assert!(s.contains("M10.12 20"), "{s}");
let s = export(&d, &ExportOptions::default());
assert!(s.contains("width=\"200\" height=\"100\"") && s.contains("M10.123 20"), "{s}");
// All art bounds.
let s = export(&d, &ExportOptions { artboard: None, ..Default::default() });
assert!(s.contains("viewBox=\"0 0 43.877 34\""), "{s}");
let s = export(&d, &ExportOptions { object_ids: false, ..Default::default() });
assert!(!s.contains("id=\"Layer_1\""));
}
#[test]
fn unique_sanitized_ids() {
let mut d = Document::new(200.0, 200.0);
let mut a = rect_node(&mut d, Rect::new(0.0, 0.0, 1.0, 1.0), Appearance::default_art());
a.name = Some("1 thing".into());
let mut b = rect_node(&mut d, Rect::new(0.0, 0.0, 1.0, 1.0), Appearance::default_art());
b.name = Some("1 thing".into());
let mut c = rect_node(
&mut d,
Rect::new(0.0, 0.0, 1.0, 1.0),
Appearance::basic(Paint::Gradient(Box::new(GradientPaint::new(Gradient::default()))), Paint::None, 1.0),
);
c.name = Some("linear-gradient-1".into());
let s = export(&doc_with(vec![a, b, c]), &ExportOptions::default());
assert!(s.contains("id=\"_1_thing\"") && s.contains("id=\"_1_thing-2\""), "{s}");
assert!(s.contains("id=\"linear-gradient-2\""), "{s}");
}
// ---- hand-written SVG imports ----
fn import_art(svg: &str) -> Document {
import(svg).unwrap()
}
#[test]
fn import_basic_shapes() {
let d = import_art(
r##"<svg xmlns="http://www.w3.org/2000/svg" width="300" height="200">
<rect x="10" y="10" width="50" height="30" fill="red"/>
<circle cx="100" cy="50" r="20" fill="#00f"/>
<ellipse cx="200" cy="50" rx="30" ry="10" fill="none" stroke="black" stroke-width="2"/>
<polygon points="10,100 60,100 35,150" fill="rgb(0,128,0)"/>
<polyline points="100,100 150,150 200,100" fill="none" stroke="#333"/>
<line x1="0" y1="190" x2="300" y2="190" stroke="black"/>
</svg>"##,
);
assert_eq!(d.artboards[0].rect, Rect::new(0.0, 0.0, 300.0, 200.0));
let a = art(&d);
assert_eq!(a.len(), 6);
assert!(close_rect(a[0].geometric_bounds().unwrap(), Rect::new(10.0, 10.0, 60.0, 40.0), 0.01));
assert_eq!(a[0].appearance.fill_paint().color().unwrap().to_hex(), "#ff0000");
assert!(close_rect(a[1].geometric_bounds().unwrap(), Rect::new(80.0, 30.0, 120.0, 70.0), 0.01));
assert!(close_rect(a[2].geometric_bounds().unwrap(), Rect::new(170.0, 40.0, 230.0, 60.0), 0.01));
assert!(a[2].appearance.fill().is_none());
assert_eq!(a[3].path_data().unwrap().anchor_count(), 3);
assert!(a[3].path_data().unwrap().is_closed());
assert_eq!(a[3].appearance.fill_paint().color().unwrap().to_hex(), "#008000");
assert!(!a[4].path_data().unwrap().is_closed());
assert_eq!(a[5].path_data().unwrap().anchor_count(), 2);
}
#[test]
fn import_path_with_arcs() {
let d = import_art(
r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><path d="M10 50 A40 40 0 0 1 90 50 A40 40 0 0 1 10 50 Z"/></svg>"#,
);
let a = art(&d);
assert_eq!(a.len(), 1);
assert!(close_rect(a[0].geometric_bounds().unwrap(), Rect::new(10.0, 10.0, 90.0, 90.0), 0.05), "{:?}", a[0].geometric_bounds());
// Default fill is black.
assert_eq!(a[0].appearance.fill_paint().color().unwrap().to_hex(), "#000000");
}
#[test]
fn import_transforms_and_nested_groups() {
let d = import_art(
r#"<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200">
<g id="outer" transform="translate(50 50)">
<g id="inner" transform="scale(2)">
<rect width="10" height="10" transform="rotate(90)" stroke="black" stroke-width="1"/>
</g>
</g></svg>"#,
);
// One top-level <g id> → it becomes a layer.
assert_eq!(d.layers.len(), 1);
assert_eq!(d.layers[0].name.as_deref(), Some("outer"));
let a = art(&d);
assert_eq!(a.len(), 2);
assert_eq!(a[0].name.as_deref(), Some("inner"));
assert!(close_rect(a[1].geometric_bounds().unwrap(), Rect::new(30.0, 50.0, 50.0, 70.0), 0.01), "{:?}", a[1].geometric_bounds());
assert!((a[1].appearance.stroke_width() - 2.0).abs() < 1e-6);
}
#[test]
fn import_viewbox_scaling() {
let d = import_art(
r#"<svg xmlns="http://www.w3.org/2000/svg" width="200" height="100" viewBox="10 10 20 10"><rect x="10" y="10" width="10" height="10"/><text x="20" y="15" font-size="2">T</text></svg>"#,
);
assert_eq!(d.artboards[0].rect, Rect::new(0.0, 0.0, 200.0, 100.0));
let a = art(&d);
assert!(close_rect(a[0].geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 100.0, 100.0), 0.01));
let NodeKind::Text(t) = &a[1].kind else { panic!() };
assert!(t.xf.translation().to_point().distance(Point::new(100.0, 50.0)) < 0.01, "{:?}", t.xf);
}
#[test]
fn import_gradient_with_transform() {
let d = import_art(
r##"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="10" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="translate(10 0) rotate(90)">
<stop offset="0" stop-color="#f00"/><stop offset="1" stop-color="#00f" stop-opacity="0.5"/>
</linearGradient>
<radialGradient id="r"><stop offset="0" stop-color="white"/><stop offset="1" stop-color="black"/></radialGradient>
</defs>
<rect width="100" height="50" fill="url(#g)"/>
<rect y="50" width="100" height="50" fill="url(#r)"/>
</svg>"##,
);
let a = art(&d);
let Paint::Gradient(g) = a[0].appearance.fill_paint() else { panic!() };
let geom = g.geom.unwrap();
assert!(geom.start.distance(Point::new(10.0, 0.0)) < 1e-3 && geom.end.distance(Point::new(10.0, 10.0)) < 1e-3, "{geom:?}");
assert_eq!(g.gradient.stops[0].color.to_hex(), "#ff0000");
assert!((g.gradient.stops[1].opacity - 0.5).abs() < 1e-3);
// objectBoundingBox radial on a 100x50 box → centre (50,75), elliptical.
let Paint::Gradient(g) = a[1].appearance.fill_paint() else { panic!() };
let geom = g.geom.unwrap();
assert_eq!(g.gradient.kind, GradientKind::Radial);
assert!(geom.start.distance(Point::new(50.0, 75.0)) < 1e-3, "{geom:?}");
assert!((geom.length() - 50.0).abs() < 1e-3 && (geom.aspect - 0.5).abs() < 1e-3, "{geom:?}");
}
#[test]
fn import_use_and_defs() {
let d = import_art(
r##"<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100" height="100">
<defs><rect id="r" width="10" height="10" fill="#0f0"/></defs>
<use xlink:href="#r" x="20" y="30"/>
<use href="#r" x="50" y="60"/>
</svg>"##,
);
let a = art(&d);
let paths: Vec<_> = a.iter().filter(|n| n.path_data().is_some()).collect();
assert_eq!(paths.len(), 2);
assert!(close_rect(paths[0].geometric_bounds().unwrap(), Rect::new(20.0, 30.0, 30.0, 40.0), 0.01));
assert!(close_rect(paths[1].geometric_bounds().unwrap(), Rect::new(50.0, 60.0, 60.0, 70.0), 0.01));
assert_eq!(paths[1].appearance.fill_paint().color().unwrap().to_hex(), "#00ff00");
}
#[test]
fn import_clip_mask_and_warnings() {
let (d, w) = import_with_report(
r##"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
<defs>
<clipPath id="c"><circle cx="50" cy="50" r="25"/></clipPath>
<mask id="m"><rect width="100" height="100" fill="white"/></mask>
<pattern id="p" width="4" height="4" patternUnits="userSpaceOnUse"><rect width="2" height="2"/></pattern>
</defs>
<g clip-path="url(#c)"><rect width="100" height="100" fill="blue"/></g>
<g mask="url(#m)"><rect width="10" height="10" fill="url(#p)"/></g>
</svg>"##,
)
.unwrap();
let a = art(&d);
assert!(matches!(a[0].kind, NodeKind::Group { clip: true, .. }));
assert!(close_rect(a[0].geometric_bounds().unwrap(), Rect::new(25.0, 25.0, 75.0, 75.0), 0.01));
assert!(w.iter().any(|w| w.contains("mask")), "{w:?}");
assert!(w.iter().any(|w| w.contains("pattern")), "{w:?}");
}
#[test]
fn import_opacity_wrapper_folds_into_object() {
let d = import_art(
r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><rect width="10" height="10" opacity="0.4" transform="translate(5 5)" style="mix-blend-mode:screen"/></svg>"#,
);
let a = art(&d);
assert_eq!(a.len(), 1);
assert!((a[0].opacity - 0.4).abs() < 1e-3);
assert_eq!(a[0].blend, BlendMode::Screen);
assert!(close_rect(a[0].geometric_bounds().unwrap(), Rect::new(5.0, 5.0, 15.0, 15.0), 0.01));
}
#[test]
fn import_text_tspans_and_css() {
let d = import_art(
r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
<style>.big { font-size: 30px; fill: #00f }</style>
<text x="5" y="40" font-family="'Helvetica Neue', sans-serif" font-size="10">Hello <tspan class="big" font-weight="bold">World</tspan>
</text></svg>"#,
);
let a = art(&d);
let NodeKind::Text(t) = &a[0].kind else { panic!() };
assert_eq!(t.plain_text(), "Hello World");
assert_eq!(t.runs.len(), 2);
assert_eq!(t.runs[0].style.font_family, "Helvetica Neue");
assert!((t.runs[1].style.size - 30.0).abs() < 1e-6);
assert_eq!(t.runs[1].style.font_style, "Bold");
assert_eq!(t.runs[1].style.fill.color().unwrap().to_hex(), "#0000ff");
}
#[test]
fn import_invalid_is_error() {
assert!(import("not svg").is_err());
assert!(import("<svg xmlns=\"http://www.w3.org/2000/svg\"><rect").is_err());
}
#[test]
fn full_document_roundtrip_is_stable() {
// export → import → export produces identical SVG.
let mut d = Document::new(300.0, 300.0);
let a = rect_node(&mut d, Rect::new(10.0, 10.0, 60.0, 60.0), Appearance::basic(solid("#112233"), solid("#445566"), 3.0));
let e = Node::path(d.alloc_id(), shapes::ellipse(Rect::new(100.0, 100.0, 200.0, 150.0)), Appearance::default_art());
let gid = d.alloc_id();
let g = Node::group(gid, vec![Arc::new(a), Arc::new(e)]);
let d = doc_with(vec![g]);
let s1 = export(&d, &ExportOptions::default());
let r = import(&s1).unwrap();
let s2 = export(&r, &ExportOptions { object_ids: false, ..Default::default() });
let s1b = export(&d, &ExportOptions { object_ids: false, ..Default::default() });
let strip = |s: &str| s.lines().filter(|l| !l.contains("<title>")).collect::<Vec<_>>().join("\n");
assert_eq!(strip(&s1b), strip(&s2));
let _ = PathData::default();
}
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "drawcraft-testkit"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "DrawCraft test helpers"
[lints]
workspace = true
[dependencies]
drawcraft-geom = { workspace = true }
drawcraft-doc = { workspace = true }
+2
View File
@@ -0,0 +1,2 @@
//! DrawCraft test helpers
#![forbid(unsafe_code)]
+17
View File
@@ -0,0 +1,17 @@
[package]
name = "drawcraft-text"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "DrawCraft text: font database, shaping, layout, glyph outlines"
[lints]
workspace = true
[dependencies]
drawcraft-geom = { workspace = true }
drawcraft-doc = { workspace = true }
kurbo = { workspace = true }
skrifa = { workspace = true }
harfrust = { workspace = true }
log = { workspace = true }
+407
View File
@@ -0,0 +1,407 @@
//! Font database: bundled OFL fonts, user fonts, optional system font catalog, outline cache.
use std::collections::HashMap;
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::{Arc, Mutex, RwLock};
use kurbo::BezPath;
use skrifa::instance::{LocationRef, Size};
use skrifa::outline::{DrawSettings, OutlinePen};
use skrifa::raw::FileRef;
use skrifa::string::StringId;
use skrifa::{GlyphId, MetadataProvider};
/// The family used when a requested family is unknown (Illustrator's Myriad Pro analogue).
pub const FALLBACK_FAMILY: &str = "Source Sans 3";
static BUNDLED: &[&[u8]] = &[
include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf"),
include_bytes!("../../../assets/fonts/SourceSans3-Semibold.ttf"),
include_bytes!("../../../assets/fonts/SourceSans3-Bold.ttf"),
include_bytes!("../../../assets/fonts/SourceSans3-It.ttf"),
include_bytes!("../../../assets/fonts/SourceSerif4-Regular.ttf"),
include_bytes!("../../../assets/fonts/Inter-Regular.ttf"),
include_bytes!("../../../assets/fonts/Inter-Medium.ttf"),
include_bytes!("../../../assets/fonts/Inter-SemiBold.ttf"),
include_bytes!("../../../assets/fonts/JetBrainsMono-Regular.ttf"),
];
enum FontBytes {
Static(&'static [u8]),
Owned(Arc<Vec<u8>>),
}
/// One loaded font face.
pub struct FontFace {
id: u32,
/// Typographic family name (e.g. "Source Sans 3").
pub family: String,
/// Typographic style name (e.g. "Semibold", "Italic").
pub style: String,
/// usWeightClass-style weight (400 = regular).
pub weight: f32,
pub italic: bool,
bytes: FontBytes,
index: u32,
pub(crate) upem: f64,
/// Ascender in font units (positive = up).
pub(crate) ascent: f64,
/// Descender in font units (positive = down).
pub(crate) descent: f64,
pub(crate) shaper: harfrust::ShaperData,
}
impl std::fmt::Debug for FontFace {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "FontFace({} {})", self.family, self.style)
}
}
impl FontFace {
pub(crate) fn data(&self) -> &[u8] {
match &self.bytes {
FontBytes::Static(b) => b,
FontBytes::Owned(v) => v.as_slice(),
}
}
pub(crate) fn skrifa(&self) -> Option<skrifa::FontRef<'_>> {
skrifa::FontRef::from_index(self.data(), self.index).ok()
}
pub(crate) fn hb(&self) -> Option<harfrust::FontRef<'_>> {
harfrust::FontRef::from_index(self.data(), self.index).ok()
}
/// Unique id of this face within the process.
pub fn id(&self) -> u32 {
self.id
}
/// Does the face map `c` to a glyph?
pub fn covers(&self, c: char) -> bool {
self.skrifa().is_some_and(|f| f.charmap().map(c).is_some())
}
}
#[cfg(not(target_arch = "wasm32"))]
#[derive(Clone, Debug)]
struct CatalogEntry {
family: String,
style: String,
path: std::path::PathBuf,
}
/// Process-wide font database.
pub struct FontDb {
faces: RwLock<Vec<Arc<FontFace>>>,
outlines: Mutex<HashMap<(u32, u32), Arc<BezPath>>>,
#[cfg(not(target_arch = "wasm32"))]
catalog: RwLock<Vec<CatalogEntry>>,
}
static NEXT_ID: AtomicU32 = AtomicU32::new(1);
const OUTLINE_CACHE_MAX: usize = 50_000;
fn name(font: &skrifa::FontRef<'_>, ids: &[StringId]) -> Option<String> {
ids.iter().find_map(|id| font.localized_strings(*id).english_or_first().map(|s| s.to_string()).filter(|s| !s.is_empty()))
}
/// Parse every face in `data` (a font file or collection). Returns `(index, family, style)`.
fn enumerate_faces(data: &[u8]) -> Vec<(u32, String, String)> {
let count = match FileRef::new(data) {
Ok(FileRef::Font(_)) => 1,
Ok(FileRef::Collection(c)) => c.len(),
Err(_) => 0,
};
(0..count)
.filter_map(|i| {
let f = skrifa::FontRef::from_index(data, i).ok()?;
let family = name(&f, &[StringId::TYPOGRAPHIC_FAMILY_NAME, StringId::FAMILY_NAME])?;
let style = name(&f, &[StringId::TYPOGRAPHIC_SUBFAMILY_NAME, StringId::SUBFAMILY_NAME]).unwrap_or_else(|| "Regular".into());
Some((i, family, style))
})
.collect()
}
fn make_face(bytes: FontBytes, index: u32, family: String, style: String) -> Option<FontFace> {
let data: &[u8] = match &bytes {
FontBytes::Static(b) => b,
FontBytes::Owned(v) => v.as_slice(),
};
let f = skrifa::FontRef::from_index(data, index).ok()?;
let m = f.metrics(Size::unscaled(), LocationRef::default());
let a = f.attributes();
let shaper = harfrust::ShaperData::new(&harfrust::FontRef::from_index(data, index).ok()?);
Some(FontFace {
id: NEXT_ID.fetch_add(1, Ordering::Relaxed),
family,
style,
weight: a.weight.value(),
italic: !matches!(a.style, skrifa::attribute::Style::Normal),
upem: m.units_per_em.max(1) as f64,
ascent: m.ascent as f64,
descent: -(m.descent as f64),
shaper,
bytes,
index,
})
}
fn norm(s: &str) -> String {
s.chars().filter(|c| c.is_alphanumeric()).flat_map(|c| c.to_lowercase()).collect()
}
/// Weight implied by a style name.
fn style_weight(style: &str) -> f32 {
let s = norm(style);
const TABLE: &[(&str, f32)] = &[
("extralight", 200.0),
("ultralight", 200.0),
("semibold", 600.0),
("demibold", 600.0),
("extrabold", 800.0),
("ultrabold", 800.0),
("hairline", 100.0),
("thin", 100.0),
("light", 300.0),
("medium", 500.0),
("bold", 700.0),
("black", 900.0),
("heavy", 900.0),
];
TABLE.iter().find(|(k, _)| s.contains(k)).map(|(_, w)| *w).unwrap_or(400.0)
}
fn style_italic(style: &str) -> bool {
let s = norm(style);
s.contains("italic") || s.contains("oblique") || s == "it"
}
impl FontDb {
fn new_bundled() -> Self {
let mut faces = Vec::new();
for data in BUNDLED {
for (i, family, style) in enumerate_faces(data) {
if let Some(f) = make_face(FontBytes::Static(data), i, family, style) {
faces.push(Arc::new(f));
}
}
}
Self {
faces: RwLock::new(faces),
outlines: Mutex::new(HashMap::new()),
#[cfg(not(target_arch = "wasm32"))]
catalog: RwLock::new(Vec::new()),
}
}
/// Process-wide database preloaded with the bundled fonts.
pub fn global() -> &'static FontDb {
static DB: std::sync::OnceLock<FontDb> = std::sync::OnceLock::new();
DB.get_or_init(FontDb::new_bundled)
}
fn read_faces(&self) -> std::sync::RwLockReadGuard<'_, Vec<Arc<FontFace>>> {
self.faces.read().unwrap_or_else(|e| e.into_inner())
}
/// Family names available (loaded plus cataloged system fonts), sorted and deduplicated.
pub fn families(&self) -> Vec<String> {
let mut v: Vec<String> = self.read_faces().iter().map(|f| f.family.clone()).collect();
#[cfg(not(target_arch = "wasm32"))]
v.extend(self.catalog.read().unwrap_or_else(|e| e.into_inner()).iter().map(|c| c.family.clone()));
v.sort_by_key(|a| a.to_lowercase());
v.dedup_by(|a, b| a.eq_ignore_ascii_case(b));
v
}
/// Style names available for `family` (Regular first, then by weight).
pub fn styles(&self, family: &str) -> Vec<String> {
let mut v: Vec<(bool, f32, String)> =
self.read_faces().iter().filter(|f| f.family.eq_ignore_ascii_case(family)).map(|f| (f.italic, f.weight, f.style.clone())).collect();
#[cfg(not(target_arch = "wasm32"))]
for c in self.catalog.read().unwrap_or_else(|e| e.into_inner()).iter() {
if c.family.eq_ignore_ascii_case(family) && !v.iter().any(|(_, _, s)| s.eq_ignore_ascii_case(&c.style)) {
v.push((style_italic(&c.style), style_weight(&c.style), c.style.clone()));
}
}
v.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.total_cmp(&b.1)).then(a.2.cmp(&b.2)));
v.dedup_by(|a, b| a.2 == b.2);
v.into_iter().map(|t| t.2).collect()
}
/// Add a user font (TTF/OTF/TTC bytes). Returns the number of faces added (0 if unparseable or
/// every face was already present).
pub fn add_font(&self, bytes: Vec<u8>) -> usize {
let data = Arc::new(bytes);
let mut added = 0;
for (i, family, style) in enumerate_faces(&data) {
if self.read_faces().iter().any(|f| f.family.eq_ignore_ascii_case(&family) && f.style.eq_ignore_ascii_case(&style)) {
continue;
}
if let Some(f) = make_face(FontBytes::Owned(data.clone()), i, family, style) {
self.faces.write().unwrap_or_else(|e| e.into_inner()).push(Arc::new(f));
added += 1;
}
}
added
}
/// Scan the platform's font directories and catalog the faces found (native only; not called by
/// default). Font data is loaded lazily when a cataloged family is first resolved. Returns the
/// number of faces cataloged.
#[cfg(not(target_arch = "wasm32"))]
pub fn load_system_fonts(&self) -> usize {
let mut dirs: Vec<std::path::PathBuf> = Vec::new();
let home = std::env::var_os("HOME").map(std::path::PathBuf::from);
if cfg!(target_os = "macos") {
dirs.extend(["/System/Library/Fonts", "/Library/Fonts"].map(Into::into));
if let Some(h) = &home {
dirs.push(h.join("Library/Fonts"));
}
} else if cfg!(windows) {
let root = std::env::var_os("WINDIR").map(std::path::PathBuf::from).unwrap_or_else(|| "C:\\Windows".into());
dirs.push(root.join("Fonts"));
if let Some(l) = std::env::var_os("LOCALAPPDATA") {
dirs.push(std::path::PathBuf::from(l).join("Microsoft\\Windows\\Fonts"));
}
} else {
dirs.extend(["/usr/share/fonts", "/usr/local/share/fonts"].map(Into::into));
if let Some(h) = &home {
dirs.push(h.join(".fonts"));
dirs.push(h.join(".local/share/fonts"));
}
}
let mut found = Vec::new();
let mut stack = dirs;
while let Some(d) = stack.pop() {
let Ok(rd) = std::fs::read_dir(&d) else { continue };
for e in rd.flatten() {
let p = e.path();
if p.is_dir() {
stack.push(p);
continue;
}
let ext = p.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase());
if !matches!(ext.as_deref(), Some("ttf" | "otf" | "ttc" | "otc")) {
continue;
}
let Ok(data) = std::fs::read(&p) else { continue };
for (_, family, style) in enumerate_faces(&data) {
found.push(CatalogEntry { family, style, path: p.clone() });
}
}
}
let n = found.len();
log::debug!("cataloged {n} system font faces");
*self.catalog.write().unwrap_or_else(|e| e.into_inner()) = found;
n
}
#[cfg(not(target_arch = "wasm32"))]
fn load_cataloged(&self, family: &str) -> bool {
let paths: Vec<std::path::PathBuf> = {
let cat = self.catalog.read().unwrap_or_else(|e| e.into_inner());
let mut p: Vec<_> = cat.iter().filter(|c| c.family.eq_ignore_ascii_case(family)).map(|c| c.path.clone()).collect();
p.dedup();
p
};
let mut any = false;
for p in paths {
if let Ok(data) = std::fs::read(&p) {
any |= self.add_font(data) > 0;
}
}
any
}
/// Resolve a family + style to a face, falling back to the closest style of the family, then to
/// Source Sans 3 Regular.
pub fn face(&self, family: &str, style: &str) -> Arc<FontFace> {
if let Some(f) = self.find(family, style) {
return f;
}
#[cfg(not(target_arch = "wasm32"))]
if self.load_cataloged(family)
&& let Some(f) = self.find(family, style)
{
return f;
}
self.find(FALLBACK_FAMILY, style)
.or_else(|| self.find(FALLBACK_FAMILY, "Regular"))
.or_else(|| self.read_faces().first().cloned())
.expect("bundled fonts are always present")
}
/// Is `family` available (loaded)?
pub fn has_family(&self, family: &str) -> bool {
self.read_faces().iter().any(|f| f.family.eq_ignore_ascii_case(family))
}
fn find(&self, family: &str, style: &str) -> Option<Arc<FontFace>> {
let faces = self.read_faces();
let cands: Vec<&Arc<FontFace>> = faces.iter().filter(|f| f.family.eq_ignore_ascii_case(family)).collect();
if cands.is_empty() {
return None;
}
let ns = norm(style);
if let Some(f) = cands.iter().find(|f| norm(&f.style) == ns) {
return Some((*f).clone());
}
let (tw, ti) = (style_weight(style), style_italic(style));
cands
.iter()
.min_by(|a, b| {
let sa = (a.weight - tw).abs() + if a.italic != ti { 1000.0 } else { 0.0 };
let sb = (b.weight - tw).abs() + if b.italic != ti { 1000.0 } else { 0.0 };
sa.total_cmp(&sb)
})
.map(|f| (*f).clone())
}
/// First face (fallback family first, then load order) that covers `c`.
pub(crate) fn fallback_for(&self, c: char, exclude: u32) -> Option<Arc<FontFace>> {
let faces = self.read_faces();
let mut order: Vec<&Arc<FontFace>> = faces.iter().filter(|f| f.id != exclude).collect();
order.sort_by_key(|f| (!f.family.eq_ignore_ascii_case(FALLBACK_FAMILY), f.italic, (f.weight - 400.0).abs() as i32));
order.into_iter().find(|f| f.covers(c)).cloned()
}
/// Glyph outline in font units, y-down (flipped), cached per (face, glyph).
pub(crate) fn outline(&self, face: &FontFace, gid: u32) -> Arc<BezPath> {
let key = (face.id, gid);
if let Some(p) = self.outlines.lock().unwrap_or_else(|e| e.into_inner()).get(&key) {
return p.clone();
}
let mut pen = FlipPen(BezPath::new());
if let Some(f) = face.skrifa()
&& let Some(g) = f.outline_glyphs().get(GlyphId::new(gid))
{
let _ = g.draw(DrawSettings::unhinted(Size::unscaled(), LocationRef::default()), &mut pen);
}
let p = Arc::new(pen.0);
let mut cache = self.outlines.lock().unwrap_or_else(|e| e.into_inner());
if cache.len() >= OUTLINE_CACHE_MAX {
cache.clear();
}
cache.insert(key, p.clone());
p
}
}
struct FlipPen(BezPath);
impl OutlinePen for FlipPen {
fn move_to(&mut self, x: f32, y: f32) {
self.0.move_to((x as f64, -y as f64));
}
fn line_to(&mut self, x: f32, y: f32) {
self.0.line_to((x as f64, -y as f64));
}
fn quad_to(&mut self, cx0: f32, cy0: f32, x: f32, y: f32) {
self.0.quad_to((cx0 as f64, -cy0 as f64), (x as f64, -y as f64));
}
fn curve_to(&mut self, cx0: f32, cy0: f32, cx1: f32, cy1: f32, x: f32, y: f32) {
self.0.curve_to((cx0 as f64, -cy0 as f64), (cx1 as f64, -cy1 as f64), (x as f64, -y as f64));
}
fn close(&mut self) {
self.0.close_path();
}
}
+415
View File
@@ -0,0 +1,415 @@
//! Line breaking and glyph placement for point, area and on-path type.
use std::ops::Range;
use drawcraft_doc::{CharStyle, Justify, ParaStyle, TextKind, TextObject};
use kurbo::{Affine, BezPath, ParamCurve, ParamCurveArclen, PathEl, PathSeg, Point, Rect, Shape, Vec2};
use crate::fontdb::FontDb;
use crate::shape::{SGlyph, shape_range, style_metrics};
use crate::{LineInfo, PositionedGlyph, TextLayout};
const EPS: f64 = 1e-6;
struct Ctx<'a> {
db: &'a FontDb,
text: &'a str,
runs: Vec<(Range<usize>, &'a CharStyle)>,
default: CharStyle,
out: TextLayout,
}
impl Ctx<'_> {
fn style_at(&self, b: usize) -> &CharStyle {
let find = |b: usize| self.runs.iter().find(|(r, _)| r.start <= b && b < r.end).map(|(_, s)| *s);
find(b).or_else(|| b.checked_sub(1).and_then(find)).or_else(|| self.runs.first().map(|(_, s)| *s)).unwrap_or(&self.default)
}
fn shape_para(&self, r: Range<usize>) -> Vec<SGlyph> {
let mut v = Vec::with_capacity(r.len());
shape_range(self.db, self.text, r, &self.runs, &mut v);
v
}
fn emit(&mut self, g: &SGlyph, pre: Affine, origin: Point, angle: f64, advance: f64, line: usize) {
let local = Affine::rotate(-g.rotation.to_radians()) * Affine::translate((g.dx, g.dy - g.bshift)) * Affine::scale_non_uniform(g.sx, g.sy);
let src = self.db.outline(&g.face, g.gid);
let outline = if src.elements().is_empty() {
BezPath::new()
} else {
let mut p = (*src).clone();
p.apply_affine(pre * local);
p
};
self.out.glyphs.push(PositionedGlyph { outline, run: g.run, byte: g.byte, origin, advance, len: g.len, angle, line, font_id: g.face.id() });
}
}
/// Lay out a text object into text-space glyph outlines.
pub fn layout(db: &FontDb, t: &TextObject) -> TextLayout {
let text = t.plain_text();
let mut runs = Vec::with_capacity(t.runs.len());
let mut off = 0;
for r in &t.runs {
runs.push((off..off + r.text.len(), &r.style));
off += r.text.len();
}
let mut paras = Vec::new();
let mut s = 0;
for (i, c) in text.char_indices() {
if c == '\n' {
paras.push(s..i);
s = i + 1;
}
}
paras.push(s..text.len());
let mut cx = Ctx { db, text: &text, runs, default: CharStyle::default(), out: TextLayout::default() };
match &t.kind {
TextKind::Point => flow(&mut cx, &paras, &t.para, None),
TextKind::Area { frame } => {
let region = Region::new(&frame.to_bezpath());
flow(&mut cx, &paras, &t.para, Some(&region));
}
TextKind::OnPath { path, start } => on_path(&mut cx, &paras, &t.para, &path.to_bezpath(), *start, path.is_closed()),
}
finish_bounds(&mut cx.out);
cx.out
}
fn finish_bounds(out: &mut TextLayout) {
let mut b: Option<Rect> = None;
let mut add = |r: Rect| b = Some(b.map_or(r, |b| b.union(r)));
for g in &out.glyphs {
if !g.outline.elements().is_empty() {
add(g.outline.bounding_box());
}
}
if !out.on_path {
for l in &out.lines {
add(Rect::new(l.x0.min(l.x1), l.baseline - l.ascent, l.x0.max(l.x1), l.baseline + l.descent));
}
}
out.bounds = b.unwrap_or_default();
}
/// A flattened area-type frame.
struct Region {
bbox: Rect,
polys: Vec<Vec<Point>>,
}
impl Region {
fn new(path: &BezPath) -> Self {
let mut polys: Vec<Vec<Point>> = Vec::new();
kurbo::flatten(path, 0.1, |el| match el {
PathEl::MoveTo(p) => polys.push(vec![p]),
PathEl::LineTo(p) => {
if let Some(v) = polys.last_mut() {
v.push(p);
}
}
_ => {}
});
polys.retain(|p| p.len() >= 3);
Self { bbox: path.bounding_box(), polys }
}
/// Inside intervals (even-odd) of the horizontal line at `y`.
fn intervals(&self, y: f64) -> Vec<(f64, f64)> {
let mut xs = Vec::new();
for poly in &self.polys {
for i in 0..poly.len() {
let a = poly[i];
let b = poly[(i + 1) % poly.len()];
if (a.y <= y) != (b.y <= y) {
xs.push(a.x + (y - a.y) / (b.y - a.y) * (b.x - a.x));
}
}
}
xs.sort_by(f64::total_cmp);
xs.chunks_exact(2).map(|c| (c[0], c[1])).collect()
}
/// Widest horizontal span inside the frame over the band `top..bottom`.
fn span(&self, top: f64, bottom: f64) -> Option<(f64, f64)> {
if self.polys.is_empty() {
return (self.bbox.width() > 0.0).then_some((self.bbox.x0, self.bbox.x1));
}
let clamp = |y: f64| y.clamp(self.bbox.y0 + 1e-4, self.bbox.y1 - 1e-4);
let mid = self.intervals(clamp((top + bottom) * 0.5));
let others = [self.intervals(clamp(top)), self.intervals(clamp(bottom))];
mid.into_iter()
.filter_map(|(mut a, mut b)| {
for o in &others {
let best =
o.iter().map(|&(c, d)| (a.max(c), b.min(d))).filter(|(c, d)| d > c).max_by(|x, y| (x.1 - x.0).total_cmp(&(y.1 - y.0)))?;
a = best.0;
b = best.1;
}
(b > a).then_some((a, b))
})
.max_by(|x, y| (x.1 - x.0).total_cmp(&(y.1 - y.0)))
}
}
/// Greedy break: returns the end glyph index for a line starting at `i` of the given width.
fn break_line(g: &[SGlyph], i: usize, width: f64) -> usize {
if !width.is_finite() {
return g.len();
}
let mut x = 0.0;
let mut last_break = None;
let mut j = i;
while j < g.len() {
let gl = &g[j];
if j > i && !gl.is_space() && x + gl.adv > width + EPS {
break;
}
x += gl.adv;
if gl.break_after() {
last_break = Some(j + 1);
}
j += 1;
}
if j >= g.len() {
return g.len();
}
// Don't separate a cluster's glyphs.
let mut end = match last_break {
Some(b) if b > i => b,
_ => j,
};
while end > i + 1 && end < g.len() && g[end].byte == g[end - 1].byte {
end -= 1;
}
end
}
fn max_metrics(g: &[SGlyph]) -> Option<(f64, f64, f64)> {
(!g.is_empty()).then(|| g.iter().fold((0.0f64, 0.0f64, 0.0f64), |m, g| (m.0.max(g.ascent), m.1.max(g.descent), m.2.max(g.leading))))
}
fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], para: &ParaStyle, region: Option<&Region>) {
let mut prev_baseline: Option<f64> = None;
let mut pending_space = 0.0;
'paras: for (pi, pr) in paras.iter().enumerate() {
let sg = cx.shape_para(pr.clone());
let pm = style_metrics(cx.db, cx.style_at(pr.start));
if pi > 0 {
pending_space += para.space_before;
}
let n = sg.len();
let mut i = 0;
let mut first_line = true;
loop {
let est = if i < n { (sg[i].ascent, sg[i].descent, sg[i].leading) } else { pm };
let first_ind = if first_line { para.first_line_indent } else { 0.0 };
let ind_l = para.left_indent + first_ind;
let mut baseline = match (prev_baseline, region) {
(None, Some(r)) => r.bbox.y0 + est.0,
(None, None) => 0.0,
(Some(b), _) => b + est.2 + pending_space,
};
let (x0, x1) = match region {
None => (f64::NEG_INFINITY, f64::INFINITY),
Some(r) => loop {
if baseline + est.1 > r.bbox.y1 + 0.01 {
cx.out.overflow = cx.text.len() > if i < n { sg[i].byte } else { pr.start };
break 'paras;
}
match r.span(baseline - est.0, baseline + est.1) {
Some((a, b)) if b - a - ind_l - para.right_indent > est.0.max(1.0) => break (a, b),
_ => baseline += est.2.max(1.0),
}
},
};
let (ax0, ax1) = (x0 + ind_l, x1 - para.right_indent);
let width = ax1 - ax0;
let end = if i < n { break_line(&sg, i, width) } else { n };
let m = max_metrics(&sg[i..end]).unwrap_or(pm);
baseline += if prev_baseline.is_none() { m.0 - est.0 } else { m.2 - est.2 };
if let Some(r) = region
&& baseline + m.1 > r.bbox.y1 + 0.01
{
cx.out.overflow = cx.text.len() > if i < n { sg[i].byte } else { pr.start };
break 'paras;
}
pending_space = 0.0;
let last_of_para = end >= n;
let mut trimmed = end;
while trimmed > i && sg[trimmed - 1].is_space() {
trimmed -= 1;
}
let w: f64 = sg[i..trimmed].iter().map(|g| g.adv).sum();
let (align, justify) = match para.justify {
Justify::Left => (0, false),
Justify::Center => (1, false),
Justify::Right => (2, false),
Justify::JustifyLeft => (0, !last_of_para),
Justify::JustifyCenter => (1, !last_of_para),
Justify::JustifyRight => (2, !last_of_para),
Justify::JustifyAll => (0, true),
};
let justify = justify && region.is_some();
let (mut per_space, mut per_gap) = (0.0, 0.0);
if justify && width - w > EPS {
let spaces = sg[i..trimmed].iter().filter(|g| g.is_space()).count();
if spaces > 0 {
per_space = (width - w) / spaces as f64;
} else if para.justify == Justify::JustifyAll && trimmed - i > 1 {
per_gap = (width - w) / (trimmed - i - 1) as f64;
}
}
let start_x = if justify {
ax0
} else if region.is_none() {
match align {
0 => ind_l,
1 => (ind_l - para.right_indent - w) * 0.5,
_ => -para.right_indent - w,
}
} else {
match align {
0 => ax0,
1 => ax0 + (width - w) * 0.5,
_ => ax1 - w,
}
};
let li = cx.out.lines.len();
let glyph_start = cx.out.glyphs.len();
let mut x = start_x;
let mut x_end = start_x;
for (j, g) in sg.iter().enumerate().take(end).skip(i) {
let mut adv = g.adv;
if j < trimmed {
if g.is_space() {
adv += per_space;
} else if j + 1 < trimmed {
adv += per_gap;
}
}
cx.emit(g, Affine::translate((x, baseline)), Point::new(x, baseline), 0.0, adv, li);
x += adv;
if j + 1 == trimmed {
x_end = x;
}
}
cx.out.lines.push(LineInfo {
baseline,
x0: start_x,
x1: x_end,
ascent: m.0,
descent: m.1,
start: if i < n { sg[i].byte } else { pr.start },
end: if last_of_para { pr.end } else { sg[end].byte },
glyph_start,
glyph_end: cx.out.glyphs.len(),
});
prev_baseline = Some(baseline);
first_line = false;
i = end;
if i >= n {
break;
}
}
pending_space += para.space_after;
}
}
/// Arc-length parameterised path.
struct ArcPath {
segs: Vec<(PathSeg, f64, f64)>,
len: f64,
}
impl ArcPath {
fn new(p: &BezPath) -> Self {
let mut segs = Vec::new();
let mut cum = 0.0;
for s in p.segments() {
let l = s.arclen(1e-4);
if l > 1e-9 {
segs.push((s, cum, l));
cum += l;
}
}
Self { segs, len: cum }
}
/// Point and unit tangent at arc length `s`.
fn at(&self, s: f64) -> (Point, Vec2) {
let s = s.clamp(0.0, self.len);
let i = self.segs.partition_point(|(_, c, _)| *c <= s).saturating_sub(1);
let (seg, c, l) = self.segs[i];
let t = seg.inv_arclen((s - c).min(l), 1e-4).clamp(0.0, 1.0);
let p = seg.eval(t);
let (t0, t1) = ((t - 1e-4).max(0.0), (t + 1e-4).min(1.0));
let d = seg.eval(t1) - seg.eval(t0);
let len = d.hypot();
(p, if len > 1e-12 { d / len } else { Vec2::new(1.0, 0.0) })
}
}
fn on_path(cx: &mut Ctx<'_>, paras: &[Range<usize>], para: &ParaStyle, path: &BezPath, start: f64, closed: bool) {
cx.out.on_path = true;
let mut sg = Vec::new();
for pr in paras {
sg.extend(cx.shape_para(pr.clone()));
}
let ap = ArcPath::new(path);
let m = max_metrics(&sg).unwrap_or_else(|| style_metrics(cx.db, cx.style_at(0)));
let text_len = cx.text.len();
if ap.segs.is_empty() {
cx.out.overflow = !sg.is_empty();
cx.out.lines.push(LineInfo {
baseline: 0.0,
x0: 0.0,
x1: 0.0,
ascent: m.0,
descent: m.1,
start: 0,
end: text_len,
glyph_start: 0,
glyph_end: 0,
});
return;
}
let s_start = start.clamp(0.0, 1.0) * ap.len;
let avail = if closed { ap.len } else { ap.len - s_start };
let w: f64 = sg.iter().map(|g| g.adv).sum();
let s0 = match para.justify {
Justify::Center | Justify::JustifyCenter => s_start + ((avail - w) * 0.5).max(0.0),
Justify::Right | Justify::JustifyRight => s_start + (avail - w).max(0.0),
_ => s_start,
};
let mut x = 0.0;
for g in &sg {
let s = s0 + x;
if s + g.adv - s_start > avail + 1e-6 {
cx.out.overflow = true;
break;
}
let mut mid = s + g.adv * 0.5;
if closed {
mid = mid.rem_euclid(ap.len);
}
let (p, dir) = ap.at(mid);
let angle = dir.y.atan2(dir.x);
let pre = Affine::translate(p.to_vec2()) * Affine::rotate(angle) * Affine::translate((-g.adv * 0.5, 0.0));
cx.emit(g, pre, p - dir * (g.adv * 0.5), angle, g.adv, 0);
x += g.adv;
}
let (ps, _) = ap.at(if closed { s0.rem_euclid(ap.len) } else { s0 });
let (pe, _) = ap.at(if closed { (s0 + x).rem_euclid(ap.len) } else { s0 + x });
cx.out.lines.push(LineInfo {
baseline: ps.y,
x0: ps.x,
x1: pe.x,
ascent: m.0,
descent: m.1,
start: 0,
end: text_len,
glyph_start: 0,
glyph_end: cx.out.glyphs.len(),
});
}
+149
View File
@@ -0,0 +1,149 @@
//! DrawCraft text: font database, shaping, layout, glyph outlines.
//!
//! API contract used by `drawcraft-render`, `drawcraft-tools` and the UI:
//! - [`FontDb::global`]: process-wide database preloaded with the bundled OFL fonts.
//! - [`layout`]: lays out a [`TextObject`] into glyph outlines in *text space* (apply `t.xf` to
//! get document coordinates), plus line and caret information.
//! - [`caret_position`] / [`hit_byte`]: caret geometry and hit testing for the Type tool.
//!
//! Frames (`TextKind::Area`) and paths (`TextKind::OnPath`) are interpreted in text space.
#![forbid(unsafe_code)]
mod fontdb;
mod layout;
mod shape;
pub use drawcraft_doc::TextObject;
pub use fontdb::{FALLBACK_FAMILY, FontDb, FontFace};
use kurbo::{BezPath, Point, Rect, Vec2};
pub use layout::layout;
/// One laid-out glyph.
#[derive(Clone, Debug)]
pub struct PositionedGlyph {
/// Outline in text space.
pub outline: BezPath,
/// Index of the run (`TextObject::runs`) the glyph came from.
pub run: usize,
/// Byte offset of the source character (cluster start) in the plain text.
pub byte: usize,
/// Pen position on the baseline (leading edge of the glyph).
pub origin: Point,
/// Advance along the baseline (includes tracking and justification space).
pub advance: f64,
/// Number of source bytes covered by the glyph's cluster.
pub len: usize,
/// Baseline direction in radians (0 = horizontal; non-zero for type on a path).
pub angle: f64,
/// Index into [`TextLayout::lines`].
pub line: usize,
/// [`FontFace::id`] of the face that supplied the glyph.
pub font_id: u32,
}
/// One line of laid-out text.
///
/// For type on a path there is a single line; `x0, baseline` is the start point of the text on the
/// path and `x1` the x of its end point.
#[derive(Clone, Debug)]
pub struct LineInfo {
pub baseline: f64,
/// Left edge of the line content (after alignment).
pub x0: f64,
/// Right edge of the line content, trailing spaces excluded.
pub x1: f64,
pub ascent: f64,
pub descent: f64,
/// Byte range of the line in the plain text (excludes the paragraph's `\n`).
pub start: usize,
pub end: usize,
/// Range of the line's glyphs in [`TextLayout::glyphs`].
pub glyph_start: usize,
pub glyph_end: usize,
}
#[derive(Clone, Debug, Default)]
pub struct TextLayout {
pub glyphs: Vec<PositionedGlyph>,
pub lines: Vec<LineInfo>,
/// Ink/advance bounds in text space.
pub bounds: Rect,
/// True if area text did not fit its frame (the red overflow "+" marker), or on-path text ran
/// past the end of the path.
pub overflow: bool,
/// True for type on a path (glyphs have individual `angle`s).
pub on_path: bool,
}
impl TextLayout {
/// All glyph outlines combined (e.g. for Create Outlines).
pub fn to_bezpath(&self) -> BezPath {
let mut p = BezPath::new();
for g in &self.glyphs {
p.extend(g.outline.iter());
}
p
}
/// Index of the line containing byte offset `byte`.
pub fn line_of(&self, byte: usize) -> usize {
self.lines.iter().rposition(|l| l.start <= byte).unwrap_or(0)
}
}
fn dir(angle: f64) -> Vec2 {
Vec2::new(angle.cos(), angle.sin())
}
/// Caret for byte offset `byte`, as a (top, bottom) segment in text space.
pub fn caret_position(layout: &TextLayout, byte: usize) -> (Point, Point) {
let Some(line) = layout.lines.get(layout.line_of(byte)) else {
return (Point::ZERO, Point::ZERO);
};
let glyphs = &layout.glyphs[line.glyph_start..line.glyph_end];
let (pos, angle) = if let Some(g) = glyphs.iter().find(|g| g.byte + g.len > byte) {
let frac = if byte <= g.byte { 0.0 } else { (byte - g.byte) as f64 / g.len.max(1) as f64 };
(g.origin + dir(g.angle) * (g.advance * frac), g.angle)
} else if let Some(g) = glyphs.last() {
(g.origin + dir(g.angle) * g.advance, g.angle)
} else {
(Point::new(line.x0, line.baseline), 0.0)
};
// Up vector in y-down space, rotated with the baseline.
let d = dir(angle);
let up = Vec2::new(d.y, -d.x);
(pos + up * line.ascent, pos - up * line.descent)
}
/// Byte offset nearest to the text-space point `p` (for clicking/dragging with the Type tool).
pub fn hit_byte(layout: &TextLayout, p: Point) -> usize {
if layout.on_path {
let best = layout.glyphs.iter().min_by(|a, b| {
let ca = a.origin + dir(a.angle) * (a.advance * 0.5);
let cb = b.origin + dir(b.angle) * (b.advance * 0.5);
(p - ca).hypot2().total_cmp(&(p - cb).hypot2())
});
return match best {
Some(g) if (p - g.origin).dot(dir(g.angle)) < g.advance * 0.5 => g.byte,
Some(g) => g.byte + g.len,
None => layout.lines.first().map_or(0, |l| l.start),
};
}
let Some(li) = layout.lines.iter().position(|l| p.y < l.baseline + l.descent).or(layout.lines.len().checked_sub(1)) else {
return 0;
};
let line = &layout.lines[li];
let glyphs = &layout.glyphs[line.glyph_start..line.glyph_end];
if let Some(g) = glyphs.iter().find(|g| p.x < g.origin.x + g.advance * 0.5) {
return g.byte;
}
// Past the end: a soft-wrapped line ends before its last (usually space) glyph so the caret
// stays on this line.
let soft_wrap = layout.lines.get(li + 1).is_some_and(|n| n.start == line.end);
match glyphs.last() {
Some(g) if soft_wrap => g.byte,
_ => line.end,
}
}
#[cfg(test)]
mod tests;
+188
View File
@@ -0,0 +1,188 @@
//! Shaping: text runs -> positioned glyphs in points (before line breaking).
use std::ops::Range;
use std::sync::Arc;
use drawcraft_doc::CharStyle;
use harfrust::{Direction, Feature, ShapeOptions, Tag, UnicodeBuffer};
use skrifa::MetadataProvider;
use skrifa::instance::{LocationRef, Size};
use crate::fontdb::{FontDb, FontFace};
/// A shaped glyph with all character-style effects resolved, in points (y down).
#[derive(Clone, Debug)]
pub(crate) struct SGlyph {
pub face: Arc<FontFace>,
pub gid: u32,
/// Byte offset of the cluster in the plain text, and its length in bytes.
pub byte: usize,
pub len: usize,
pub run: usize,
/// Advance in points (tracking, manual kerning and horizontal scale applied).
pub adv: f64,
/// Offset from the pen position, y down.
pub dx: f64,
pub dy: f64,
/// Outline scale (font units -> points).
pub sx: f64,
pub sy: f64,
/// Baseline shift in points (positive = up).
pub bshift: f64,
/// Character rotation in degrees (counter-clockwise).
pub rotation: f64,
pub ascent: f64,
pub descent: f64,
pub leading: f64,
/// First source character of the cluster.
pub ch: char,
}
impl SGlyph {
pub fn is_space(&self) -> bool {
matches!(self.ch, ' ' | '\t' | '\u{3000}' | '\u{2002}'..='\u{200B}')
}
/// A line may break after this glyph.
pub fn break_after(&self) -> bool {
self.is_space() || matches!(self.ch, '-' | '\u{2010}' | '\u{2013}' | '\u{2014}' | '/') || is_cjk(self.ch)
}
}
fn is_cjk(c: char) -> bool {
matches!(c as u32, 0x2E80..=0x9FFF | 0xAC00..=0xD7AF | 0xF900..=0xFAFF | 0xFF00..=0xFFEF | 0x20000..=0x2FFFF)
}
/// Vertical metrics (points) of a style's resolved face: (ascent, descent, leading).
pub(crate) fn style_metrics(db: &FontDb, st: &CharStyle) -> (f64, f64, f64) {
let face = db.face(&st.font_family, &st.font_style);
let k = st.size / face.upem;
let vs = st.v_scale / 100.0;
(face.ascent * k * vs, face.descent * k * vs, st.effective_leading())
}
/// Shape `text[range]`, where `runs` gives each run's byte range in `text` and style.
pub(crate) fn shape_range(db: &FontDb, text: &str, range: Range<usize>, runs: &[(Range<usize>, &CharStyle)], out: &mut Vec<SGlyph>) {
for (ri, (rr, st)) in runs.iter().enumerate() {
let a = rr.start.max(range.start);
let b = rr.end.min(range.end);
if a >= b {
continue;
}
let primary = db.face(&st.font_family, &st.font_style);
let pmap = primary.skrifa().map(|f| f.charmap());
// Split into segments by font coverage.
let mut seg_start = a;
let mut seg_face = primary.clone();
let mut cache: Vec<(char, Arc<FontFace>)> = Vec::new();
for (i, c) in text[a..b].char_indices() {
let i = a + i;
let covered = c.is_whitespace() || c.is_control() || pmap.as_ref().is_none_or(|m| m.map(c).is_some());
let face = if covered {
primary.clone()
} else if let Some((_, f)) = cache.iter().find(|(k, _)| *k == c) {
f.clone()
} else {
let f = db.fallback_for(c, primary.id()).unwrap_or_else(|| primary.clone());
cache.push((c, f.clone()));
f
};
// Combining marks stay with their base.
if face.id() != seg_face.id() && !is_mark(c) {
if i > seg_start {
shape_segment(text, seg_start..i, ri, st, &seg_face, out);
}
seg_start = i;
seg_face = face;
}
}
if b > seg_start {
shape_segment(text, seg_start..b, ri, st, &seg_face, out);
}
}
}
fn is_mark(c: char) -> bool {
matches!(c as u32, 0x0300..=0x036F | 0x1AB0..=0x1AFF | 0x1DC0..=0x1DFF | 0x20D0..=0x20FF | 0xFE20..=0xFE2F | 0x200D | 0xFE00..=0xFE0F)
}
fn shape_segment(text: &str, range: Range<usize>, run: usize, st: &CharStyle, face: &Arc<FontFace>, out: &mut Vec<SGlyph>) {
let seg = &text[range.clone()];
let size = st.size.max(0.0);
let k = size / face.upem;
let hs = st.h_scale / 100.0;
let vs = st.v_scale / 100.0;
let tracking = st.tracking / 1000.0 * size;
let manual_kern = st.kerning.map(|v| v / 1000.0 * size).unwrap_or(0.0);
let ascent = face.ascent * k * vs;
let descent = face.descent * k * vs;
let leading = st.effective_leading();
let first_char = |byte: usize| text[byte..].chars().next().unwrap_or(' ');
let mut raw: Vec<(u32, u32, i32, i32, i32)> = Vec::with_capacity(seg.len()); // gid, cluster, xadv, xoff, yoff
let shaped = face.hb().map(|hb| {
let shaper = face.shaper.shaper(&hb).build();
let mut buf = UnicodeBuffer::new();
for (i, c) in seg.char_indices() {
let cl = (range.start + i) as u32;
if st.all_caps {
for u in c.to_uppercase() {
buf.add(u, cl);
}
} else {
buf.add(c, cl);
}
}
buf.set_direction(Direction::LeftToRight);
buf.guess_segment_properties();
let feats: Vec<Feature> = if st.kerning.is_some() { vec![Feature::new(Tag::new(b"kern"), 0, ..)] } else { Vec::new() };
let gb = shaper.shape(buf, ShapeOptions::new().features(&feats));
for (info, pos) in gb.glyph_infos().iter().zip(gb.glyph_positions()) {
raw.push((info.glyph_id, info.cluster, pos.x_advance, pos.x_offset, pos.y_offset));
}
});
if shaped.is_none() {
// Fallback: nominal glyphs and hmtx advances, no shaping.
if let Some(f) = face.skrifa() {
let cmap = f.charmap();
let gm = f.glyph_metrics(Size::unscaled(), LocationRef::default());
for (i, c) in seg.char_indices() {
let cl = (range.start + i) as u32;
let chars: Vec<char> = if st.all_caps { c.to_uppercase().collect() } else { vec![c] };
for u in chars {
let g = cmap.map(u).unwrap_or_default();
let adv = gm.advance_width(g).unwrap_or(face.upem as f32 * 0.5);
raw.push((g.to_u32(), cl, adv.round() as i32, 0, 0));
}
}
}
}
let n = raw.len();
for (gi, &(gid, cl, xa, xo, yo)) in raw.iter().enumerate() {
let cl = cl as usize;
// Cluster end: the next larger cluster value in the segment, else the segment end.
let end = raw[gi + 1..].iter().map(|r| r.1 as usize).find(|&c| c > cl).unwrap_or(range.end);
let last_in_cluster = gi + 1 == n || raw[gi + 1].1 as usize != cl;
let mut adv = xa as f64 * k * hs;
if last_in_cluster {
adv += tracking + manual_kern;
}
out.push(SGlyph {
face: face.clone(),
gid,
byte: cl,
len: end.saturating_sub(cl).max(1),
run,
adv,
dx: xo as f64 * k * hs,
dy: -(yo as f64) * k * vs,
sx: k * hs,
sy: k * vs,
bshift: st.baseline_shift,
rotation: st.rotation,
ascent,
descent,
leading,
ch: first_char(cl),
});
}
}
+361
View File
@@ -0,0 +1,361 @@
use super::*;
use drawcraft_doc::{CharStyle, Justify, TextKind, TextRun};
use drawcraft_geom::PathData;
use kurbo::{Affine, Shape};
fn db() -> &'static FontDb {
FontDb::global()
}
fn style(size: f64) -> CharStyle {
CharStyle { size, ..CharStyle::default() }
}
fn serif(size: f64) -> CharStyle {
CharStyle { font_family: "Source Serif 4".into(), ..style(size) }
}
fn point(text: &str, st: CharStyle) -> TextObject {
TextObject::point(Point::ZERO, text, st)
}
fn area(text: &str, st: CharStyle, frame: Rect, justify: Justify) -> TextObject {
let mut t = point(text, st);
t.kind = TextKind::Area { frame: PathData::from_bezpath(&frame.to_path(0.1)) };
t.xf = Affine::IDENTITY;
t.para.justify = justify;
t
}
fn width(t: &TextObject) -> f64 {
let l = layout(db(), t);
l.glyphs.iter().map(|g| g.advance).sum()
}
const LOREM: &str = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.";
#[test]
fn bundled_families_and_styles() {
let f = db().families();
for fam in ["Source Sans 3", "Source Serif 4", "Inter", "JetBrains Mono"] {
assert!(f.iter().any(|x| x == fam), "{fam} missing from {f:?}");
}
let s = db().styles("Source Sans 3");
assert_eq!(s.first().map(String::as_str), Some("Regular"), "{s:?}");
assert!(s.iter().any(|x| x == "Bold") && s.iter().any(|x| x == "Italic") && s.iter().any(|x| x == "Semibold"), "{s:?}");
}
#[test]
fn style_lookup_and_fallback() {
let f = db().face("Inter", "Semibold");
assert_eq!((f.family.as_str(), f.style.as_str()), ("Inter", "SemiBold"));
let f = db().face("inter", "Bold");
assert_eq!(f.style, "SemiBold", "closest weight");
let f = db().face("No Such Font", "Regular");
assert_eq!((f.family.as_str(), f.style.as_str()), ("Source Sans 3", "Regular"));
let f = db().face("No Such Font", "Bold");
assert_eq!(f.style, "Bold");
assert_eq!(db().face("Source Sans 3", "Italic").style, "Italic");
}
#[test]
fn add_font_rejects_garbage_and_duplicates() {
assert_eq!(db().add_font(vec![1, 2, 3]), 0);
let bytes = include_bytes!("../../../assets/fonts/Inter-Regular.ttf").to_vec();
assert_eq!(db().add_font(bytes), 0, "already bundled");
}
#[test]
fn glyph_count_and_advances() {
let l = layout(db(), &point("Hello", style(24.0)));
assert_eq!(l.glyphs.len(), 5);
assert_eq!(l.lines.len(), 1);
for (i, g) in l.glyphs.iter().enumerate() {
assert!(g.advance > 0.0);
assert_eq!(g.byte, i);
assert!(!g.outline.elements().is_empty());
}
// Glyphs sit on the baseline (y = 0), ascenders above it (y < 0, y-down).
let h = l.glyphs[0].outline.bounding_box();
assert!(h.y0 < -10.0 && h.y1.abs() < 1.0, "{h:?}");
assert!(l.bounds.width() > 30.0);
}
#[test]
fn ligatures_and_kerning() {
// "fi" forms a ligature in Source Serif 4: one glyph covering two bytes.
let l = layout(db(), &point("fi", serif(20.0)));
assert_eq!(l.glyphs.len(), 1);
assert_eq!(l.glyphs[0].len, 2);
// Metrics kerning tightens "AV"; manual kerning 0 disables it.
let auto = width(&point("AV", style(100.0)));
let off = width(&point("AV", CharStyle { kerning: Some(0.0), ..style(100.0) }));
assert!(auto < off - 1.0, "auto {auto} off {off}");
}
#[test]
fn point_text_lines_and_auto_leading() {
let l = layout(db(), &point("one\ntwo\n", style(10.0)));
assert_eq!(l.lines.len(), 3);
assert_eq!(l.lines[0].baseline, 0.0);
assert!((l.lines[1].baseline - 12.0).abs() < 1e-9);
assert!((l.lines[2].baseline - 24.0).abs() < 1e-9);
assert_eq!((l.lines[0].start, l.lines[0].end), (0, 3));
assert_eq!((l.lines[1].start, l.lines[1].end), (4, 7));
assert_eq!((l.lines[2].start, l.lines[2].end), (8, 8));
}
#[test]
fn explicit_leading() {
let l = layout(db(), &point("a\nb", CharStyle { leading: Some(30.0), ..style(10.0) }));
assert!((l.lines[1].baseline - 30.0).abs() < 1e-9);
}
#[test]
fn point_alignment() {
let mut t = point("Centered", style(20.0));
let w = width(&t);
t.para.justify = Justify::Center;
let l = layout(db(), &t);
assert!((l.lines[0].x0 + w / 2.0).abs() < 1e-6 && (l.lines[0].x1 - w / 2.0).abs() < 1e-6);
t.para.justify = Justify::Right;
let l = layout(db(), &t);
assert!(l.lines[0].x1.abs() < 1e-6);
assert!(l.glyphs[0].origin.x < -w + 1e-6);
}
#[test]
fn tracking_and_scale() {
let base = width(&point("abcd", style(10.0)));
let tracked = width(&point("abcd", CharStyle { tracking: 100.0, ..style(10.0) }));
assert!((tracked - base - 4.0 * 1.0).abs() < 1e-6, "{base} {tracked}");
let wide = width(&point("abcd", CharStyle { h_scale: 200.0, ..style(10.0) }));
assert!((wide - 2.0 * base).abs() < 0.05);
let l = layout(db(), &point("x", CharStyle { baseline_shift: 5.0, ..style(10.0) }));
let b = l.glyphs[0].outline.bounding_box();
assert!(b.y1 < -4.0, "shifted up: {b:?}");
}
#[test]
fn all_caps() {
let a = width(&point("abc", CharStyle { all_caps: true, ..style(10.0) }));
let b = width(&point("ABC", style(10.0)));
assert!((a - b).abs() < 1e-9);
let l = layout(db(), &point("\u{df}", CharStyle { all_caps: true, ..style(10.0) }));
assert_eq!(l.glyphs.len(), 2, "ß -> SS");
}
#[test]
fn area_line_breaking_within_frame() {
let frame = Rect::new(10.0, 10.0, 110.0, 400.0);
let l = layout(db(), &area(LOREM, style(12.0), frame, Justify::Left));
assert!(l.lines.len() > 3);
assert!(!l.overflow);
for line in &l.lines {
assert!(line.x0 >= frame.x0 - 1e-6 && line.x1 <= frame.x1 + 1e-6, "{line:?}");
}
// Lines tile the text.
for w in l.lines.windows(2) {
assert_eq!(w[0].end, w[1].start);
}
assert_eq!(l.lines.last().unwrap().end, LOREM.len());
// First baseline = frame top + ascent; subsequent lines advance by the leading.
assert!((l.lines[0].baseline - (frame.y0 + l.lines[0].ascent)).abs() < 1e-6);
assert!((l.lines[1].baseline - l.lines[0].baseline - 14.4).abs() < 1e-6);
// Lines start at word boundaries.
for line in &l.lines[1..] {
assert_eq!(&LOREM[line.start - 1..line.start], " ");
}
}
#[test]
fn area_overflow() {
let small = layout(db(), &area(LOREM, style(12.0), Rect::new(0.0, 0.0, 100.0, 40.0), Justify::Left));
assert!(small.overflow);
assert!(!small.lines.is_empty() && small.lines.last().unwrap().end < LOREM.len());
let big = layout(db(), &area(LOREM, style(12.0), Rect::new(0.0, 0.0, 1000.0, 400.0), Justify::Left));
assert!(!big.overflow);
assert_eq!(big.glyphs.len(), LOREM.chars().count());
}
#[test]
fn area_alignment_and_justify() {
let frame = Rect::new(0.0, 0.0, 150.0, 400.0);
let l = layout(db(), &area(LOREM, style(12.0), frame, Justify::Right));
for line in &l.lines {
assert!((line.x1 - frame.x1).abs() < 1e-6);
}
let l = layout(db(), &area(LOREM, style(12.0), frame, Justify::JustifyLeft));
let n = l.lines.len();
for line in &l.lines[..n - 1] {
assert!((line.x0 - frame.x0).abs() < 1e-6 && (line.x1 - frame.x1).abs() < 1e-6, "{line:?}");
}
assert!(l.lines[n - 1].x1 < frame.x1 - 1.0, "last line not justified");
let l = layout(db(), &area("Wide", style(12.0), frame, Justify::JustifyAll));
assert!((l.lines[0].x1 - frame.x1).abs() < 1e-6);
}
#[test]
fn indents_and_paragraph_spacing() {
let frame = Rect::new(0.0, 0.0, 200.0, 400.0);
let mut t = area("para one\npara two", style(10.0), frame, Justify::Left);
t.para.left_indent = 10.0;
t.para.first_line_indent = 5.0;
t.para.space_before = 6.0;
let l = layout(db(), &t);
assert!((l.lines[0].x0 - 15.0).abs() < 1e-9);
assert!((l.lines[1].baseline - l.lines[0].baseline - 18.0).abs() < 1e-9);
}
#[test]
fn non_rect_frame_narrows_lines() {
// Triangle pointing up: lines get wider towards the bottom.
let tri = PathData::from_bezpath(&{
let mut p = BezPath::new();
p.move_to((100.0, 0.0));
p.line_to((200.0, 200.0));
p.line_to((0.0, 200.0));
p.close_path();
p
});
let mut t = point(&LOREM.repeat(2), style(10.0));
t.kind = TextKind::Area { frame: tri };
let l = layout(db(), &t);
assert!(l.lines.len() > 5);
let w0 = l.lines[0].x1 - l.lines[0].x0;
let wl = l.lines[l.lines.len() - 2].x1 - l.lines[l.lines.len() - 2].x0;
assert!(w0 < wl, "{w0} {wl}");
for line in &l.lines {
let y = line.baseline - line.ascent;
let half = y / 2.0; // half-width of the triangle at y
assert!(line.x0 >= 100.0 - half - 1e-3 && line.x1 <= 100.0 + half + 1e-3, "{line:?}");
}
}
#[test]
fn caret_mapping_round_trip() {
let l = layout(db(), &point("Hi there\nnext", style(20.0)));
let (top, bot) = caret_position(&l, 0);
assert!(top.x.abs() < 1e-9 && top.y < 0.0 && bot.y > 0.0);
let (end_top, _) = caret_position(&l, 8);
assert!((end_top.x - l.lines[0].x1).abs() < 1e-6);
for b in [0, 1, 3, 8, 9, 11, 13] {
let (t, bo) = caret_position(&l, b);
let mid = t.midpoint(bo);
assert_eq!(hit_byte(&l, mid + Vec2::new(0.1, 0.0)), b, "byte {b}");
}
// Second line.
let (t2, b2) = caret_position(&l, 10);
assert!(t2.y > 0.0 && b2.y > 24.0);
assert_eq!(hit_byte(&l, Point::new(1000.0, 24.0)), 13);
assert_eq!(hit_byte(&l, Point::new(-50.0, -100.0)), 0);
}
#[test]
fn caret_inside_ligature_and_empty_text() {
let l = layout(db(), &point("fi", serif(20.0)));
let (a, _) = caret_position(&l, 1);
let g = &l.glyphs[0];
assert!((a.x - g.advance / 2.0).abs() < 1e-6);
let e = layout(db(), &point("", style(20.0)));
assert_eq!(e.lines.len(), 1);
let (t, b) = caret_position(&e, 0);
assert!(t.y < 0.0 && b.y > 0.0);
}
#[test]
fn on_path_placement() {
let mut line = BezPath::new();
line.move_to((0.0, 50.0));
line.line_to((500.0, 50.0));
let mut t = point("Path", style(20.0));
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: 0.1 };
let l = layout(db(), &t);
assert!(l.on_path && !l.overflow);
assert_eq!(l.glyphs.len(), 4);
assert!((l.glyphs[0].origin.x - 50.0).abs() < 1e-6);
for g in &l.glyphs {
assert!((g.origin.y - 50.0).abs() < 1e-6 && g.angle.abs() < 1e-6);
}
// Vertical path downward: glyphs rotated 90°.
let mut v = BezPath::new();
v.move_to((0.0, 0.0));
v.line_to((0.0, 300.0));
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&v), start: 0.0 };
let l = layout(db(), &t);
for g in &l.glyphs {
assert!((g.angle - std::f64::consts::FRAC_PI_2).abs() < 1e-3);
assert!(g.origin.x.abs() < 1e-6);
}
let (top, bot) = caret_position(&l, 0);
assert!(top.x > 0.0 && bot.x < 0.0, "caret perpendicular to path: {top:?} {bot:?}");
let mid = l.glyphs[1].origin + Vec2::new(0.0, 0.2);
assert_eq!(hit_byte(&l, mid), 1);
}
#[test]
fn on_path_circle_and_overflow() {
let circle = kurbo::Circle::new((0.0, 0.0), 100.0).to_path(0.1);
let mut t = point("Around the circle", style(14.0));
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&circle), start: 0.0 };
let l = layout(db(), &t);
assert!(!l.overflow);
for g in &l.glyphs {
let r = g.origin.to_vec2().hypot();
assert!((r - 100.0).abs() < 2.0, "{r}");
}
let mut short = BezPath::new();
short.move_to((0.0, 0.0));
short.line_to((30.0, 0.0));
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&short), start: 0.0 };
let l = layout(db(), &t);
assert!(l.overflow && l.glyphs.len() < t.plain_text().len());
}
#[test]
fn fallback_font_per_character() {
let primary = db().face("Source Sans 3", "Regular");
// Find a character the primary lacks but another bundled font has.
let c = ['\u{2500}', '\u{2192}', '\u{25B6}', '\u{2588}', '\u{21E5}', '\u{2318}']
.into_iter()
.find(|&c| !primary.covers(c) && db().fallback_for(c, primary.id()).is_some())
.expect("some symbol only covered by a non-primary bundled font");
let text = format!("a{c}b");
let l = layout(db(), &point(&text, style(12.0)));
assert_eq!(l.glyphs.len(), 3);
assert_eq!(l.glyphs[0].font_id, primary.id());
assert_ne!(l.glyphs[1].font_id, primary.id());
assert!(!l.glyphs[1].outline.elements().is_empty());
assert_eq!(l.glyphs[2].font_id, primary.id());
}
#[test]
fn multiple_runs_and_styles() {
let mut t = point("", style(10.0));
t.runs = vec![
TextRun { text: "Big".into(), style: style(40.0) },
TextRun { text: "small".into(), style: CharStyle { font_family: "Inter".into(), ..style(10.0) } },
];
let l = layout(db(), &t);
assert_eq!(l.glyphs.len(), 8);
assert_eq!(l.glyphs[3].run, 1);
assert!(l.lines[0].ascent > 30.0);
assert_ne!(l.glyphs[0].font_id, l.glyphs[3].font_id);
}
#[test]
fn layout_is_fast() {
let text: String = LOREM.chars().cycle().take(1000).collect();
let t = area(&text, style(12.0), Rect::new(0.0, 0.0, 300.0, 2000.0), Justify::JustifyLeft);
let _ = layout(db(), &t); // warm caches
let n = 20;
let start = std::time::Instant::now();
for _ in 0..n {
let l = layout(db(), &t);
assert_eq!(l.glyphs.len(), 1000);
}
let per = start.elapsed().as_secs_f64() * 1000.0 / n as f64;
eprintln!("layout of 1000 chars: {per:.3} ms");
let budget = if cfg!(debug_assertions) { 100.0 } else { 5.0 };
assert!(per < budget, "{per} ms");
}
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "drawcraft-tools"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "DrawCraft tool state machines: pointer events in, commands and overlays out"
[lints]
workspace = true
[dependencies]
drawcraft-geom = { workspace = true }
drawcraft-color = { workspace = true }
drawcraft-doc = { workspace = true }
drawcraft-text = { workspace = true }
kurbo = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
+150
View File
@@ -0,0 +1,150 @@
//! Bounding-box handle geometry shared by the Selection tool and the UI.
use drawcraft_geom::{Affine, Point, Rect, Vec2};
/// The 8 handles, clockwise from top-left.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Handle {
TopLeft,
Top,
TopRight,
Right,
BottomRight,
Bottom,
BottomLeft,
Left,
}
impl Handle {
pub const ALL: [Handle; 8] =
[Handle::TopLeft, Handle::Top, Handle::TopRight, Handle::Right, Handle::BottomRight, Handle::Bottom, Handle::BottomLeft, Handle::Left];
pub fn pos(self, r: Rect) -> Point {
let c = r.center();
match self {
Handle::TopLeft => Point::new(r.x0, r.y0),
Handle::Top => Point::new(c.x, r.y0),
Handle::TopRight => Point::new(r.x1, r.y0),
Handle::Right => Point::new(r.x1, c.y),
Handle::BottomRight => Point::new(r.x1, r.y1),
Handle::Bottom => Point::new(c.x, r.y1),
Handle::BottomLeft => Point::new(r.x0, r.y1),
Handle::Left => Point::new(r.x0, c.y),
}
}
pub fn opposite(self) -> Handle {
Handle::ALL[(self as usize + 4) % 8]
}
pub fn is_corner(self) -> bool {
(self as usize).is_multiple_of(2)
}
/// Which axes this handle scales (x, y).
pub fn axes(self) -> (bool, bool) {
match self {
Handle::Top | Handle::Bottom => (false, true),
Handle::Left | Handle::Right => (true, false),
_ => (true, true),
}
}
}
/// Handle under `p` (within `tol` document units).
pub fn hit_handle(r: Rect, p: Point, tol: f64) -> Option<Handle> {
Handle::ALL.into_iter().find(|h| {
let q = h.pos(r);
(q.x - p.x).abs() <= tol && (q.y - p.y).abs() <= tol
})
}
/// Is `p` in the rotate zone just outside a corner (Illustrator shows the curved rotate cursor there)?
pub fn in_rotate_zone(r: Rect, p: Point, inner: f64, outer: f64) -> Option<Handle> {
[Handle::TopLeft, Handle::TopRight, Handle::BottomRight, Handle::BottomLeft].into_iter().find(|h| {
let q = h.pos(r);
let d = p.distance(q);
d > inner && d <= outer && !r.contains(p)
})
}
/// Scale transform for dragging `handle` of `r` to `p`.
/// `proportional` (Shift) keeps the aspect ratio; `from_center` (Alt) scales about the centre.
pub fn scale_for_drag(r: Rect, handle: Handle, p: Point, proportional: bool, from_center: bool) -> Affine {
let origin = if from_center { r.center() } else { handle.opposite().pos(r) };
let start = handle.pos(r);
let (ax, ay) = handle.axes();
let d0 = start - origin;
let d1 = p - origin;
let mut sx = if ax && d0.x.abs() > 1e-9 { d1.x / d0.x } else { 1.0 };
let mut sy = if ay && d0.y.abs() > 1e-9 { d1.y / d0.y } else { 1.0 };
if proportional {
if handle.is_corner() {
let s = if sx.abs() > sy.abs() { sx } else { sy };
sx = s.abs() * sx.signum();
sy = s.abs() * sy.signum();
} else if ax {
sy = sx.abs();
} else {
sx = sy.abs();
}
}
// Avoid degenerate zero scales (Illustrator never collapses an object completely).
let clamp = |s: f64| if s.abs() < 1e-4 { 1e-4 * if s < 0.0 { -1.0 } else { 1.0 } } else { s };
Affine::translate(origin.to_vec2()) * Affine::scale_non_uniform(clamp(sx), clamp(sy)) * Affine::translate(-origin.to_vec2())
}
/// Rotation about `center` taking `from` to `to`; `snap` constrains to 45° steps.
pub fn rotate_for_drag(center: Point, from: Point, to: Point, snap: bool) -> (Affine, f64) {
let a0 = (from - center).atan2();
let a1 = (to - center).atan2();
let mut a = a1 - a0;
if snap {
let step = std::f64::consts::FRAC_PI_4;
a = (a / step).round() * step;
}
(Affine::translate(center.to_vec2()) * Affine::rotate(a) * Affine::translate(-center.to_vec2()), -a.to_degrees())
}
/// Move constrained to 45° multiples when `shift`.
pub fn move_delta(from: Point, to: Point, shift: bool) -> Vec2 {
let d = to - from;
if shift { drawcraft_geom::constrain_angle(d, 45.0) } else { d }
}
#[cfg(test)]
mod tests {
use super::*;
const R: Rect = Rect::new(0.0, 0.0, 100.0, 50.0);
#[test]
fn handle_positions() {
assert_eq!(Handle::Right.pos(R), Point::new(100.0, 25.0));
assert_eq!(Handle::TopLeft.opposite(), Handle::BottomRight);
assert_eq!(Handle::Top.opposite(), Handle::Bottom);
assert_eq!(hit_handle(R, Point::new(99.0, 24.0), 3.0), Some(Handle::Right));
assert_eq!(hit_handle(R, Point::new(50.0, 25.0), 3.0), None);
}
#[test]
fn scale_corner_free_and_proportional() {
let a = scale_for_drag(R, Handle::BottomRight, Point::new(200.0, 50.0), false, false);
assert_eq!(a.transform_rect_bbox(R), Rect::new(0.0, 0.0, 200.0, 50.0));
let a = scale_for_drag(R, Handle::BottomRight, Point::new(200.0, 50.0), true, false);
assert_eq!(a.transform_rect_bbox(R), Rect::new(0.0, 0.0, 200.0, 100.0));
}
#[test]
fn scale_side_and_center() {
let a = scale_for_drag(R, Handle::Right, Point::new(150.0, 999.0), false, false);
assert_eq!(a.transform_rect_bbox(R), Rect::new(0.0, 0.0, 150.0, 50.0));
let a = scale_for_drag(R, Handle::Right, Point::new(150.0, 25.0), false, true);
assert_eq!(a.transform_rect_bbox(R), Rect::new(-50.0, 0.0, 150.0, 50.0));
}
#[test]
fn rotate_snaps() {
let (_, deg) = rotate_for_drag(Point::ZERO, Point::new(10.0, 0.0), Point::new(10.0, 9.0), true);
assert!((deg + 45.0).abs() < 1e-9);
assert!(in_rotate_zone(R, Point::new(-8.0, -8.0), 4.0, 20.0).is_some());
assert!(in_rotate_zone(R, Point::new(5.0, 5.0), 4.0, 20.0).is_none());
}
}
+179
View File
@@ -0,0 +1,179 @@
//! The tool catalogue: ids, labels, shortcuts and toolbar groups (Illustrator's Advanced toolbar order).
use serde::Serialize;
#[derive(Clone, Copy, Debug, Serialize)]
pub struct ToolInfo {
pub id: &'static str,
pub label: &'static str,
/// Single-key shortcut (Shift+key shown as "Shift+X").
pub shortcut: Option<&'static str>,
/// Icon name in the UI icon set.
pub icon: &'static str,
}
const fn t(id: &'static str, label: &'static str, shortcut: Option<&'static str>, icon: &'static str) -> ToolInfo {
ToolInfo { id, label, shortcut, icon }
}
/// Toolbar groups in order; the first tool of each group is the default visible one.
pub const TOOL_GROUPS: &[&[ToolInfo]] = &[
&[t("selection", "Selection Tool", Some("V"), "tool-selection")],
&[
t("directSelection", "Direct Selection Tool", Some("A"), "tool-direct"),
t("groupSelection", "Group Selection Tool", None, "tool-group-select"),
],
&[t("magicWand", "Magic Wand Tool", Some("Y"), "tool-magic-wand")],
&[t("lasso", "Lasso Tool", Some("Q"), "tool-lasso")],
&[
t("pen", "Pen Tool", Some("P"), "tool-pen"),
t("addAnchor", "Add Anchor Point Tool", Some("+"), "tool-pen-add"),
t("deleteAnchor", "Delete Anchor Point Tool", Some("-"), "tool-pen-delete"),
t("anchorPoint", "Anchor Point Tool", Some("Shift+C"), "tool-anchor"),
],
&[t("curvature", "Curvature Tool", Some("Shift+~"), "tool-curvature")],
&[
t("type", "Type Tool", Some("T"), "tool-type"),
t("areaType", "Area Type Tool", None, "tool-type-area"),
t("typeOnPath", "Type on a Path Tool", None, "tool-type-path"),
t("verticalType", "Vertical Type Tool", None, "tool-type-vertical"),
t("verticalAreaType", "Vertical Area Type Tool", None, "tool-type-vertical"),
t("verticalTypeOnPath", "Vertical Type on a Path Tool", None, "tool-type-path"),
t("touchType", "Touch Type Tool", Some("Shift+T"), "tool-touch-type"),
],
&[
t("lineSegment", "Line Segment Tool", Some("\\"), "tool-line"),
t("arc", "Arc Tool", None, "tool-arc"),
t("spiral", "Spiral Tool", None, "tool-spiral"),
t("rectangularGrid", "Rectangular Grid Tool", None, "tool-rect-grid"),
t("polarGrid", "Polar Grid Tool", None, "tool-polar-grid"),
],
&[
t("rectangle", "Rectangle Tool", Some("M"), "tool-rect"),
t("roundedRectangle", "Rounded Rectangle Tool", None, "tool-rounded-rect"),
t("ellipse", "Ellipse Tool", Some("L"), "tool-ellipse"),
t("polygon", "Polygon Tool", None, "tool-polygon"),
t("star", "Star Tool", None, "tool-star"),
t("flare", "Flare Tool", None, "tool-flare"),
],
&[t("paintbrush", "Paintbrush Tool", Some("B"), "tool-brush"), t("blobBrush", "Blob Brush Tool", Some("Shift+B"), "tool-blob-brush")],
&[
t("shaper", "Shaper Tool", Some("Shift+N"), "tool-shaper"),
t("pencil", "Pencil Tool", Some("N"), "tool-pencil"),
t("smooth", "Smooth Tool", None, "tool-smooth"),
t("pathEraser", "Path Eraser Tool", None, "tool-path-eraser"),
t("join", "Join Tool", None, "tool-join"),
],
&[
t("eraser", "Eraser Tool", Some("Shift+E"), "tool-eraser"),
t("scissors", "Scissors Tool", Some("C"), "tool-scissors"),
t("knife", "Knife", None, "tool-knife"),
],
&[t("rotate", "Rotate Tool", Some("R"), "tool-rotate"), t("reflect", "Reflect Tool", Some("O"), "tool-reflect")],
&[
t("scale", "Scale Tool", Some("S"), "tool-scale"),
t("shear", "Shear Tool", None, "tool-shear"),
t("reshape", "Reshape Tool", None, "tool-reshape"),
],
&[
t("width", "Width Tool", Some("Shift+W"), "tool-width"),
t("warp", "Warp Tool", Some("Shift+R"), "tool-warp"),
t("twirl", "Twirl Tool", None, "tool-twirl"),
t("pucker", "Pucker Tool", None, "tool-pucker"),
t("bloat", "Bloat Tool", None, "tool-bloat"),
t("scallop", "Scallop Tool", None, "tool-scallop"),
t("crystallize", "Crystallize Tool", None, "tool-crystallize"),
t("wrinkle", "Wrinkle Tool", None, "tool-wrinkle"),
],
&[t("freeTransform", "Free Transform Tool", Some("E"), "tool-free-transform"), t("puppetWarp", "Puppet Warp Tool", None, "tool-puppet")],
&[
t("shapeBuilder", "Shape Builder Tool", Some("Shift+M"), "tool-shape-builder"),
t("livePaintBucket", "Live Paint Bucket", Some("K"), "tool-bucket"),
t("livePaintSelection", "Live Paint Selection Tool", Some("Shift+L"), "tool-live-select"),
],
&[
t("perspectiveGrid", "Perspective Grid Tool", Some("Shift+P"), "tool-perspective"),
t("perspectiveSelection", "Perspective Selection Tool", Some("Shift+V"), "tool-perspective-select"),
],
&[t("mesh", "Mesh Tool", Some("U"), "tool-mesh")],
&[t("gradient", "Gradient Tool", Some("G"), "tool-gradient")],
&[t("eyedropper", "Eyedropper Tool", Some("I"), "tool-eyedropper"), t("measure", "Measure Tool", None, "tool-measure")],
&[t("blend", "Blend Tool", Some("W"), "tool-blend")],
&[
t("symbolSprayer", "Symbol Sprayer Tool", Some("Shift+S"), "tool-symbol"),
t("symbolShifter", "Symbol Shifter Tool", None, "tool-symbol"),
t("symbolScruncher", "Symbol Scruncher Tool", None, "tool-symbol"),
t("symbolSizer", "Symbol Sizer Tool", None, "tool-symbol"),
t("symbolSpinner", "Symbol Spinner Tool", None, "tool-symbol"),
t("symbolStainer", "Symbol Stainer Tool", None, "tool-symbol"),
t("symbolScreener", "Symbol Screener Tool", None, "tool-symbol"),
t("symbolStyler", "Symbol Styler Tool", None, "tool-symbol"),
],
&[
t("columnGraph", "Column Graph Tool", Some("J"), "tool-graph"),
t("stackedColumnGraph", "Stacked Column Graph Tool", None, "tool-graph"),
t("barGraph", "Bar Graph Tool", None, "tool-graph"),
t("stackedBarGraph", "Stacked Bar Graph Tool", None, "tool-graph"),
t("lineGraph", "Line Graph Tool", None, "tool-graph"),
t("areaGraph", "Area Graph Tool", None, "tool-graph"),
t("scatterGraph", "Scatter Graph Tool", None, "tool-graph"),
t("pieGraph", "Pie Graph Tool", None, "tool-graph"),
t("radarGraph", "Radar Graph Tool", None, "tool-graph"),
],
&[
t("artboard", "Artboard Tool", Some("Shift+O"), "tool-artboard"),
t("slice", "Slice Tool", Some("Shift+K"), "tool-slice"),
t("sliceSelection", "Slice Selection Tool", None, "tool-slice"),
],
&[
t("hand", "Hand Tool", Some("H"), "tool-hand"),
t("rotateView", "Rotate View Tool", Some("Shift+H"), "tool-rotate-view"),
t("printTiling", "Print Tiling Tool", None, "tool-print-tiling"),
],
&[t("zoom", "Zoom Tool", Some("Z"), "tool-zoom")],
];
pub fn all_tools() -> impl Iterator<Item = &'static ToolInfo> {
TOOL_GROUPS.iter().flat_map(|g| g.iter())
}
pub fn tool_info(id: &str) -> Option<&'static ToolInfo> {
all_tools().find(|t| t.id == id)
}
/// Tool for a single-key shortcut such as "V" or "Shift+M".
pub fn tool_for_shortcut(s: &str) -> Option<&'static ToolInfo> {
all_tools().find(|t| t.shortcut == Some(s))
}
/// Index of the group containing `id`.
pub fn group_of(id: &str) -> Option<usize> {
TOOL_GROUPS.iter().position(|g| g.iter().any(|t| t.id == id))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unique_ids_and_shortcuts() {
let mut ids: Vec<&str> = all_tools().map(|t| t.id).collect();
let n = ids.len();
ids.sort();
ids.dedup();
assert_eq!(ids.len(), n, "duplicate tool ids");
let mut sc: Vec<&str> = all_tools().filter_map(|t| t.shortcut).collect();
let n = sc.len();
sc.sort();
sc.dedup();
assert_eq!(sc.len(), n, "duplicate shortcuts");
assert!(n > 40);
}
#[test]
fn lookups() {
assert_eq!(tool_for_shortcut("V").unwrap().id, "selection");
assert_eq!(tool_for_shortcut("Shift+M").unwrap().id, "shapeBuilder");
assert_eq!(group_of("star"), group_of("rectangle"));
}
}
+259
View File
@@ -0,0 +1,259 @@
//! Direct Selection (A) and Group Selection tools.
//!
//! Direct Selection: click an anchor to select it (Shift toggles), click a segment to select the
//! path's anchors on that segment, drag to move selected anchors, drag a direction handle to
//! reshape, marquee to select anchors. Group Selection: click selects the leaf; each further click
//! on it adds the next enclosing group.
use drawcraft_doc::hit::hit_test;
use drawcraft_doc::{AnchorRef, NodeId, NodeKind};
use drawcraft_geom::{Point, Rect};
use serde_json::{Value, json};
use crate::bbox::move_delta;
use crate::select::matrix_json;
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext};
#[derive(Clone, Debug)]
enum State {
Idle,
MoveAnchors { start: Point, began: bool },
MoveObject { start: Point, began: bool },
Handle { id: NodeId, si: usize, ai: usize, out: bool },
Marquee { start: Point, cur: Point, add: bool },
}
pub struct DirectSelectionTool {
group: bool,
state: State,
}
impl DirectSelectionTool {
pub fn new(group: bool) -> Self {
Self { group, state: State::Idle }
}
}
/// Anchor (or handle) of any selected/visible path under `p`.
fn hit_anchor(cx: &ToolContext, p: Point, tol: f64, selected_only: bool) -> Option<(NodeId, usize, usize)> {
let ids: Vec<NodeId> = if selected_only {
cx.selection.objects.clone()
} else {
let mut v = vec![];
cx.doc.walk(|n| {
if matches!(n.kind, NodeKind::Path { .. }) {
v.push(n.id)
}
});
v.reverse();
v
};
for id in ids {
if !cx.doc.is_editable(id) {
continue;
}
if let Some(pd) = cx.doc.node(id).and_then(|n| n.path_data()) {
for (si, ai, a) in pd.anchors() {
if a.p.distance(p) <= tol {
return Some((id, si, ai));
}
}
}
}
None
}
/// Direction handle of a partially selected anchor under `p`: (id, si, ai, is_out).
fn hit_handle(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeId, usize, usize, bool)> {
for (id, set) in &cx.selection.anchors {
let Some(pd) = cx.doc.node(*id).and_then(|n| n.path_data()) else { continue };
for &(si, ai) in set {
let Some(a) = pd.subpaths.get(si).and_then(|s| s.anchors.get(ai)) else { continue };
if a.has_out() && a.h_out.distance(p) <= tol {
return Some((*id, si, ai, true));
}
if a.has_in() && a.h_in.distance(p) <= tol {
return Some((*id, si, ai, false));
}
}
}
None
}
fn anchors_json(v: &[AnchorRef]) -> Value {
Value::Array(v.iter().map(|(s, a)| json!([s, a])).collect())
}
impl Tool for DirectSelectionTool {
fn id(&self) -> &'static str {
if self.group { "groupSelection" } else { "directSelection" }
}
fn busy(&self) -> bool {
!matches!(self.state, State::Idle)
}
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
let p = ev.pos;
let tol = cx.tol(4.0);
match (ev.kind, self.state.clone()) {
(PointerKind::Down, _) if self.group => {
let Some(h) = hit_test(cx.doc, p, cx.hit_options()) else {
self.state = State::Marquee { start: p, cur: p, add: ev.mods.shift };
return vec![];
};
// Walk up from the leaf: select the first ancestor not yet selected (below the layer).
let chain: Vec<NodeId> = h.ancestry.iter().skip(1).rev().copied().collect();
let target = if cx.selection.contains(h.leaf) {
chain.iter().find(|id| !cx.selection.contains(**id)).copied().unwrap_or(h.leaf)
} else {
h.leaf
};
self.state = State::MoveObject { start: p, began: false };
if cx.selection.contains(target) {
vec![]
} else if ev.mods.shift || cx.selection.contains(h.leaf) {
vec![Action::Exec("select.add".into(), json!({"ids": [target.0]}))]
} else {
vec![Action::Exec("select.set".into(), json!({"ids": [target.0]}))]
}
}
(PointerKind::Down, _) => {
if let Some((id, si, ai, out)) = hit_handle(cx, p, tol) {
self.state = State::Handle { id, si, ai, out };
return vec![Action::Begin("Reshape".into())];
}
if let Some((id, si, ai)) = hit_anchor(cx, p, tol, false) {
let already = cx.selection.partial(id).is_some_and(|s| s.contains(&(si, ai)));
self.state = State::MoveAnchors { start: p, began: false };
if ev.mods.shift {
return vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[si, ai]], "mode": "toggle"}))];
}
if !already {
return vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[si, ai]], "mode": "set"}))];
}
return vec![];
}
if let Some(h) = hit_test(cx.doc, p, cx.hit_options()) {
// Clicking a segment/fill selects the whole leaf path (all anchors).
self.state = State::MoveObject { start: p, began: false };
if ev.mods.shift {
return vec![Action::Exec("select.toggle".into(), json!({"id": h.leaf.0}))];
}
if !cx.selection.contains(h.leaf) || cx.selection.partial(h.leaf).is_some() {
return vec![Action::Exec("select.set".into(), json!({"ids": [h.leaf.0]}))];
}
return vec![];
}
self.state = State::Marquee { start: p, cur: p, add: ev.mods.shift };
vec![]
}
(PointerKind::Drag, State::MoveAnchors { start, began }) => {
let mut out = vec![];
if !began {
if p.distance(start) < cx.tol(3.0) {
return out;
}
out.push(Action::Begin("Move".into()));
self.state = State::MoveAnchors { start, began: true };
}
let d = move_delta(start, p, ev.mods.shift);
out.push(Action::Preview("path.moveAnchors".into(), json!({"dx": d.x, "dy": d.y})));
out
}
(PointerKind::Drag, State::MoveObject { start, began }) => {
let mut out = vec![];
if !began {
if p.distance(start) < cx.tol(3.0) {
return out;
}
out.push(Action::Begin("Move".into()));
self.state = State::MoveObject { start, began: true };
}
let d = move_delta(start, p, ev.mods.shift);
out.push(Action::Preview(
"object.transform".into(),
json!({"matrix": matrix_json(drawcraft_geom::Affine::translate(d)), "copy": ev.mods.alt}),
));
out
}
(PointerKind::Drag, State::Handle { id, si, ai, out }) => {
vec![Action::Preview(
"path.setHandle".into(),
json!({"id": id.0, "subpath": si, "anchor": ai, "which": if out {"out"} else {"in"}, "x": p.x, "y": p.y, "independent": ev.mods.alt}),
)]
}
(PointerKind::Drag, State::Marquee { start, add, .. }) => {
self.state = State::Marquee { start, cur: p, add };
vec![]
}
(PointerKind::Up, State::MoveAnchors { began, .. } | State::MoveObject { began, .. }) => {
self.state = State::Idle;
if began { vec![Action::Commit] } else { vec![] }
}
(PointerKind::Up, State::Handle { .. }) => {
self.state = State::Idle;
vec![Action::Commit]
}
(PointerKind::Up, State::Marquee { start, add, .. }) => {
self.state = State::Idle;
let r = Rect::from_points(start, p);
if r.width() < cx.tol(3.0) && r.height() < cx.tol(3.0) {
return if add { vec![] } else { vec![Action::Exec("select.none".into(), json!({}))] };
}
// Collect anchors inside the rect for every editable path.
let mut sel: Vec<(NodeId, Vec<AnchorRef>)> = vec![];
cx.doc.walk(|n| {
if let NodeKind::Path { path, .. } = &n.kind {
let v: Vec<AnchorRef> = path.anchors().filter(|(_, _, a)| r.contains(a.p)).map(|(s, i, _)| (s, i)).collect();
if !v.is_empty() {
sel.push((n.id, v));
}
}
});
sel.retain(|(id, _)| cx.doc.is_editable(*id));
let items: Vec<Value> = sel.iter().map(|(id, v)| json!({"id": id.0, "anchors": anchors_json(v)})).collect();
vec![Action::Exec("select.anchorsMany".into(), json!({"items": items, "add": add}))]
}
_ => vec![],
}
}
fn overlays(&self, _cx: &ToolContext) -> Vec<Overlay> {
match self.state {
State::Marquee { start, cur, .. } => vec![Overlay::Marquee(Rect::from_points(start, cur))],
_ => vec![],
}
}
fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor {
Cursor::ArrowHollow
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testutil::*;
use drawcraft_doc::Selection;
#[test]
fn click_anchor_selects_it() {
let (d, id) = doc_with_rect();
let s = Selection::default();
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = DirectSelectionTool::new(false);
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0));
assert_eq!(a, vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[0, 0]], "mode": "set"}))]);
}
#[test]
fn marquee_selects_anchors() {
let (d, id) = doc_with_rect();
let s = Selection::default();
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = DirectSelectionTool::new(false);
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 50.0));
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 250.0, 150.0));
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 250.0, 150.0));
assert_eq!(a, vec![Action::Exec("select.anchorsMany".into(), json!({"items": [{"id": id.0, "anchors": [[0, 1]]}], "add": false}))]);
}
}
+282
View File
@@ -0,0 +1,282 @@
//! DrawCraft tools: pointer events in → commands and overlays out.
//!
//! Tools never mutate the document directly. They emit [`Action`]s that the engine executes:
//! `Begin` snapshots the document, each `Preview` re-applies one command on top of that snapshot
//! (replacing the previous preview), and `Commit` records the last preview as a single undo step
//! and journal entry. One-shot `Exec` actions run immediately. This makes every gesture replayable
//! by the control channel and MCP, and keeps tools testable without a UI.
#![forbid(unsafe_code)]
pub mod bbox;
pub mod catalog;
pub mod direct;
pub mod pen;
pub mod select;
pub mod shape;
use drawcraft_color::Paint;
use drawcraft_doc::{Document, NodeId, Selection};
use drawcraft_geom::{BezPath, Point, Rect};
use serde::{Deserialize, Serialize};
use serde_json::Value;
pub use catalog::{TOOL_GROUPS, ToolInfo, tool_info};
/// Modifier keys.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Mods {
#[serde(default)]
pub shift: bool,
/// Option on macOS.
#[serde(default)]
pub alt: bool,
/// Command on macOS, Ctrl elsewhere.
#[serde(default)]
pub cmd: bool,
#[serde(default)]
pub ctrl: bool,
#[serde(default)]
pub space: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum PointerKind {
/// Button pressed.
Down,
/// Moved with the button held.
Drag,
/// Button released.
Up,
/// Moved with no button (hover).
Move,
DoubleClick,
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct PointerEvent {
pub kind: PointerKind,
/// Document coordinates.
pub pos: Point,
#[serde(default)]
pub mods: Mods,
#[serde(default = "one")]
pub pressure: f32,
}
fn one() -> f32 {
1.0
}
impl PointerEvent {
pub fn new(kind: PointerKind, x: f64, y: f64) -> Self {
Self { kind, pos: Point::new(x, y), mods: Mods::default(), pressure: 1.0 }
}
pub fn with_mods(mut self, m: Mods) -> Self {
self.mods = m;
self
}
}
/// Keys tools care about.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum ToolKey {
Enter,
Escape,
Backspace,
Delete,
Up,
Down,
Left,
Right,
/// Illustrator: ↑/↓ while drawing a polygon/star change sides/points; `[`/`]` change sizes.
BracketLeft,
BracketRight,
Tab,
}
/// What a tool asks the engine to do.
#[derive(Clone, Debug, PartialEq)]
pub enum Action {
/// Start an interaction (snapshot the document). The label becomes the undo name.
Begin(String),
/// Replace the current preview with this command, applied to the snapshot.
Preview(String, Value),
/// Finish the interaction: keep the last preview as one undo step.
Commit,
/// Abort the interaction: restore the snapshot.
Cancel,
/// Execute a command immediately (its own undo step, if it edits).
Exec(String, Value),
/// Ask the UI to open a dialog (e.g. click with the Rectangle tool → size dialog).
Dialog(String, Value),
/// Switch to another tool (e.g. after placing a text frame).
SwitchTool(String),
}
/// Paint defaults for new art (the fill/stroke proxy).
#[derive(Clone, Debug, PartialEq)]
pub struct PaintDefaults {
pub fill: Paint,
pub stroke: Paint,
pub stroke_width: f64,
}
/// Read-only context a tool sees.
pub struct ToolContext<'a> {
pub doc: &'a Document,
pub selection: &'a Selection,
/// Screen pixels per document point.
pub zoom: f64,
pub isolation: Option<NodeId>,
pub paint: &'a PaintDefaults,
pub outline: bool,
pub smart_guides: bool,
pub snap_to_grid: bool,
/// Bounding box shown (View → Show/Hide Bounding Box).
pub show_bbox: bool,
}
impl ToolContext<'_> {
/// Tolerance in document units for `px` screen pixels.
pub fn tol(&self, px: f64) -> f64 {
px / self.zoom.max(1e-9)
}
pub fn hit_options(&self) -> drawcraft_doc::hit::HitOptions {
drawcraft_doc::hit::HitOptions { tol: self.tol(3.0), outline: self.outline, path_only: false }
}
}
/// Visual feedback drawn by the UI in screen space (coordinates here are document space).
#[derive(Clone, Debug, PartialEq)]
pub enum Overlay {
/// Marquee / rubber band rectangle.
Marquee(Rect),
/// A path preview (e.g. pen rubber band) in a colour (RGB).
Path { path: BezPath, color: [u8; 3], width: f32, dashed: bool },
/// A line segment (smart guide, handle line).
Line { a: Point, b: Point, color: [u8; 3], dashed: bool },
/// An anchor square: filled = selected.
Anchor { p: Point, color: [u8; 3], filled: bool, size: f32 },
/// A handle end circle.
Handle { p: Point, color: [u8; 3] },
/// Smart-guide style label (e.g. "anchor", "W: 100 pt").
Label { p: Point, text: String, color: [u8; 3] },
/// Measurement pill near the cursor (grey box with white text).
Measure { p: Point, text: String },
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum Cursor {
#[default]
Arrow,
ArrowHollow,
Move,
Crosshair,
ResizeH,
ResizeV,
ResizeNwSe,
ResizeNeSw,
Rotate,
Pen,
PenAdd,
PenDelete,
PenClose,
PenContinue,
Text,
Hand,
HandGrab,
ZoomIn,
ZoomOut,
Eyedropper,
NotAllowed,
}
/// A tool state machine.
pub trait Tool: Send {
fn id(&self) -> &'static str;
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action>;
fn key(&mut self, _cx: &ToolContext, _key: ToolKey, _mods: Mods) -> Vec<Action> {
vec![]
}
fn overlays(&self, _cx: &ToolContext) -> Vec<Overlay> {
vec![]
}
fn cursor(&self, _cx: &ToolContext, _p: Point, _mods: Mods) -> Cursor {
Cursor::Arrow
}
/// Tool options as JSON (shown by the Control bar / tool options dialog).
fn options(&self) -> Value {
Value::Null
}
fn set_option(&mut self, _key: &str, _value: &Value) {}
/// Is an interaction in progress (drag, open pen path)?
fn busy(&self) -> bool {
false
}
/// Called when the user switches away (finish pending work).
fn deactivate(&mut self, _cx: &ToolContext) -> Vec<Action> {
vec![]
}
}
/// Create a tool by id. Unknown or not-yet-implemented tools fall back to a no-op tool that keeps the id.
pub fn create(id: &str) -> Box<dyn Tool> {
match id {
"selection" => Box::new(select::SelectionTool::default()),
"directSelection" => Box::new(direct::DirectSelectionTool::new(false)),
"groupSelection" => Box::new(direct::DirectSelectionTool::new(true)),
"pen" => Box::new(pen::PenTool::default()),
"rectangle" | "roundedRectangle" | "ellipse" | "polygon" | "star" | "lineSegment" => Box::new(shape::ShapeTool::new(id)),
other => Box::new(NoopTool(tool_info(other).map(|t| t.id).unwrap_or("selection"))),
}
}
/// Placeholder for tools whose behaviour hasn't landed yet (selecting them still works).
pub struct NoopTool(&'static str);
impl Tool for NoopTool {
fn id(&self) -> &'static str {
self.0
}
fn pointer(&mut self, _cx: &ToolContext, _ev: &PointerEvent) -> Vec<Action> {
vec![]
}
}
pub(crate) fn json_ids(ids: &[NodeId]) -> Value {
Value::Array(ids.iter().map(|i| Value::from(i.0)).collect())
}
#[cfg(test)]
pub(crate) mod testutil {
use super::*;
use drawcraft_doc::{Appearance, Node};
use drawcraft_geom::shapes;
pub fn doc_with_rect() -> (Document, NodeId) {
let mut d = Document::new(500.0, 500.0);
let l = d.layers[0].id;
let id = d.alloc_id();
d.insert(Some(l), 0, Node::path(id, shapes::rectangle(Rect::new(100.0, 100.0, 200.0, 200.0)), Appearance::default_art())).unwrap();
(d, id)
}
pub fn paint() -> PaintDefaults {
PaintDefaults { fill: Paint::solid(drawcraft_color::Color::WHITE), stroke: Paint::solid(drawcraft_color::Color::BLACK), stroke_width: 1.0 }
}
pub fn cx<'a>(d: &'a Document, s: &'a Selection, p: &'a PaintDefaults) -> ToolContext<'a> {
ToolContext {
doc: d,
selection: s,
zoom: 1.0,
isolation: None,
paint: p,
outline: false,
smart_guides: true,
snap_to_grid: false,
show_bbox: true,
}
}
}
+181
View File
@@ -0,0 +1,181 @@
//! The Pen tool (P).
//!
//! Click adds a corner anchor; click-drag adds a smooth anchor with symmetric handles (Alt-drag
//! breaks the handles); Shift constrains to 45°. Clicking the first anchor closes the path.
//! Enter/Esc (or switching tools) ends the path. Clicking the end of a selected open path continues
//! it. The rubber-band preview shows the next segment.
use drawcraft_doc::{NodeId, NodeKind};
use drawcraft_geom::{BezPath, Point};
use serde_json::json;
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
#[derive(Default)]
pub struct PenTool {
/// The path being drawn (set once the first anchor exists and the engine selected it).
drawing: bool,
drag: Option<(Point, bool)>,
hover: Option<Point>,
}
/// The open path the pen is extending: the single selected open path.
fn active_path(cx: &ToolContext) -> Option<(NodeId, Point, Point, Point)> {
if cx.selection.objects.len() != 1 {
return None;
}
let id = cx.selection.objects[0];
let n = cx.doc.node(id)?;
let NodeKind::Path { path, .. } = &n.kind else { return None };
let sp = path.subpaths.last()?;
if sp.closed {
return None;
}
let first = sp.anchors.first()?.p;
let last = sp.anchors.last()?;
Some((id, first, last.p, last.h_out))
}
impl Tool for PenTool {
fn id(&self) -> &'static str {
"pen"
}
fn busy(&self) -> bool {
self.drag.is_some()
}
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
let mut p = ev.pos;
let tol = cx.tol(5.0);
let active = if self.drawing { active_path(cx) } else { None };
if self.drawing && active.is_none() && ev.kind == PointerKind::Down {
self.drawing = false;
}
match ev.kind {
PointerKind::Move => {
self.hover = Some(p);
vec![]
}
PointerKind::Down => {
if let Some((id, first, last, _)) = active {
if ev.mods.shift {
p = last + drawcraft_geom::constrain_angle(p - last, 45.0);
}
if p.distance(first) <= tol {
self.drag = Some((first, true));
return vec![Action::Begin("Close Path".into()), Action::Preview("path.close".into(), json!({"id": id.0}))];
}
self.drag = Some((p, false));
return vec![Action::Begin("Pen".into()), Action::Preview("path.appendAnchor".into(), json!({"id": id.0, "x": p.x, "y": p.y}))];
}
// Continue a selected open path when clicking on one of its ends.
if let Some((_, first, last, _)) = active_path(cx)
&& (p.distance(last) <= tol || p.distance(first) <= tol)
{
self.drawing = true;
if p.distance(first) <= tol && p.distance(last) > tol {
return vec![Action::Exec("path.reverse".into(), json!({}))];
}
return vec![];
}
self.drawing = true;
self.drag = Some((p, false));
vec![Action::Begin("Pen".into()), Action::Preview("path.create".into(), json!({"anchors": [{"x": p.x, "y": p.y}]}))]
}
PointerKind::Drag => {
let Some((a, closing)) = self.drag else { return vec![] };
let mut out_h = ev.pos;
if ev.mods.shift {
out_h = a + drawcraft_geom::constrain_angle(ev.pos - a, 45.0);
}
let alt = ev.mods.alt;
let Some((id, ..)) = active_path(cx).or(active) else {
// First anchor of a new path: re-issue create with handles.
let in_h = a - (out_h - a);
return vec![Action::Preview(
"path.create".into(),
json!({"anchors": [{"x": a.x, "y": a.y, "out": [out_h.x, out_h.y], "in": [in_h.x, in_h.y]}]}),
)];
};
if closing {
return vec![Action::Preview(
"path.close".into(),
json!({"id": id.0, "in": [2.0 * a.x - out_h.x, 2.0 * a.y - out_h.y], "independent": alt}),
)];
}
let in_h = if alt { a } else { a - (out_h - a) };
vec![Action::Preview(
"path.appendAnchor".into(),
json!({"id": id.0, "x": a.x, "y": a.y, "in": [in_h.x, in_h.y], "out": [out_h.x, out_h.y]}),
)]
}
PointerKind::Up => {
let Some((_, closing)) = self.drag.take() else { return vec![] };
if closing {
self.drawing = false;
}
vec![Action::Commit]
}
PointerKind::DoubleClick => vec![],
}
}
fn key(&mut self, _cx: &ToolContext, key: ToolKey, _m: Mods) -> Vec<Action> {
match key {
ToolKey::Enter | ToolKey::Escape => {
self.drawing = false;
self.drag = None;
vec![]
}
_ => vec![],
}
}
fn deactivate(&mut self, _cx: &ToolContext) -> Vec<Action> {
self.drawing = false;
vec![]
}
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
if !self.drawing || self.drag.is_some() {
return vec![];
}
let (Some((id, _, last, out)), Some(h)) = (active_path(cx), self.hover) else { return vec![] };
let mut bp = BezPath::new();
bp.move_to(last);
if out.distance(last) > 1e-9 {
bp.quad_to(out, h);
} else {
bp.line_to(h);
}
let c = cx.doc.layer_color(id);
vec![Overlay::Path { path: bp, color: c, width: 1.0, dashed: false }]
}
fn cursor(&self, cx: &ToolContext, p: Point, _m: Mods) -> Cursor {
if let Some((_, first, last, _)) = active_path(cx) {
if self.drawing && p.distance(first) <= cx.tol(5.0) {
return Cursor::PenClose;
}
if !self.drawing && (p.distance(last) <= cx.tol(5.0) || p.distance(first) <= cx.tol(5.0)) {
return Cursor::PenContinue;
}
}
Cursor::Pen
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testutil::*;
use drawcraft_doc::Selection;
#[test]
fn first_click_creates_path() {
let (d, _) = doc_with_rect();
let s = Selection::default();
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = PenTool::default();
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0));
assert_eq!(a[0], Action::Begin("Pen".into()));
assert!(matches!(&a[1], Action::Preview(c, _) if c == "path.create"));
assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 10.0, 10.0)), vec![Action::Commit]);
}
}
+307
View File
@@ -0,0 +1,307 @@
//! The Selection tool (V): click/shift-click, marquee, move (Alt copies, Shift constrains),
//! bounding-box scale (Shift proportional, Alt from centre) and rotate (outside corners, Shift 45°),
//! double-click to enter isolation mode.
use drawcraft_doc::NodeId;
use drawcraft_doc::hit::{hit_test, marquee};
use drawcraft_geom::{Affine, Point, Rect};
use serde_json::{Value, json};
use crate::bbox::{Handle, hit_handle, in_rotate_zone, move_delta, rotate_for_drag, scale_for_drag};
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, json_ids};
#[derive(Clone, Debug, Default)]
enum State {
#[default]
Idle,
/// Pressed on an object; becomes Moving after the drag threshold.
Moving {
start: Point,
began: bool,
},
Scaling {
handle: Handle,
rect: Rect,
},
Rotating {
center: Point,
start: Point,
},
Marquee {
start: Point,
cur: Point,
add: bool,
},
}
#[derive(Default)]
pub struct SelectionTool {
state: State,
measure: Option<(Point, String)>,
}
pub fn matrix_json(a: Affine) -> Value {
let c = a.as_coeffs();
json!([c[0], c[1], c[2], c[3], c[4], c[5]])
}
/// Selection bounds used for the bounding box (geometric bounds, like Illustrator's default
/// "Use Preview Bounds" off).
pub fn selection_bounds(cx: &ToolContext) -> Option<Rect> {
cx.doc.bounds_of(&cx.selection.objects, false)
}
impl SelectionTool {
fn drag_threshold(cx: &ToolContext) -> f64 {
cx.tol(3.0)
}
}
impl Tool for SelectionTool {
fn id(&self) -> &'static str {
"selection"
}
fn busy(&self) -> bool {
!matches!(self.state, State::Idle)
}
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
let p = ev.pos;
let m = ev.mods;
match (ev.kind, self.state.clone()) {
(PointerKind::DoubleClick, _) => {
self.state = State::Idle;
if let Some(h) = hit_test(cx.doc, p, cx.hit_options()) {
let top = h.top_object(cx.isolation);
if cx.doc.node(top).is_some_and(|n| matches!(n.kind, drawcraft_doc::NodeKind::Group { .. })) {
return vec![Action::Exec("object.isolate".into(), json!({ "id": top.0 }))];
}
if cx.doc.node(top).is_some_and(|n| matches!(n.kind, drawcraft_doc::NodeKind::Text(_))) {
return vec![Action::SwitchTool("type".into())];
}
} else if cx.isolation.is_some() {
return vec![Action::Exec("object.exitIsolation".into(), json!({}))];
}
vec![]
}
(PointerKind::Down, _) => {
// 1. Bounding-box handles of the current selection.
if cx.show_bbox
&& let Some(r) = selection_bounds(cx)
{
let tol = cx.tol(5.0);
if let Some(h) = hit_handle(r, p, tol) {
self.state = State::Scaling { handle: h, rect: r };
return vec![Action::Begin("Scale".into())];
}
if in_rotate_zone(r, p, tol, cx.tol(18.0)).is_some() {
self.state = State::Rotating { center: r.center(), start: p };
return vec![Action::Begin("Rotate".into())];
}
}
// 2. Objects.
match hit_test(cx.doc, p, cx.hit_options()) {
Some(h) => {
let top = h.top_object(cx.isolation);
let mut out = vec![];
if m.shift {
out.push(Action::Exec("select.toggle".into(), json!({ "id": top.0 })));
if cx.selection.contains(top) {
self.state = State::Idle;
return out;
}
} else if !cx.selection.contains(top) {
out.push(Action::Exec("select.set".into(), json!({ "ids": [top.0] })));
}
self.state = State::Moving { start: p, began: false };
out
}
None => {
self.state = State::Marquee { start: p, cur: p, add: m.shift };
vec![]
}
}
}
(PointerKind::Drag, State::Moving { start, began, .. }) => {
let mut out = vec![];
if !began {
if p.distance(start) < Self::drag_threshold(cx) {
return out;
}
out.push(Action::Begin(if m.alt { "Copy".into() } else { "Move".into() }));
}
let d = move_delta(start, p, m.shift);
self.state = State::Moving { start, began: true };
self.measure = Some((p, format!("dX: {:.2} pt\ndY: {:.2} pt", d.x, d.y)));
out.push(Action::Preview("object.transform".into(), json!({ "matrix": matrix_json(Affine::translate(d)), "copy": m.alt })));
out
}
(PointerKind::Drag, State::Scaling { handle, rect, .. }) => {
let a = scale_for_drag(rect, handle, p, m.shift, m.alt);
let nr = a.transform_rect_bbox(rect);
self.measure = Some((p, format!("W: {:.2} pt\nH: {:.2} pt", nr.width(), nr.height())));
vec![Action::Preview("object.transform".into(), json!({ "matrix": matrix_json(a), "copy": false }))]
}
(PointerKind::Drag, State::Rotating { center, start, .. }) => {
let (a, deg) = rotate_for_drag(center, start, p, m.shift);
self.measure = Some((p, format!("{:.1}°", -deg)));
vec![Action::Preview("object.transform".into(), json!({ "matrix": matrix_json(a), "copy": false }))]
}
(PointerKind::Drag, State::Marquee { start, add, .. }) => {
self.state = State::Marquee { start, cur: p, add };
vec![]
}
(PointerKind::Up, State::Moving { began, .. }) => {
self.state = State::Idle;
self.measure = None;
if began { vec![Action::Commit] } else { vec![] }
}
(PointerKind::Up, State::Scaling { .. } | State::Rotating { .. }) => {
self.state = State::Idle;
self.measure = None;
vec![Action::Commit]
}
(PointerKind::Up, State::Marquee { start, add, .. }) => {
self.state = State::Idle;
let r = Rect::from_points(start, p);
if r.width() < Self::drag_threshold(cx) && r.height() < Self::drag_threshold(cx) {
return if add { vec![] } else { vec![Action::Exec("select.none".into(), json!({}))] };
}
let ids: Vec<NodeId> = marquee(cx.doc, r, cx.isolation, false);
if add {
vec![Action::Exec("select.add".into(), json!({ "ids": json_ids(&ids) }))]
} else {
vec![Action::Exec("select.set".into(), json!({ "ids": json_ids(&ids) }))]
}
}
_ => vec![],
}
}
fn overlays(&self, _cx: &ToolContext) -> Vec<Overlay> {
let mut o = vec![];
if let State::Marquee { start, cur, .. } = self.state {
o.push(Overlay::Marquee(Rect::from_points(start, cur)));
}
if let Some((p, t)) = &self.measure {
o.push(Overlay::Measure { p: *p, text: t.clone() });
}
o
}
fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor {
match self.state {
State::Rotating { .. } => return Cursor::Rotate,
State::Scaling { handle, .. } => return handle_cursor(handle),
State::Moving { began: true, .. } => return Cursor::Arrow,
_ => {}
}
if cx.show_bbox
&& let Some(r) = selection_bounds(cx)
{
let tol = cx.tol(5.0);
if let Some(h) = hit_handle(r, p, tol) {
return handle_cursor(h);
}
if in_rotate_zone(r, p, tol, cx.tol(18.0)).is_some() {
return Cursor::Rotate;
}
}
if let Some(h) = hit_test(cx.doc, p, cx.hit_options()) {
if cx.selection.contains(h.top_object(cx.isolation)) || m.alt {
return Cursor::Move;
}
return Cursor::Arrow;
}
Cursor::Arrow
}
}
fn handle_cursor(h: Handle) -> Cursor {
match h {
Handle::Top | Handle::Bottom => Cursor::ResizeV,
Handle::Left | Handle::Right => Cursor::ResizeH,
Handle::TopLeft | Handle::BottomRight => Cursor::ResizeNwSe,
Handle::TopRight | Handle::BottomLeft => Cursor::ResizeNeSw,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testutil::*;
use drawcraft_doc::Selection;
fn ev(kind: PointerKind, x: f64, y: f64) -> PointerEvent {
PointerEvent::new(kind, x, y)
}
#[test]
fn click_selects_object() {
let (d, id) = doc_with_rect();
let s = Selection::default();
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = SelectionTool::default();
let a = t.pointer(&cx, &ev(PointerKind::Down, 150.0, 150.0));
assert_eq!(a, vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))]);
assert!(t.pointer(&cx, &ev(PointerKind::Up, 150.0, 150.0)).is_empty());
}
#[test]
fn drag_moves_with_single_undo() {
let (d, id) = doc_with_rect();
let mut s = Selection::default();
s.add(id);
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = SelectionTool::default();
assert!(t.pointer(&cx, &ev(PointerKind::Down, 150.0, 150.0)).is_empty());
let a = t.pointer(&cx, &ev(PointerKind::Drag, 160.0, 150.0));
assert_eq!(a[0], Action::Begin("Move".into()));
assert!(matches!(&a[1], Action::Preview(c, v) if c == "object.transform" && v["matrix"][4] == 10.0));
let a = t.pointer(&cx, &ev(PointerKind::Up, 160.0, 150.0));
assert_eq!(a, vec![Action::Commit]);
}
#[test]
fn marquee_selects() {
let (d, id) = doc_with_rect();
let s = Selection::default();
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = SelectionTool::default();
t.pointer(&cx, &ev(PointerKind::Down, 50.0, 50.0));
t.pointer(&cx, &ev(PointerKind::Drag, 120.0, 120.0));
assert_eq!(t.overlays(&cx).len(), 1);
let a = t.pointer(&cx, &ev(PointerKind::Up, 120.0, 120.0));
assert_eq!(a, vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))]);
}
#[test]
fn handle_drag_scales() {
let (d, id) = doc_with_rect();
let mut s = Selection::default();
s.add(id);
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = SelectionTool::default();
assert_eq!(t.cursor(&cx, Point::new(200.0, 200.0), Mods::default()), Cursor::ResizeNwSe);
assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, 200.0, 200.0)), vec![Action::Begin("Scale".into())]);
let a = t.pointer(&cx, &ev(PointerKind::Drag, 300.0, 300.0));
assert!(matches!(&a[0], Action::Preview(_, v) if v["matrix"][0] == 2.0));
assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 300.0, 300.0)), vec![Action::Commit]);
}
#[test]
fn click_empty_deselects() {
let (d, _) = doc_with_rect();
let s = Selection::default();
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = SelectionTool::default();
t.pointer(&cx, &ev(PointerKind::Down, 400.0, 400.0));
assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 400.0, 400.0)), vec![Action::Exec("select.none".into(), json!({}))]);
}
}
+251
View File
@@ -0,0 +1,251 @@
//! Shape tools: Rectangle, Rounded Rectangle, Ellipse, Polygon, Star, Line Segment.
//!
//! Drag draws (Shift constrains to square/circle/45°, Alt draws from the centre); a click without a
//! drag asks the UI for the size dialog (like Illustrator). ↑/↓ during a polygon/star drag change the
//! side/point count.
use drawcraft_geom::{Point, Rect};
use serde_json::{Value, json};
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
pub struct ShapeTool {
id: &'static str,
start: Option<Point>,
last: Point,
mods: Mods,
began: bool,
pub sides: u32,
pub points: u32,
pub corner_radius: f64,
/// Star inner radius as a fraction of the outer radius.
pub star_ratio: f64,
}
impl ShapeTool {
pub fn new(id: &str) -> Self {
let id: &'static str = match id {
"roundedRectangle" => "roundedRectangle",
"ellipse" => "ellipse",
"polygon" => "polygon",
"star" => "star",
"lineSegment" => "lineSegment",
_ => "rectangle",
};
Self { id, start: None, last: Point::ZERO, mods: Mods::default(), began: false, sides: 6, points: 5, corner_radius: 12.0, star_ratio: 0.5 }
}
fn label(&self) -> &'static str {
match self.id {
"roundedRectangle" => "Rounded Rectangle",
"ellipse" => "Ellipse",
"polygon" => "Polygon",
"star" => "Star",
"lineSegment" => "Line",
_ => "Rectangle",
}
}
/// The command for the current drag.
pub fn command(&self, start: Point, p: Point, m: Mods) -> (String, Value) {
match self.id {
"polygon" | "star" => {
let v = p - start;
let r = v.hypot().max(0.01);
let rot = if m.shift { 0.0 } else { (v.atan2().to_degrees() + 90.0) % 360.0 };
if self.id == "polygon" {
("shape.polygon".into(), json!({ "cx": start.x, "cy": start.y, "radius": r, "sides": self.sides, "rotation": rot }))
} else {
(
"shape.star".into(),
json!({ "cx": start.x, "cy": start.y, "radius1": r, "radius2": r * self.star_ratio, "points": self.points, "rotation": rot }),
)
}
}
"lineSegment" => {
let (a, b) = if m.alt { (start - (p - start), p) } else { (start, p) };
let b = if m.shift { a + drawcraft_geom::constrain_angle(b - a, 45.0) } else { b };
("shape.line".into(), json!({ "x1": a.x, "y1": a.y, "x2": b.x, "y2": b.y }))
}
_ => {
let mut d = p - start;
if m.shift {
let s = d.x.abs().max(d.y.abs());
d = drawcraft_geom::Vec2::new(s * d.x.signum(), s * d.y.signum());
}
let r = if m.alt { Rect::from_points(start - d, start + d) } else { Rect::from_points(start, start + d) };
let cmd = if self.id == "ellipse" { "shape.ellipse" } else { "shape.rectangle" };
let mut v = json!({ "x": r.x0, "y": r.y0, "width": r.width(), "height": r.height() });
if self.id == "roundedRectangle" {
v["radius"] = json!(self.corner_radius);
}
(cmd.into(), v)
}
}
}
}
impl Tool for ShapeTool {
fn id(&self) -> &'static str {
self.id
}
fn busy(&self) -> bool {
self.start.is_some()
}
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
match ev.kind {
PointerKind::Down => {
self.start = Some(ev.pos);
self.last = ev.pos;
self.began = false;
vec![]
}
PointerKind::Drag => {
let Some(s) = self.start else { return vec![] };
self.last = ev.pos;
self.mods = ev.mods;
let mut out = vec![];
if !self.began {
if ev.pos.distance(s) < cx.tol(2.0) {
return out;
}
self.began = true;
out.push(Action::Begin(self.label().into()));
}
let (c, v) = self.command(s, ev.pos, ev.mods);
out.push(Action::Preview(c, v));
out
}
PointerKind::Up => {
let Some(s) = self.start.take() else { return vec![] };
if self.began {
self.began = false;
vec![Action::Commit]
} else {
vec![Action::Dialog(self.id.into(), json!({ "x": s.x, "y": s.y }))]
}
}
_ => vec![],
}
}
fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec<Action> {
let Some(s) = self.start.filter(|_| self.began) else { return vec![] };
let changed = match (self.id, key) {
("polygon", ToolKey::Up) => {
self.sides = (self.sides + 1).min(1000);
true
}
("polygon", ToolKey::Down) => {
self.sides = self.sides.saturating_sub(1).max(3);
true
}
("star", ToolKey::Up) => {
self.points = (self.points + 1).min(1000);
true
}
("star", ToolKey::Down) => {
self.points = self.points.saturating_sub(1).max(3);
true
}
("roundedRectangle", ToolKey::Up) => {
self.corner_radius += 1.0;
true
}
("roundedRectangle", ToolKey::Down) => {
self.corner_radius = (self.corner_radius - 1.0).max(0.0);
true
}
_ => false,
};
if changed {
let (c, v) = self.command(s, self.last, self.mods);
vec![Action::Preview(c, v)]
} else if key == ToolKey::Escape {
self.start = None;
self.began = false;
vec![Action::Cancel]
} else {
vec![]
}
}
fn overlays(&self, _cx: &ToolContext) -> Vec<Overlay> {
match self.start {
Some(s) if self.began => {
let d = self.last - s;
vec![Overlay::Measure { p: self.last, text: format!("W: {:.2} pt\nH: {:.2} pt", d.x.abs(), d.y.abs()) }]
}
_ => vec![],
}
}
fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor {
Cursor::Crosshair
}
fn options(&self) -> Value {
json!({ "sides": self.sides, "points": self.points, "cornerRadius": self.corner_radius, "starRatio": self.star_ratio })
}
fn set_option(&mut self, key: &str, v: &Value) {
match key {
"sides" => self.sides = v.as_u64().unwrap_or(6).clamp(3, 1000) as u32,
"points" => self.points = v.as_u64().unwrap_or(5).clamp(3, 1000) as u32,
"cornerRadius" => self.corner_radius = v.as_f64().unwrap_or(12.0).max(0.0),
"starRatio" => self.star_ratio = v.as_f64().unwrap_or(0.5).clamp(0.01, 1.0),
_ => {}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testutil::*;
use drawcraft_doc::Selection;
#[test]
fn rect_drag() {
let (d, _) = doc_with_rect();
let s = Selection::default();
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = ShapeTool::new("rectangle");
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0));
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 50.0, 30.0));
assert_eq!(a[0], Action::Begin("Rectangle".into()));
assert_eq!(a[1], Action::Preview("shape.rectangle".into(), json!({"x": 10.0, "y": 10.0, "width": 40.0, "height": 20.0})));
assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 50.0, 30.0)), vec![Action::Commit]);
}
#[test]
fn shift_square_alt_center() {
let t = ShapeTool::new("ellipse");
let (c, v) = t.command(Point::new(0.0, 0.0), Point::new(10.0, 4.0), Mods { shift: true, alt: true, ..Default::default() });
assert_eq!(c, "shape.ellipse");
assert_eq!(v, json!({"x": -10.0, "y": -10.0, "width": 20.0, "height": 20.0}));
}
#[test]
fn click_opens_dialog() {
let (d, _) = doc_with_rect();
let s = Selection::default();
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = ShapeTool::new("star");
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0));
assert_eq!(
t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 10.0, 10.0)),
vec![Action::Dialog("star".into(), json!({"x": 10.0, "y": 10.0}))]
);
}
#[test]
fn arrow_keys_change_sides() {
let (d, _) = doc_with_rect();
let s = Selection::default();
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = ShapeTool::new("polygon");
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0));
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 50.0, 10.0));
let a = t.key(&cx, ToolKey::Up, Mods::default());
assert!(matches!(&a[0], Action::Preview(_, v) if v["sides"] == 7));
}
}
+10
View File
@@ -0,0 +1,10 @@
[package]
name = "drawcraft-ui-egui"
version.workspace = true
edition.workspace = true
license.workspace = true
[lints]
workspace = true
[dependencies]
+1
View File
@@ -0,0 +1 @@
//! stub
+2
View File
@@ -0,0 +1,2 @@
max_width = 150
use_small_heuristics = "Max"

Some files were not shown because too many files have changed in this diff Show More