mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 01:37:02 -06:00
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:
co-authored by
Claude Opus 5.5
parent
fa1689bfd8
commit
bfee80a22c
@@ -1,2 +1,7 @@
|
||||
*~
|
||||
plan/
|
||||
/target
|
||||
/dist
|
||||
/corpus
|
||||
/log
|
||||
.DS_Store
|
||||
|
||||
Generated
+1270
File diff suppressed because it is too large
Load Diff
+71
@@ -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
|
||||
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "drawcraft-cli"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
@@ -0,0 +1 @@
|
||||
fn main() {}
|
||||
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "drawcraft-web"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
@@ -0,0 +1 @@
|
||||
fn main() {}
|
||||
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "drawcraft"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
@@ -0,0 +1 @@
|
||||
fn main() {}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
@@ -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 }
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -0,0 +1,2 @@
|
||||
//! DrawCraft live effects evaluated to geometry
|
||||
#![forbid(unsafe_code)]
|
||||
@@ -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 }
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()}))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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() }))
|
||||
}
|
||||
@@ -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},
|
||||
})
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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!({}));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -0,0 +1,2 @@
|
||||
//! DrawCraft native .drawcraft format
|
||||
#![forbid(unsafe_code)]
|
||||
@@ -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 }
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
[package]
|
||||
name = "drawcraft-mcp"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
@@ -0,0 +1 @@
|
||||
//! stub
|
||||
@@ -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 }
|
||||
@@ -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.
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -0,0 +1,2 @@
|
||||
//! DrawCraft PDF export and import
|
||||
#![forbid(unsafe_code)]
|
||||
@@ -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 }
|
||||
@@ -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;
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 }
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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("&"),
|
||||
'<' => o.push_str("<"),
|
||||
'>' => o.push_str(">"),
|
||||
'"' => o.push_str("""),
|
||||
'\'' => o.push_str("'"),
|
||||
_ => 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<b>&"'");
|
||||
}
|
||||
}
|
||||
@@ -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 & B <C>") && s.contains(""q"") && 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();
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -0,0 +1,2 @@
|
||||
//! DrawCraft test helpers
|
||||
#![forbid(unsafe_code)]
|
||||
@@ -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 }
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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, ¶s, &t.para, None),
|
||||
TextKind::Area { frame } => {
|
||||
let region = Region::new(&frame.to_bezpath());
|
||||
flow(&mut cx, ¶s, &t.para, Some(®ion));
|
||||
}
|
||||
TextKind::OnPath { path, start } => on_path(&mut cx, ¶s, &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(),
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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}))]);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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!({}))]);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
[package]
|
||||
name = "drawcraft-ui-egui"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
@@ -0,0 +1 @@
|
||||
//! stub
|
||||
@@ -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
Reference in New Issue
Block a user