Add customizable shape tools and 3D Box add-on

- Extend vectorcraft-geom::shapes with clean-room open/pie/chord/arc ellipse and rectangle builders, per-corner radii, polygon/star corner rounding and deterministic vertex randomization, logarithmic/Archimedean spiral divergence and inner radius, and perspective/isometric 3D box face builders.
- Isolate 3D Box and customizable shape parameters as a VectorCraft add-on under crates/*/src/**/addons/:
  - crates/tools/src/addons/box3d.rs (Box3dTool and CustomShape extras stored under Node::extras["vc-shape-tools"])
  - crates/engine/src/cmd/addons/shapes.rs (addon.shapes.rectangle, addon.shapes.ellipse, addon.shapes.polygon, addon.shapes.star, addon.shapes.spiral, addon.shapes.box3d, and addon.shapes.configure)
  - crates/ui-egui/src/addons/shapes.rs (Object > Plug-ins > 3D Box > 3D Box Tool and context-sensitive Control bar options)
- Keep crates/engine/src/cmd/create.rs, crates/tools/src/shape.rs, and crates/tools/src/draw2/family.rs byte-for-byte identical to main.
- Add translations for "3D Box" and "3D Box Tool" across all 26 .tsv catalogs in crates/ui-egui/src/i18n/.
This commit is contained in:
Harley Hawk
2026-11-05 23:25:05 +07:00
committed by bflatastic
parent b8155698b5
commit a77b9e37d3
36 changed files with 2141 additions and 7 deletions
+2
View File
@@ -9,6 +9,7 @@ mod document;
mod pathtools;
mod pen;
mod select;
mod shapes;
mod typetools;
use super::CommandSpec;
@@ -23,6 +24,7 @@ pub fn specs() -> Vec<CommandSpec> {
v.extend(pathtools::specs());
v.extend(pen::specs());
v.extend(select::specs());
v.extend(shapes::specs());
v.extend(typetools::specs());
v
}
+477
View File
@@ -0,0 +1,477 @@
//! 3D Box and customizable shapes (`addon.shapes.*`), a built-in add-on: creates rotatable 3D boxes
//! (`addon.shapes.box3d`) and customizable rectangles, ellipses, polygons, stars and spirals
//! (`addon.shapes.rectangle`, `addon.shapes.ellipse`, `addon.shapes.polygon`, `addon.shapes.star`,
//! `addon.shapes.spiral`), and configures extended live-shape parameters on selected shapes
//! (`addon.shapes.configure`: symmetric or per-corner radii, pie/chord/arc/whole modes, polygon/star
//! spoke ratio, corner rounding, randomization, spiral turns/divergence/inner radius, and 3D box
//! rotation angles).
#[cfg(test)]
mod tests;
use serde_json::{Value, json};
use vectorcraft_doc::{LiveShape, NodeId, NodeKind};
use vectorcraft_geom::{Affine, FillRule, Point, Rect, shapes};
use vectorcraft_tools::addons::box3d::CustomShape;
use super::super::create::insert_look;
use super::super::{
CommandSpec, Result, Session, bool_or, check_numbers, check_switches, cmd, f64_or, f64_req, has_doc, has_selection, str_param, targets,
};
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"addon.shapes.rectangle",
"Rectangle",
[],
None,
"{x, y, width, height, radius?: pt, radii?: [tl,tr,br,bl], startAngle?, endAngle?, arcMode?: whole|pie|chord|arc, rotation?: deg} → {id}",
has_doc,
rectangle
),
cmd!(
"addon.shapes.ellipse",
"Ellipse",
[],
None,
"{x, y, width, height, startAngle?, endAngle?, arcMode?: whole|pie|chord|arc, rotation?: deg} → {id}",
has_doc,
ellipse
),
cmd!(
"addon.shapes.polygon",
"Polygon",
[],
None,
"{cx, cy, radius, sides=6, rounded=0, randomized=0, startAngle?, endAngle?, arcMode?: whole|pie|chord|arc, rotation?: deg} → {id}",
has_doc,
polygon
),
cmd!(
"addon.shapes.star",
"Star",
[],
None,
"{cx, cy, radius1, radius2?, points=5, rounded=0, randomized=0, startAngle?, endAngle?, arcMode?: whole|pie|chord|arc, rotation?: deg} → {id}",
has_doc,
star
),
cmd!(
"addon.shapes.spiral",
"Spiral",
[],
None,
"{cx, cy, radius, turns=3, divergence=1, innerRadius=0, clockwise=true} → {id}",
has_doc,
spiral
),
cmd!(
"addon.shapes.box3d",
"3D Box",
[],
None,
"{x, y, width, height, angleX=30, angleY=30, angleZ=0, parallelX=false, parallelY=true, parallelZ=false} → {id}",
has_doc,
box3d
),
cmd!(
"addon.shapes.configure",
"Configure Shape",
[],
None,
"{ids?, radius?, radii?: [tl,tr,br,bl], startAngle?, endAngle?, arcMode?: whole|pie|chord|arc, sides?, isStar?, spokeRatio?, rounded?, randomized?, turns?, divergence?, innerRadius?, angleX?, angleY?, angleZ?, parallelX?, parallelY?, parallelZ?, reset?: bool} → {updated}",
has_selection,
configure
),
]
}
fn parse_arc_params(p: &Value) -> ((f64, f64), shapes::EllipseMode) {
let start = f64_or(p, "startAngle", 0.0);
let end = f64_or(p, "endAngle", 360.0);
let mode = match str_param(p, "arcMode") {
Some("pie" | "slice") => shapes::EllipseMode::Slice,
Some("chord") => shapes::EllipseMode::Chord,
Some("arc" | "open") => shapes::EllipseMode::Arc,
_ => shapes::EllipseMode::Whole,
};
let span = (end - start).rem_euclid(360.0);
let pie = if mode == shapes::EllipseMode::Whole && (span < 1e-6 || (span - 360.0).abs() < 1e-6) { (0.0, 360.0) } else { (start, end) };
(pie, mode)
}
fn rect_of(p: &Value, c: &str) -> Result<Rect> {
let x = f64_req(p, "x", c)?;
let y = f64_req(p, "y", c)?;
let w = f64_req(p, "width", c)?;
let h = f64_req(p, "height", c)?;
Ok(Rect::new(x, y, x + w.abs(), y + h.abs()).abs())
}
fn rect_xf(r: Rect, rot: f64) -> Affine {
if rot == 0.0 || !rot.is_finite() {
Affine::translate(r.origin().to_vec2())
} else {
Affine::translate(r.center().to_vec2()) * vectorcraft_geom::rotate_deg(rot) * Affine::translate((-r.width() / 2.0, -r.height() / 2.0))
}
}
fn insert_custom(s: &mut Session, label: &str, live: Option<LiveShape>, cs: CustomShape) -> Result<Value> {
let path = match (&live, cs.needs_extra_storage()) {
(Some(l), false) => l.to_path(),
_ => cs.to_path(),
};
let kind = NodeKind::Path { path, rule: FillRule::NonZero, live: live.map(Box::new), clipping: false, guide: false };
let look = s.new_art_look(s.paint.fill.clone(), s.paint.stroke.clone(), s.paint.stroke_width);
let val = insert_look(s, label, kind, look, None, true)?;
if cs.needs_extra_storage()
&& let Some(raw_id) = val.get("id").and_then(Value::as_u64)
&& let Ok(st) = s.doc_mut()
&& let Some(n) = std::sync::Arc::make_mut(&mut st.doc).node_mut(NodeId(raw_id))
{
cs.write_to_node(n);
}
Ok(val)
}
fn rectangle(s: &mut Session, p: &Value) -> Result<Value> {
let c = "addon.shapes.rectangle";
check_numbers(p, &["radius", "startAngle", "endAngle", "rotation"], c)?;
let r = rect_of(p, c)?;
let radius = f64_or(p, "radius", 0.0).max(0.0);
let mut radii = [radius; 4];
if let Some(arr) = p.get("radii").and_then(Value::as_array)
&& arr.len() == 4
{
for (i, item) in arr.iter().enumerate() {
if let Some(val) = item.as_f64().filter(|n| n.is_finite()) {
radii[i] = val.max(0.0);
}
}
}
let (pie, arc_mode) = parse_arc_params(p);
let xf = rect_xf(r, f64_or(p, "rotation", 0.0));
let live = LiveShape::Rectangle { w: r.width(), h: r.height(), radii, kinds: Default::default(), radii_relative: false, xf };
let cs = CustomShape::Rectangle { w: r.width(), h: r.height(), radii, kinds: Default::default(), radii_relative: false, xf, pie, arc_mode };
let label = if radii.iter().any(|&v| v > 0.0) { "Rounded Rectangle" } else { "Rectangle" };
insert_custom(s, label, Some(live), cs)
}
fn ellipse(s: &mut Session, p: &Value) -> Result<Value> {
let c = "addon.shapes.ellipse";
check_numbers(p, &["startAngle", "endAngle", "rotation"], c)?;
let r = rect_of(p, c)?;
let (pie, arc_mode) = parse_arc_params(p);
let xf = rect_xf(r, f64_or(p, "rotation", 0.0));
let live = LiveShape::Ellipse { w: r.width(), h: r.height(), pie, xf };
let cs = CustomShape::Ellipse { w: r.width(), h: r.height(), pie, arc_mode, xf };
insert_custom(s, "Ellipse", Some(live), cs)
}
fn polygon(s: &mut Session, p: &Value) -> Result<Value> {
let c = "addon.shapes.polygon";
let center = Point::new(f64_req(p, "cx", c)?, f64_req(p, "cy", c)?);
let r = f64_req(p, "radius", c)?.abs();
check_numbers(p, &["sides", "rotation", "rounded", "randomized", "startAngle", "endAngle"], c)?;
let sides = (f64_or(p, "sides", 6.0).round() as i64).clamp(3, 1000) as u32;
let rot = f64_or(p, "rotation", 0.0);
let rounded = f64_or(p, "rounded", 0.0).clamp(-10.0, 10.0);
let randomized = f64_or(p, "randomized", 0.0).clamp(-10.0, 10.0);
let (pie, arc_mode) = parse_arc_params(p);
let xf = Affine::translate(center.to_vec2()) * vectorcraft_geom::rotate_deg(rot);
let live = LiveShape::Polygon { radius: r, sides, xf, radii: vec![], kinds: vec![], radii_relative: false };
let cs = CustomShape::Polygon {
radius: r,
sides,
xf,
radii: vec![],
kinds: vec![],
radii_relative: false,
spoke_ratio: None,
rounded,
randomized,
pie,
arc_mode,
};
insert_custom(s, "Polygon", Some(live), cs)
}
fn star(s: &mut Session, p: &Value) -> Result<Value> {
let c = "addon.shapes.star";
let center = Point::new(f64_req(p, "cx", c)?, f64_req(p, "cy", c)?);
let r1 = f64_req(p, "radius1", c)?.abs();
check_numbers(p, &["radius2", "points", "rotation", "rounded", "randomized", "startAngle", "endAngle"], c)?;
let r2 = f64_or(p, "radius2", r1 / 2.0).abs();
let points = (f64_or(p, "points", 5.0).round() as i64).clamp(3, 1000) as u32;
let rot = f64_or(p, "rotation", 0.0);
let rounded = f64_or(p, "rounded", 0.0).clamp(-10.0, 10.0);
let randomized = f64_or(p, "randomized", 0.0).clamp(-10.0, 10.0);
let (pie, arc_mode) = parse_arc_params(p);
let xf = Affine::translate(center.to_vec2()) * vectorcraft_geom::rotate_deg(rot);
let ratio = if r1 > 1e-9 { (r2 / r1).clamp(0.01, 1.0) } else { 0.5 };
let cs = CustomShape::Polygon {
radius: r1,
sides: points,
xf,
radii: vec![],
kinds: vec![],
radii_relative: false,
spoke_ratio: Some(ratio),
rounded,
randomized,
pie,
arc_mode,
};
insert_custom(s, "Star", None, cs)
}
fn spiral(s: &mut Session, p: &Value) -> Result<Value> {
let c = "addon.shapes.spiral";
let center = Point::new(f64_req(p, "cx", c)?, f64_req(p, "cy", c)?);
let r = f64_req(p, "radius", c)?.abs();
check_numbers(p, &["turns", "divergence", "innerRadius"], c)?;
check_switches(p, &["clockwise"], c)?;
let turns = f64_or(p, "turns", 3.0).clamp(0.01, 1024.0);
let divergence = f64_or(p, "divergence", 1.0).clamp(0.0, 1000.0);
let inner_radius = f64_or(p, "innerRadius", 0.0).clamp(0.0, 0.999);
let clockwise = bool_or(p, "clockwise", true);
let cs = CustomShape::Spiral { center, radius: r, turns, divergence, inner_radius, clockwise };
insert_custom(s, "Spiral", None, cs)
}
fn box3d(s: &mut Session, p: &Value) -> Result<Value> {
let c = "addon.shapes.box3d";
let r = rect_of(p, c)?;
check_numbers(p, &["angleX", "angleY", "angleZ"], c)?;
check_switches(p, &["parallelX", "parallelY", "parallelZ"], c)?;
let angle_x = f64_or(p, "angleX", 30.0);
let angle_y = f64_or(p, "angleY", 30.0);
let angle_z = f64_or(p, "angleZ", 0.0);
let parallel_x = bool_or(p, "parallelX", false);
let parallel_y = bool_or(p, "parallelY", true);
let parallel_z = bool_or(p, "parallelZ", false);
let cs = CustomShape::Box3d {
w: r.width(),
h: r.height(),
angle_x,
angle_y,
angle_z,
parallel_x,
parallel_y,
parallel_z,
xf: Affine::translate(r.origin().to_vec2()),
};
let kind = NodeKind::Path { path: cs.to_path(), rule: FillRule::NonZero, live: None, clipping: false, guide: false };
let look = s.new_art_look(s.paint.fill.clone(), s.paint.stroke.clone(), s.paint.stroke_width);
let val = insert_look(s, "3D Box", kind, look, Some("3D Box".into()), true)?;
if let Some(raw_id) = val.get("id").and_then(Value::as_u64)
&& let Ok(st) = s.doc_mut()
&& let Some(n) = std::sync::Arc::make_mut(&mut st.doc).node_mut(NodeId(raw_id))
{
cs.write_to_node(n);
}
Ok(val)
}
fn configure(s: &mut Session, p: &Value) -> Result<Value> {
let c = "addon.shapes.configure";
check_numbers(
p,
&[
"radius",
"startAngle",
"endAngle",
"sides",
"spokeRatio",
"rounded",
"randomized",
"turns",
"divergence",
"innerRadius",
"angleX",
"angleY",
"angleZ",
],
c,
)?;
check_switches(p, &["isStar", "parallelX", "parallelY", "parallelZ", "reset"], c)?;
let ids = targets(s, p)?;
let reset = bool_or(p, "reset", false);
let spiral_hint = p.get("turns").is_some() || p.get("divergence").is_some() || p.get("innerRadius").is_some();
let updated = s.edit("Live Shape", |d, _| {
let mut count = 0usize;
for id in ids {
let Some(n) = d.node_mut(id) else { continue };
let Some(mut cs) = CustomShape::read_for_update(n, spiral_hint) else { continue };
if apply_shape_params(&mut cs, p, reset) {
cs.write_to_node(n);
count += 1;
}
}
Ok(count)
})?;
Ok(json!({ "updated": updated }))
}
fn num_param(p: &Value, k: &str) -> Option<f64> {
p.get(k).and_then(Value::as_f64).filter(|v| v.is_finite())
}
fn apply_arc_params(p: &Value, pie: &mut (f64, f64), arc_mode: &mut shapes::EllipseMode) -> bool {
let mut changed = false;
if let Some(a0) = num_param(p, "startAngle") {
pie.0 = a0.rem_euclid(360.0);
changed = true;
}
if let Some(a1) = num_param(p, "endAngle") {
pie.1 = a1.rem_euclid(360.0);
changed = true;
}
if let Some(m) = str_param(p, "arcMode") {
*arc_mode = parse_mode(m);
if *arc_mode == shapes::EllipseMode::Whole {
*pie = (0.0, 360.0);
} else if (pie.1 - pie.0).abs() < 1e-6 || ((pie.1 - pie.0).abs() - 360.0).abs() < 1e-6 {
*pie = (0.0, 270.0);
}
changed = true;
}
changed
}
fn apply_rect_params(p: &Value, reset: bool, max_r: f64, radii: &mut [f64; 4], pie: &mut (f64, f64), arc_mode: &mut shapes::EllipseMode) -> bool {
let mut changed = false;
if reset {
*radii = [0.0; 4];
*pie = (0.0, 360.0);
*arc_mode = shapes::EllipseMode::Whole;
changed = true;
}
if let Some(r) = num_param(p, "radius") {
*radii = [r.clamp(0.0, max_r); 4];
changed = true;
}
if let Some(arr) = p.get("radii").and_then(Value::as_array)
&& arr.len() == 4
{
for (i, v) in arr.iter().enumerate() {
if let Some(r) = v.as_f64().filter(|x| x.is_finite()) {
radii[i] = r.clamp(0.0, max_r);
changed = true;
}
}
}
changed | apply_arc_params(p, pie, arc_mode)
}
fn apply_poly_params(p: &Value, reset: bool, sides: &mut u32, spoke_ratio: &mut Option<f64>, rounded: &mut f64, randomized: &mut f64) -> bool {
let mut changed = false;
if reset {
*rounded = 0.0;
*randomized = 0.0;
if spoke_ratio.is_some() {
*spoke_ratio = Some(0.5);
}
changed = true;
}
if let Some(n_sides) = p.get("sides").and_then(Value::as_u64) {
*sides = (n_sides as u32).clamp(3, 1000);
changed = true;
}
if let Some(star) = p.get("isStar").and_then(Value::as_bool) {
*spoke_ratio = if star { Some(spoke_ratio.unwrap_or(0.5)) } else { None };
changed = true;
}
if let Some(sr) = num_param(p, "spokeRatio") {
*spoke_ratio = Some(sr.clamp(0.01, 1.0));
changed = true;
}
if let Some(rnd) = num_param(p, "rounded") {
*rounded = rnd.clamp(-10.0, 10.0);
changed = true;
}
if let Some(rand) = num_param(p, "randomized") {
*randomized = rand.clamp(-10.0, 10.0);
changed = true;
}
changed
}
fn apply_shape_params(cs: &mut CustomShape, p: &Value, reset: bool) -> bool {
match cs {
CustomShape::Rectangle { w, h, radii, pie, arc_mode, .. } => apply_rect_params(p, reset, w.min(*h) / 2.0, radii, pie, arc_mode),
CustomShape::Ellipse { pie, arc_mode, .. } => {
let mut changed = false;
if reset {
*pie = (0.0, 360.0);
*arc_mode = shapes::EllipseMode::Whole;
changed = true;
}
changed | apply_arc_params(p, pie, arc_mode)
}
CustomShape::Polygon { sides, spoke_ratio, rounded, randomized, pie, arc_mode, .. } => {
if reset {
*pie = (0.0, 360.0);
*arc_mode = shapes::EllipseMode::Whole;
}
apply_poly_params(p, reset, sides, spoke_ratio, rounded, randomized) | apply_arc_params(p, pie, arc_mode)
}
CustomShape::Spiral { turns, divergence, inner_radius, .. } => {
let mut changed = false;
if reset {
*turns = 3.0;
*divergence = 1.0;
*inner_radius = 0.0;
changed = true;
}
if let Some(t) = num_param(p, "turns") {
*turns = t.clamp(0.01, 1024.0);
changed = true;
}
if let Some(div) = num_param(p, "divergence") {
*divergence = div.clamp(0.0, 1000.0);
changed = true;
}
if let Some(ir) = num_param(p, "innerRadius") {
*inner_radius = ir.clamp(0.0, 0.999);
changed = true;
}
changed
}
CustomShape::Box3d { angle_x, angle_y, angle_z, parallel_x, parallel_y, parallel_z, .. } => {
let mut changed = false;
if reset {
*angle_x = 30.0;
*angle_y = 30.0;
*angle_z = 0.0;
*parallel_x = false;
*parallel_y = true;
*parallel_z = false;
changed = true;
}
for (k, slot) in [("angleX", angle_x), ("angleY", angle_y), ("angleZ", angle_z)] {
if let Some(v) = num_param(p, k) {
*slot = v;
changed = true;
}
}
for (k, slot) in [("parallelX", parallel_x), ("parallelY", parallel_y), ("parallelZ", parallel_z)] {
if let Some(v) = p.get(k).and_then(Value::as_bool) {
*slot = v;
changed = true;
}
}
changed
}
}
}
fn parse_mode(m: &str) -> shapes::EllipseMode {
match m {
"pie" | "slice" => shapes::EllipseMode::Slice,
"chord" => shapes::EllipseMode::Chord,
"arc" | "open" => shapes::EllipseMode::Arc,
_ => shapes::EllipseMode::Whole,
}
}
@@ -0,0 +1,137 @@
use serde_json::json;
use vectorcraft_doc::{LiveShape, NodeId, NodeKind};
use vectorcraft_geom::{Affine, Rect, shapes};
use vectorcraft_tools::addons::box3d::CustomShape;
use crate::Session;
fn new_session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
s
}
#[test]
fn default_shapes_do_not_alter_standard_documents() {
let mut s = new_session();
// Default rectangle: standard LiveShape::Rectangle, no vc-shape-tools attribute.
let rect_id =
NodeId(s.execute("shape.rectangle", &json!({"x": 10.0, "y": 20.0, "width": 100.0, "height": 50.0})).unwrap()["id"].as_u64().unwrap());
let n = s.doc().unwrap().doc.node(rect_id).unwrap();
assert!(n.extras().attrs.is_none());
let NodeKind::Path { path, live: Some(live), .. } = &n.kind else { panic!("expected live path") };
assert_eq!(
**live,
LiveShape::Rectangle {
w: 100.0,
h: 50.0,
radii: [0.0; 4],
kinds: Default::default(),
radii_relative: false,
xf: Affine::translate((10.0, 20.0))
}
);
assert_eq!(path, &shapes::rectangle(Rect::new(10.0, 20.0, 110.0, 70.0)));
// Default ellipse: standard LiveShape::Ellipse, no vc-shape-tools attribute.
let ell_id = NodeId(s.execute("shape.ellipse", &json!({"x": 0.0, "y": 0.0, "width": 80.0, "height": 60.0})).unwrap()["id"].as_u64().unwrap());
let n = s.doc().unwrap().doc.node(ell_id).unwrap();
assert!(n.extras().attrs.is_none());
let NodeKind::Path { path, live: Some(live), .. } = &n.kind else { panic!("expected live path") };
assert_eq!(**live, LiveShape::Ellipse { w: 80.0, h: 60.0, pie: (0.0, 360.0), xf: Affine::IDENTITY });
assert_eq!(path, &shapes::ellipse(Rect::new(0.0, 0.0, 80.0, 60.0)));
}
#[test]
fn addon_shapes_commands_and_format_roundtrip_preserve_paths() {
let mut s = new_session();
// Create customizable shapes through addon.shapes.* commands.
let box_id = NodeId(
s.execute(
"addon.shapes.box3d",
&json!({"x": 10.0, "y": 10.0, "width": 100.0, "height": 80.0, "angleX": 30.0, "angleY": 35.0, "angleZ": 0.0}),
)
.unwrap()["id"]
.as_u64()
.unwrap(),
);
let n = s.doc().unwrap().doc.node(box_id).unwrap();
let NodeKind::Path { path, .. } = &n.kind else { panic!("expected path") };
assert_eq!(path.subpaths.len(), 3);
assert!(
matches!(CustomShape::read(n), Some(CustomShape::Box3d { angle_x, angle_y, .. }) if (angle_x - 30.0).abs() < 1e-6 && (angle_y - 35.0).abs() < 1e-6)
);
s.execute("addon.shapes.configure", &json!({"angleX": 45.0, "parallelX": true})).unwrap();
let n = s.doc().unwrap().doc.node(box_id).unwrap();
assert!(matches!(CustomShape::read(n), Some(CustomShape::Box3d { angle_x, parallel_x: true, .. }) if (angle_x - 45.0).abs() < 1e-6));
let ell_id = NodeId(
s.execute(
"addon.shapes.ellipse",
&json!({"x": 0.0, "y": 0.0, "width": 100.0, "height": 100.0, "arcMode": "arc", "startAngle": 30.0, "endAngle": 210.0}),
)
.unwrap()["id"]
.as_u64()
.unwrap(),
);
let n = s.doc().unwrap().doc.node(ell_id).unwrap();
let NodeKind::Path { path, .. } = &n.kind else { panic!("expected path") };
assert!(!path.subpaths[0].closed);
s.execute("addon.shapes.configure", &json!({"arcMode": "chord"})).unwrap();
let n = s.doc().unwrap().doc.node(ell_id).unwrap();
let NodeKind::Path { path, .. } = &n.kind else { panic!("expected path") };
assert!(path.subpaths[0].closed);
let rect_id = NodeId(
s.execute("addon.shapes.rectangle", &json!({"x": 0.0, "y": 0.0, "width": 100.0, "height": 80.0, "radii": [10.0, 20.0, 5.0, 0.0], "arcMode": "pie", "startAngle": 0.0, "endAngle": 180.0}))
.unwrap()["id"]
.as_u64()
.unwrap(),
);
let n = s.doc().unwrap().doc.node(rect_id).unwrap();
assert!(
matches!(CustomShape::read(n), Some(CustomShape::Rectangle { radii, arc_mode: shapes::EllipseMode::Slice, .. }) if radii == [10.0, 20.0, 5.0, 0.0])
);
let poly_id = NodeId(
s.execute("addon.shapes.polygon", &json!({"cx": 50.0, "cy": 50.0, "radius": 40.0, "sides": 5, "rounded": 0.25, "randomized": 0.1})).unwrap()
["id"]
.as_u64()
.unwrap(),
);
s.execute("addon.shapes.configure", &json!({"isStar": true, "spokeRatio": 0.4})).unwrap();
let n = s.doc().unwrap().doc.node(poly_id).unwrap();
assert!(
matches!(CustomShape::read(n), Some(CustomShape::Polygon { spoke_ratio: Some(sr), rounded, .. }) if (sr - 0.4).abs() < 1e-6 && (rounded - 0.25).abs() < 1e-6)
);
let spiral_id = NodeId(
s.execute("addon.shapes.spiral", &json!({"cx": 60.0, "cy": 60.0, "radius": 45.0, "turns": 4.0, "divergence": 1.4, "innerRadius": 0.2}))
.unwrap()["id"]
.as_u64()
.unwrap(),
);
let n = s.doc().unwrap().doc.node(spiral_id).unwrap();
assert!(
matches!(CustomShape::read(n), Some(CustomShape::Spiral { turns, divergence, .. }) if (turns - 4.0).abs() < 1e-6 && (divergence - 1.4).abs() < 1e-6)
);
// Verify that saving and reopening a document containing add-on shapes preserves both the
// baked PathData/LiveShape (for a build that ignores the add-on attribute) and the stored
// extras attribute across a save/load roundtrip.
let doc_before = s.doc().unwrap().doc.clone();
let bytes = vectorcraft_format::save(&doc_before, false);
let doc_after = vectorcraft_format::load(&bytes).unwrap();
let re_saved = vectorcraft_format::save(&doc_after, false);
assert_eq!(bytes, re_saved);
for id in [box_id, ell_id, rect_id, poly_id, spiral_id] {
let a = doc_before.node(id).unwrap();
let b = doc_after.node(id).unwrap();
assert_eq!(a.kind, b.kind);
assert_eq!(CustomShape::read(a), CustomShape::read(b));
}
}
+361 -3
View File
@@ -103,15 +103,44 @@ pub fn ellipse(r: Rect) -> PathData {
PathData::single(SubPath::new(anchors, true))
}
/// How an ellipse or shape arc is closed or left open when cut between start and end angles.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum EllipseMode {
/// Closed sector through the centre (pie slice).
#[default]
Slice,
/// Open arc curve without closing segments.
Arc,
/// Closed arc with a straight chord connecting the end back to the start.
Chord,
/// Whole closed shape (ignores start/end cut).
Whole,
}
/// The ellipse inscribed in `r` cut as a pie: from `start` to `end` degrees, measured
/// counterclockwise on screen from 3 o'clock (as Illustrator's Pie Start and End Angle are), then
/// back through the centre. A pie spanning the whole turn (or nothing) is the whole [`ellipse`].
/// The arc is one cubic per quarter turn at most: the centre, then the arc's anchors.
pub fn ellipse_pie(r: Rect, start: f64, end: f64) -> PathData {
let span = (end - start).rem_euclid(360.0);
if !(start.is_finite() && end.is_finite()) || span < 1e-9 || 360.0 - span < 1e-9 {
ellipse_arc(r, start, end, EllipseMode::Slice)
}
/// The ellipse inscribed in `r` cut from `start` to `end` degrees according to `mode`:
/// - [`EllipseMode::Slice`]: closed through the centre (pie chart).
/// - [`EllipseMode::Chord`]: closed with a straight chord between the endpoints.
/// - [`EllipseMode::Arc`]: open elliptical arc (no centre lines, `closed = false`).
/// - [`EllipseMode::Whole`]: full closed [`ellipse`].
pub fn ellipse_arc(r: Rect, start: f64, end: f64, mode: EllipseMode) -> PathData {
if mode == EllipseMode::Whole || !(start.is_finite() && end.is_finite()) {
return ellipse(r);
}
let raw_span = (end - start).rem_euclid(360.0);
let full = raw_span < 1e-9 || 360.0 - raw_span < 1e-9;
if full && mode != EllipseMode::Arc {
return ellipse(r);
}
let span = if full { 360.0 } else { raw_span };
let r = r.abs();
let c = r.center();
let (rx, ry) = (r.width() / 2.0, r.height() / 2.0);
@@ -135,7 +164,10 @@ pub fn ellipse_pie(r: Rect, start: f64, end: f64) -> PathData {
};
let step = span / f64::from(pieces);
let k = 4.0 / 3.0 * (step.to_radians() / 4.0).tan();
let mut anchors = vec![Anchor::corner(c)];
let mut anchors = Vec::with_capacity(pieces as usize + 2);
if mode == EllipseMode::Slice {
anchors.push(Anchor::corner(c));
}
for i in 0..=pieces {
let a = start + step * f64::from(i);
let (p, d) = (at(a), tangent(a) * k);
@@ -144,6 +176,123 @@ pub fn ellipse_pie(r: Rect, start: f64, end: f64) -> PathData {
let kind = if i == 0 || i == pieces { AnchorKind::Corner } else { AnchorKind::Smooth };
anchors.push(Anchor { p, h_in, h_out, kind });
}
let closed = mode != EllipseMode::Arc;
PathData::single(SubPath::new(anchors, closed))
}
/// Cut or open any closed single-subpath `path` around `center` from `start_deg` to `end_deg`
/// according to `mode` ([`EllipseMode::Slice`], [`EllipseMode::Arc`], [`EllipseMode::Chord`],
/// [`EllipseMode::Whole`]).
pub fn apply_shape_arc(path: PathData, center: Point, start_deg: f64, end_deg: f64, mode: EllipseMode) -> PathData {
use kurbo::{BezPath, ParamCurve};
if mode == EllipseMode::Whole || !(start_deg.is_finite() && end_deg.is_finite()) {
return path;
}
let Some(sp) = path.subpaths.first() else {
return path;
};
let n = sp.segment_count();
if !sp.closed || n == 0 {
return path;
}
let raw_span = (end_deg - start_deg).rem_euclid(360.0);
let full = raw_span < 1e-9 || 360.0 - raw_span < 1e-9;
if full {
if mode == EllipseMode::Arc {
let mut open_sp = sp.clone();
open_sp.closed = false;
return PathData::single(open_sp);
}
return path;
}
let u0 = start_deg.rem_euclid(360.0) / 360.0;
let du = (raw_span / 360.0).clamp(1e-6, 1.0 - 1e-6);
let s0 = u0 * (n as f64);
let s1 = (u0 + du) * (n as f64);
let k0 = (s0.floor() as usize).min(n.saturating_sub(1));
let t0 = (s0 - k0 as f64).clamp(0.0, 1.0);
let k1 = s1.floor() as usize;
let t1 = (s1 - k1 as f64).clamp(0.0, 1.0);
let mut cubics = Vec::new();
if k0 == k1 {
if t1 > t0 + 1e-9 {
cubics.push(sp.segment(k0 % n).subsegment(t0..t1));
}
} else {
if t0 < 1.0 - 1e-9 {
cubics.push(sp.segment(k0 % n).subsegment(t0..1.0));
}
for k in (k0 + 1)..k1 {
cubics.push(sp.segment(k % n));
}
if t1 > 1e-9 {
cubics.push(sp.segment(k1 % n).subsegment(0.0..t1));
}
}
let Some(first) = cubics.first() else {
return path;
};
let mut bp = BezPath::new();
if mode == EllipseMode::Slice {
bp.move_to(center);
bp.line_to(first.p0);
} else {
bp.move_to(first.p0);
}
for c in &cubics {
if c.p0.distance(c.p1) <= 1e-9 && c.p3.distance(c.p2) <= 1e-9 {
bp.line_to(c.p3);
} else {
bp.curve_to(c.p1, c.p2, c.p3);
}
}
if mode != EllipseMode::Arc {
bp.close_path();
}
PathData::from_bezpath(&bp)
}
/// Deterministic vertex jitter for `randomized` polygons and stars (`(dr, da)` in `[-1, 1]`).
fn vertex_jitter(index: u32, total: u32) -> (f64, f64) {
let mut s = 0x9e37_79b9_7f4a_7c15_u64
.wrapping_add((u64::from(index) + 1).wrapping_mul(0xbf58_476d_1ce4_e5b9))
.wrapping_add(u64::from(total).wrapping_mul(0x94d0_49bb_1331_11eb));
let mut next = || {
s ^= s >> 12;
s ^= s << 25;
s ^= s >> 27;
let v = s.wrapping_mul(0x2545_f491_4f6c_dd1d);
((v >> 11) as f64 / ((1u64 << 53) as f64)) * 2.0 - 1.0
};
(next(), next())
}
/// Build a closed subpath from polygon/star vertices with optional smooth bezier `rounded`
/// tangents (`-10..=10`).
fn contour_with_rounding(pts: &[Point], rounded: f64) -> PathData {
let n = pts.len();
if n == 0 || !rounded.is_finite() || rounded.abs() < 1e-9 {
return PathData::single(SubPath::polyline(pts, true));
}
let r_factor = rounded.clamp(-10.0, 10.0) * 0.4;
let mut anchors = Vec::with_capacity(n);
for i in 0..n {
let p_prev = pts[(i + n - 1) % n];
let p = pts[i];
let p_next = pts[(i + 1) % n];
let v_in = p - p_prev;
let v_out = p_next - p;
let len_in = v_in.hypot();
let len_out = v_out.hypot();
let u_in = if len_in > 1e-9 { v_in / len_in } else { Vec2::ZERO };
let u_out = if len_out > 1e-9 { v_out / len_out } else { Vec2::ZERO };
let sum = u_in + u_out;
let sum_len = sum.hypot();
let tangent = if sum_len > 1e-9 { sum / sum_len } else { Vec2::new(-u_in.y, u_in.x) };
let h_in = p - tangent * (len_in * r_factor);
let h_out = p + tangent * (len_out * r_factor);
anchors.push(Anchor { p, h_in, h_out, kind: AnchorKind::Smooth });
}
PathData::single(SubPath::new(anchors, true))
}
@@ -160,6 +309,26 @@ pub fn polygon(center: Point, radius: f64, sides: u32, rotation_deg: f64) -> Pat
PathData::single(SubPath::polyline(&pts, true))
}
/// Regular polygon with `sides` (≥ 3), bezier tangent `rounded` factor and deterministic
/// `randomized` vertex jitter.
pub fn polygon_configured(center: Point, radius: f64, sides: u32, rotation_deg: f64, rounded: f64, randomized: f64) -> PathData {
if (!rounded.is_finite() || rounded.abs() < 1e-9) && (!randomized.is_finite() || randomized.abs() < 1e-9) {
return polygon(center, radius, sides.clamp(3, 1000), rotation_deg);
}
let n = sides.clamp(3, 1000);
let rot = if rotation_deg.is_finite() { rotation_deg.to_radians() } else { 0.0 };
let rand_amt = if randomized.is_finite() { randomized.clamp(0.0, 10.0) } else { 0.0 };
let pts: Vec<Point> = (0..n)
.map(|i| {
let (dr, da) = if rand_amt > 1e-9 { vertex_jitter(i, n) } else { (0.0, 0.0) };
let r = (radius * (1.0 + rand_amt * 0.45 * dr)).max(0.01);
let a = -PI / 2.0 + rot + TAU * (i as f64 / n as f64) + rand_amt * (PI / n as f64) * da;
Point::new(center.x + r * a.cos(), center.y + r * a.sin())
})
.collect();
contour_with_rounding(&pts, rounded)
}
/// 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;
@@ -174,6 +343,27 @@ pub fn star(center: Point, r1: f64, r2: f64, points: u32, rotation_deg: f64) ->
PathData::single(SubPath::polyline(&pts, true))
}
/// Star with `points` tips (≥ 2), outer radius `r1`, inner radius `r2`, bezier tangent `rounded`
/// factor and deterministic `randomized` vertex jitter.
pub fn star_configured(center: Point, r1: f64, r2: f64, points: u32, rotation_deg: f64, rounded: f64, randomized: f64) -> PathData {
if (!rounded.is_finite() || rounded.abs() < 1e-9) && (!randomized.is_finite() || randomized.abs() < 1e-9) {
return star(center, r1, r2, points.clamp(2, 1000), rotation_deg);
}
let n = points.clamp(2, 1000) * 2;
let rot = if rotation_deg.is_finite() { rotation_deg.to_radians() } else { 0.0 };
let rand_amt = if randomized.is_finite() { randomized.clamp(0.0, 10.0) } else { 0.0 };
let pts: Vec<Point> = (0..n)
.map(|i| {
let base_r = if i % 2 == 0 { r1 } else { r2 };
let (dr, da) = if rand_amt > 1e-9 { vertex_jitter(i, n) } else { (0.0, 0.0) };
let r = (base_r * (1.0 + rand_amt * 0.45 * dr)).max(0.01);
let a = -PI / 2.0 + rot + TAU * (i as f64 / n as f64) + rand_amt * (PI / n as f64) * da;
Point::new(center.x + r * a.cos(), center.y + r * a.sin())
})
.collect();
contour_with_rounding(&pts, rounded)
}
/// 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;
@@ -249,6 +439,137 @@ pub fn spiral(center: Point, radius: f64, decay_percent: f64, segments: u32, clo
PathData::single(sp)
}
/// Configurable spiral with `turns` revolutions (`0.25..=50.0`), `divergence` radial growth power
/// (`0.05..=20.0`, `1.0` = uniform Archimedean spacing) and `inner_radius` fraction (`0.0..=0.99`).
pub fn spiral_configured(center: Point, radius: f64, turns: f64, divergence: f64, inner_radius: f64, clockwise: bool) -> PathData {
let turns = if turns.is_finite() { turns.clamp(0.25, 50.0) } else { 3.0 };
let div = if divergence.is_finite() { divergence.clamp(0.05, 20.0) } else { 1.0 };
let inner_frac = if inner_radius.is_finite() { inner_radius.clamp(0.0, 0.99) } else { 0.0 };
let r_outer = if radius.is_finite() { radius.abs().max(0.01) } else { 50.0 };
let r_inner = r_outer * inner_frac;
let steps = (turns * 4.0).round().clamp(1.0, 200.0) as usize;
let dir = if clockwise { 1.0 } else { -1.0 };
let total_angle = dir * turns * TAU;
let dtheta = (turns * TAU) / (steps as f64);
let k = if dtheta > 1e-6 { KAPPA / dtheta } else { KAPPA };
let mut anchors = Vec::with_capacity(steps + 1);
for i in 0..=steps {
let u = (i as f64) / (steps as f64);
let theta = u * total_angle;
let f = if u <= 0.0 { 0.0 } else { u.powf(div) };
let r = r_inner + (r_outer - r_inner) * f;
let p = Point::new(center.x + r * theta.cos(), center.y + r * theta.sin());
let dr_du = if u > 1e-9 {
(r_outer - r_inner) * div * u.powf(div - 1.0)
} else if (div - 1.0).abs() < 1e-9 {
r_outer - r_inner
} else {
0.0
};
let dx_du = dr_du * theta.cos() - r * theta.sin() * total_angle;
let dy_du = dr_du * theta.sin() + r * theta.cos() * total_angle;
let d = Vec2::new(dx_du, dy_du) * (k / (steps as f64));
let h_in = if i == 0 { p } else { p - d };
let h_out = if i == steps { p } else { p + d };
anchors.push(Anchor { p, h_in, h_out, kind: AnchorKind::Smooth });
}
PathData::single(SubPath::new(anchors, false))
}
/// Rotatable 3D box inscribed in `r`, rotated by `(angle_x, angle_y, angle_z)` degrees and using
/// orthographic parallel projection per axis when `parallel_*` is true or perspective foreshortening
/// when false. Emits the visible front-facing quad faces with clockwise winding.
pub fn box3d(r: Rect, angle_x: f64, angle_y: f64, angle_z: f64, parallel_x: bool, parallel_y: bool, parallel_z: bool) -> PathData {
let r = r.abs();
if r.width() <= 1e-9 || r.height() <= 1e-9 {
return rectangle(r);
}
let rx = if angle_x.is_finite() { angle_x.to_radians() } else { 30.0_f64.to_radians() };
let ry = if angle_y.is_finite() { angle_y.to_radians() } else { 35.0_f64.to_radians() };
let rz = if angle_z.is_finite() { angle_z.to_radians() } else { 0.0 };
let (sx, cx) = rx.sin_cos();
let (sy, cy) = ry.sin_cos();
let (sz, cz) = rz.sin_cos();
let transform_pt = |mut x: f64, mut y: f64, mut z: f64| -> (f64, f64, f64) {
if !parallel_x {
let k = 1.0 - 0.22 * x;
y *= k;
z *= k;
}
if !parallel_y {
let k = 1.0 - 0.22 * y;
x *= k;
z *= k;
}
// Rotate around X
let (y1, z1) = (y * cx - z * sx, y * sx + z * cx);
// Rotate around Y
let (x2, z2) = (x * cy + z1 * sy, -x * sy + z1 * cy);
// Rotate around Z
let (x3, y3) = (x2 * cz - y1 * sz, x2 * sz + y1 * cz);
let persp = if parallel_z { 1.0 } else { 1.0 / (1.0 - 0.25 * z2).max(0.2) };
(x3 * persp, y3 * persp, z2)
};
let raw_verts: [(f64, f64, f64); 8] = [
transform_pt(-1.0, -1.0, -1.0),
transform_pt(1.0, -1.0, -1.0),
transform_pt(1.0, 1.0, -1.0),
transform_pt(-1.0, 1.0, -1.0),
transform_pt(-1.0, -1.0, 1.0),
transform_pt(1.0, -1.0, 1.0),
transform_pt(1.0, 1.0, 1.0),
transform_pt(-1.0, 1.0, 1.0),
];
let mut min_x = f64::INFINITY;
let mut max_x = f64::NEG_INFINITY;
let mut min_y = f64::INFINITY;
let mut max_y = f64::NEG_INFINITY;
for &(vx, vy, _) in &raw_verts {
min_x = min_x.min(vx);
max_x = max_x.max(vx);
min_y = min_y.min(vy);
max_y = max_y.max(vy);
}
let span_x = (max_x - min_x).max(1e-6);
let span_y = (max_y - min_y).max(1e-6);
let proj = |i: usize| -> Point {
let (vx, vy, _) = raw_verts[i];
Point::new(r.x0 + (vx - min_x) / span_x * r.width(), r.y0 + (vy - min_y) / span_y * r.height())
};
// 6 cube faces with outward-pointing vertex order in 3D.
const FACES: [[usize; 4]; 6] = [
[4, 5, 6, 7], // +Z front
[1, 0, 3, 2], // -Z back
[0, 4, 7, 3], // -X left
[5, 1, 2, 6], // +X right
[0, 1, 5, 4], // -Y top
[7, 6, 2, 3], // +Y bottom
];
let mut faces_with_depth: Vec<(f64, SubPath)> = Vec::new();
for idx in FACES {
let z_avg = (raw_verts[idx[0]].2 + raw_verts[idx[1]].2 + raw_verts[idx[2]].2 + raw_verts[idx[3]].2) * 0.25;
let mut pts = [proj(idx[0]), proj(idx[1]), proj(idx[2]), proj(idx[3])];
let mut sp = SubPath::polyline(&pts, true);
let area = sp.signed_area();
// Outward face in camera space has positive signed area (clockwise on screen, y down).
if area > 1e-4 {
faces_with_depth.push((z_avg, sp));
} else if faces_with_depth.is_empty() && area.abs() > 1e-4 {
pts.reverse();
sp = SubPath::polyline(&pts, true);
faces_with_depth.push((z_avg, sp));
}
}
faces_with_depth.sort_by(|a, b| a.0.total_cmp(&b.0));
let subpaths: Vec<SubPath> = faces_with_depth.into_iter().map(|(_, sp)| sp).collect();
if subpaths.is_empty() { rectangle(r) } else { PathData::new(subpaths) }
}
/// The largest grid divider skew, in percent either way.
pub const GRID_SKEW_MAX: f64 = 500.0;
@@ -628,4 +949,41 @@ mod tests {
let seg = arc_segment(Point::ZERO, 0, 100.0, 50.0, true, 50.0, false);
assert_eq!(seg.anchor_count(), 2);
}
#[test]
fn ellipse_arc_modes_and_configured_shapes() {
let r = Rect::new(0.0, 0.0, 100.0, 100.0);
let slice = ellipse_arc(r, 0.0, 90.0, EllipseMode::Slice);
assert!(slice.is_closed() && slice.anchor_count() == 3);
assert_eq!(slice, ellipse_pie(r, 0.0, 90.0), "ellipse_pie matches Slice mode");
let chord = ellipse_arc(r, 0.0, 90.0, EllipseMode::Chord);
assert!(chord.is_closed() && chord.anchor_count() == 2);
let open_arc = ellipse_arc(r, 0.0, 90.0, EllipseMode::Arc);
assert!(!open_arc.is_closed() && open_arc.anchor_count() == 2);
let whole = ellipse_arc(r, 0.0, 90.0, EllipseMode::Whole);
assert_eq!(whole, ellipse(r));
// Rectangle opened/cut with apply_shape_arc
let rect_open = apply_shape_arc(rectangle(r), r.center(), 30.0, 300.0, EllipseMode::Arc);
assert!(!rect_open.is_closed());
let rect_slice = apply_shape_arc(rectangle(r), r.center(), 30.0, 300.0, EllipseMode::Slice);
assert!(rect_slice.is_closed() && rect_slice.subpaths[0].anchors[0].p == r.center());
// Polygon and Star with rounded and randomized, and unconfigured parity
assert_eq!(polygon_configured(Point::ZERO, 50.0, 6, 0.0, 0.0, 0.0), polygon(Point::ZERO, 50.0, 6, 0.0));
assert_eq!(star_configured(Point::ZERO, 50.0, 25.0, 5, 0.0, 0.0, 0.0), star(Point::ZERO, 50.0, 25.0, 5, 0.0));
let poly_round = polygon_configured(Point::ZERO, 50.0, 6, 0.0, 0.5, 0.1);
assert_eq!(poly_round.anchor_count(), 6);
assert!(poly_round.subpaths[0].anchors.iter().all(|a| a.has_in() && a.has_out()));
let star_round = star_configured(Point::ZERO, 50.0, 25.0, 5, 0.0, 0.5, 0.1);
assert_eq!(star_round.anchor_count(), 10);
// Spiral configured
let sp = spiral_configured(Point::ZERO, 100.0, 3.0, 1.5, 0.25, true);
assert!(!sp.is_closed() && sp.anchor_count() == 13);
// 3D Box
let b3d = box3d(r, 30.0, 35.0, 0.0, true, true, true);
assert!(!b3d.subpaths.is_empty() && b3d.subpaths.iter().all(|s| s.closed && s.anchors.len() == 4));
}
}
+7 -2
View File
@@ -3,6 +3,7 @@
//! submenu selects them. Tool gestures emit engine commands.
pub mod anchor_eraser;
pub mod box3d;
pub mod contour;
pub mod corner_tool;
pub mod enclosing_lasso;
@@ -33,13 +34,15 @@ pub const CONTOUR: &[ToolInfo] = &[ToolInfo { id: contour::ID, label: "Contour T
pub const ENCLOSING_LASSO: &[ToolInfo] = &[ToolInfo { id: enclosing_lasso::ID, label: "Enclosing Lasso Tool", shortcut: None, icon: "tool-lasso" }];
pub const BOX_3D: &[ToolInfo] = &[ToolInfo { id: box3d::ID, label: "3D Box Tool", shortcut: None, icon: "box" }];
/// The add-ons' tool groups, after the toolbar's own ([`crate::TOOL_GROUPS`]), each with the
/// heading the All Tools drawer lists it under (its add-on's name).
const HEADED: &[(&[ToolInfo], &str)] =
&[(PATH_TOOLS, "Path Precision Tools"), (SMART_PEN, "Smart Pen"), (ENCLOSING_LASSO, "Enclosing Lasso"), (CONTOUR, "Contour")];
&[(PATH_TOOLS, "Path Precision Tools"), (SMART_PEN, "Smart Pen"), (ENCLOSING_LASSO, "Enclosing Lasso"), (CONTOUR, "Contour"), (BOX_3D, "3D Box")];
/// The add-ons' tool groups, after the toolbar's own ([`crate::TOOL_GROUPS`]).
pub const GROUPS: &[&[ToolInfo]] = &[PATH_TOOLS, SMART_PEN, ENCLOSING_LASSO, CONTOUR];
pub const GROUPS: &[&[ToolInfo]] = &[PATH_TOOLS, SMART_PEN, ENCLOSING_LASSO, CONTOUR, BOX_3D];
/// The heading the All Tools drawer lists tool `id` under: its add-on's name.
pub fn heading(id: &str) -> Option<&'static str> {
@@ -59,6 +62,7 @@ pub fn create(id: &str) -> Option<Box<dyn Tool>> {
smart_pen::ID => Box::new(smart_pen::SmartPenTool::default()),
enclosing_lasso::ID => Box::new(enclosing_lasso::EnclosingLassoTool::default()),
contour::ID => Box::new(contour::ContourTool::default()),
box3d::ID => Box::new(box3d::Box3dTool::default()),
_ => return None,
})
}
@@ -80,6 +84,7 @@ mod tests {
assert_eq!(heading(smart_pen::ID), Some("Smart Pen"));
assert_eq!(heading(enclosing_lasso::ID), Some("Enclosing Lasso"));
assert_eq!(heading(contour::ID), Some("Contour"));
assert_eq!(heading(box3d::ID), Some("3D Box"));
assert_eq!(GROUPS.len(), HEADED.len());
assert!(crate::catalog::all_tools().any(|t| t.id == extend_path::ID));
assert!(create("pen").is_none() && heading("pen").is_none());
+732
View File
@@ -0,0 +1,732 @@
//! 3D Box tool (`box3d`) and customizable shape state (`vc-shape-tools`), a built-in add-on.
//!
//! Dragging with the 3D Box tool draws a rotatable 3D box (`addon.shapes.box3d`) whose X, Y and Z
//! rotation angles and parallel/perspective projection flags are configurable in the Control bar
//! (`addon.shapes.configure` and `tool.setOption`). Extended parameters for rectangles, ellipses,
//! polygons, stars, spirals and 3D boxes are stored in the object's `vc-shape-tools` attribute
//! entry (left out of SVG export) while keeping standard `LiveShape` variants synced.
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use vectorcraft_doc::{LiveShape, Node, NodeKind};
use vectorcraft_geom::corners::cut_corners;
use vectorcraft_geom::shapes::{self, CornerKind, EllipseMode};
use vectorcraft_geom::{Affine, PathData, Point, Rect, Vec2};
use crate::guides::{DrawSnap, Leave};
use crate::shape::{box_params, space_moves, turned_box};
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
/// The 3D Box tool's id.
pub const ID: &str = "box3d";
/// Object data entry (`Node::extras`) that stores extended customizable shape parameters.
/// The `vc-` prefix marks it as internal so SVG export omits it from `data-*` attributes.
pub const KEY: &str = "vc-shape-tools";
/// Maximum stored JSON length read back from a file.
const MAX_STORED: usize = 4096;
fn whole_mode() -> EllipseMode {
EllipseMode::Whole
}
fn default_pie() -> (f64, f64) {
(0.0, 360.0)
}
/// Parse an [`EllipseMode`] from a command or tool option string (`"slice"`/`"pie"`, `"arc"`/`"open"`,
/// `"chord"`, `"whole"`/`"full"`/`"closed"`).
pub fn parse_arc_mode(s: &str) -> Option<EllipseMode> {
match s.trim().to_ascii_lowercase().as_str() {
"slice" | "pie" => Some(EllipseMode::Slice),
"arc" | "open" => Some(EllipseMode::Arc),
"chord" => Some(EllipseMode::Chord),
"whole" | "full" | "closed" => Some(EllipseMode::Whole),
_ => None,
}
}
/// Canonical string name for an [`EllipseMode`].
pub fn arc_mode_str(m: EllipseMode) -> &'static str {
match m {
EllipseMode::Slice => "slice",
EllipseMode::Arc => "arc",
EllipseMode::Chord => "chord",
EllipseMode::Whole => "whole",
}
}
/// Customizable shape state for rectangles, ellipses, polygons, stars, spirals and 3D boxes.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "shape", rename_all = "camelCase")]
pub enum CustomShape {
Rectangle {
w: f64,
h: f64,
radii: [f64; 4],
#[serde(default)]
kinds: [CornerKind; 4],
#[serde(default)]
radii_relative: bool,
xf: Affine,
#[serde(default = "default_pie")]
pie: (f64, f64),
#[serde(default = "whole_mode")]
arc_mode: EllipseMode,
},
Ellipse {
w: f64,
h: f64,
#[serde(default = "default_pie")]
pie: (f64, f64),
#[serde(default)]
arc_mode: EllipseMode,
xf: Affine,
},
Polygon {
radius: f64,
sides: u32,
xf: Affine,
#[serde(default)]
radii: Vec<f64>,
#[serde(default)]
kinds: Vec<CornerKind>,
#[serde(default)]
radii_relative: bool,
#[serde(default)]
spoke_ratio: Option<f64>,
#[serde(default)]
rounded: f64,
#[serde(default)]
randomized: f64,
#[serde(default = "default_pie")]
pie: (f64, f64),
#[serde(default = "whole_mode")]
arc_mode: EllipseMode,
},
Spiral {
center: Point,
radius: f64,
turns: f64,
divergence: f64,
inner_radius: f64,
clockwise: bool,
},
Box3d {
w: f64,
h: f64,
angle_x: f64,
angle_y: f64,
angle_z: f64,
parallel_x: bool,
parallel_y: bool,
parallel_z: bool,
xf: Affine,
},
}
impl CustomShape {
/// Regenerate the path for this customizable shape.
pub fn to_path(&self) -> PathData {
match self {
CustomShape::Rectangle { w, h, radii, kinds, radii_relative, xf, pie, arc_mode } => {
let base = shapes::rectangle(Rect::new(0.0, 0.0, *w, *h));
let resolved = vectorcraft_doc::corners::resolve_stored_radii(&base, radii, *radii_relative);
let r = std::array::from_fn(|i| resolved.get(i).copied().unwrap_or(0.0));
let rect_path = shapes::rectangle_with_corners(Rect::new(0.0, 0.0, *w, *h), r, *kinds);
let cut = shapes::apply_shape_arc(rect_path, Point::new(*w / 2.0, *h / 2.0), pie.0, pie.1, *arc_mode);
cut.transformed(*xf)
}
CustomShape::Ellipse { w, h, pie, arc_mode, xf } => {
shapes::ellipse_arc(Rect::new(0.0, 0.0, *w, *h), pie.0, pie.1, *arc_mode).transformed(*xf)
}
CustomShape::Polygon { radius, sides, xf, radii, kinds, radii_relative, spoke_ratio, rounded, randomized, pie, arc_mode } => {
let raw = match spoke_ratio {
Some(ratio) => {
let r2 = (*radius * ratio.clamp(0.01, 1.0)).max(0.01);
shapes::star_configured(Point::ZERO, *radius, r2, *sides, 0.0, *rounded, *randomized)
}
None => shapes::polygon_configured(Point::ZERO, *radius, *sides, 0.0, *rounded, *randomized),
};
let outline = raw.transformed(*xf);
let cornered = if rounded.abs() < 1e-9 && !radii.is_empty() {
let resolved = vectorcraft_doc::corners::resolve_stored_radii(&outline, radii, *radii_relative);
cut_corners(&outline, &resolved, kinds).0
} else {
outline
};
let center = *xf * Point::ZERO;
shapes::apply_shape_arc(cornered, center, pie.0, pie.1, *arc_mode)
}
CustomShape::Spiral { center, radius, turns, divergence, inner_radius, clockwise } => {
shapes::spiral_configured(*center, *radius, *turns, *divergence, *inner_radius, *clockwise)
}
CustomShape::Box3d { w, h, angle_x, angle_y, angle_z, parallel_x, parallel_y, parallel_z, xf } => {
shapes::box3d(Rect::new(0.0, 0.0, *w, *h), *angle_x, *angle_y, *angle_z, *parallel_x, *parallel_y, *parallel_z).transformed(*xf)
}
}
}
/// Standard [`LiveShape`] representation when the shape is in a state that `LiveShape` natively
/// models (so Live Corners and Pie widgets work on it directly).
pub fn to_live_shape(&self) -> Option<LiveShape> {
match self {
CustomShape::Rectangle { w, h, radii, kinds, radii_relative, xf, arc_mode, .. } if *arc_mode == EllipseMode::Whole => {
Some(LiveShape::Rectangle { w: *w, h: *h, radii: *radii, kinds: *kinds, radii_relative: *radii_relative, xf: *xf })
}
CustomShape::Ellipse { w, h, pie, arc_mode, xf } if matches!(arc_mode, EllipseMode::Whole | EllipseMode::Slice) => {
let p = if *arc_mode == EllipseMode::Whole { (0.0, 360.0) } else { *pie };
Some(LiveShape::Ellipse { w: *w, h: *h, pie: p, xf: *xf })
}
CustomShape::Polygon { radius, sides, xf, radii, kinds, radii_relative, spoke_ratio, rounded, randomized, arc_mode, .. }
if spoke_ratio.is_none() && rounded.abs() < 1e-9 && randomized.abs() < 1e-9 && *arc_mode == EllipseMode::Whole =>
{
Some(LiveShape::Polygon {
radius: *radius,
sides: *sides,
xf: *xf,
radii: radii.clone(),
kinds: kinds.clone(),
radii_relative: *radii_relative,
})
}
_ => None,
}
}
/// Whether this shape uses extended add-on parameters beyond what `LiveShape` stores on its own.
pub fn needs_extra_storage(&self) -> bool {
self.to_live_shape().is_none()
}
/// Write this shape's regenerated path, `LiveShape` (if standard) and `vc-shape-tools` attribute
/// entry onto path node `n`.
pub fn write_to_node(&self, n: &mut Node) {
let new_path = self.to_path();
let new_live = self.to_live_shape().map(Box::new);
if let NodeKind::Path { path, live, .. } = &mut n.kind {
*path = new_path;
*live = new_live;
}
if self.needs_extra_storage() {
if let Ok(entry) = serde_json::to_string(self) {
n.edit_attrs(|a| match a.data.iter_mut().find(|(k, _)| k == KEY) {
Some(e) => e.1 = entry,
None => a.data.push((KEY.into(), entry)),
});
}
} else {
n.edit_attrs(|a| a.data.retain(|(k, _)| k != KEY));
}
}
/// Read a [`CustomShape`] from `n`: either from its `vc-shape-tools` attribute entry (synced with
/// `LiveShape` if both exist), from its `LiveShape`, or by detecting a star path.
pub fn read(n: &Node) -> Option<Self> {
let NodeKind::Path { path, live, guide: false, .. } = &n.kind else { return None };
let stored = n
.extras()
.attrs
.as_deref()
.and_then(|a| a.data.iter().find(|(k, _)| k == KEY))
.filter(|(_, text)| text.len() <= MAX_STORED)
.and_then(|(_, text)| serde_json::from_str::<CustomShape>(text).ok());
match (stored, live.as_deref()) {
(Some(s), None) => Some(s),
(stored_opt, Some(l)) => Self::from_live_shape(l, stored_opt),
(None, None) => Self::detect_star_from_path(path),
}
}
/// Read a [`CustomShape`] from `n`, or upgrade an open spiral path when spiral parameters are
/// being configured.
pub fn read_for_update(n: &Node, spiral_hint: bool) -> Option<Self> {
if let Some(cs) = Self::read(n) {
return Some(cs);
}
if spiral_hint
&& let NodeKind::Path { path, live: None, guide: false, .. } = &n.kind
&& let Some(sp) = path.subpaths.first()
&& !sp.closed
&& sp.anchors.len() >= 3
&& let Some(last) = sp.anchors.last()
&& let Some(first) = sp.anchors.first()
{
let center = last.p;
let radius = first.p.distance(center).max(1.0);
return Some(CustomShape::Spiral {
center,
radius,
turns: ((sp.anchors.len().saturating_sub(1)) as f64 / 4.0).max(0.25),
divergence: 1.0,
inner_radius: 0.0,
clockwise: true,
});
}
None
}
fn from_live_shape(l: &LiveShape, stored: Option<Self>) -> Option<Self> {
match l.folded() {
LiveShape::Rectangle { w, h, radii, kinds, radii_relative, xf } => {
let (pie, arc_mode) = match stored {
Some(CustomShape::Rectangle { pie, arc_mode, .. }) => (pie, arc_mode),
_ => ((0.0, 360.0), EllipseMode::Whole),
};
Some(CustomShape::Rectangle { w, h, radii, kinds, radii_relative, xf, pie, arc_mode })
}
LiveShape::Ellipse { w, h, pie, xf } => {
let span = (pie.1 - pie.0).rem_euclid(360.0);
let default_mode = if span < 1e-9 || 360.0 - span < 1e-9 { EllipseMode::Whole } else { EllipseMode::Slice };
let arc_mode = match stored {
Some(CustomShape::Ellipse { arc_mode, .. }) if default_mode != EllipseMode::Slice => arc_mode,
_ => default_mode,
};
Some(CustomShape::Ellipse { w, h, pie, arc_mode, xf })
}
LiveShape::Polygon { radius, sides, xf, radii, kinds, radii_relative } => {
let (spoke_ratio, rounded, randomized, pie, arc_mode) = match stored {
Some(CustomShape::Polygon { spoke_ratio, rounded, randomized, pie, arc_mode, .. }) => {
(spoke_ratio, rounded, randomized, pie, arc_mode)
}
_ => (None, 0.0, 0.0, (0.0, 360.0), EllipseMode::Whole),
};
Some(CustomShape::Polygon { radius, sides, xf, radii, kinds, radii_relative, spoke_ratio, rounded, randomized, pie, arc_mode })
}
LiveShape::Line { .. } | LiveShape::Path { .. } => stored,
}
}
/// Detect a regular star path (even vertex count ≥ 6 with alternating outer/inner radii around
/// its centroid) so plain stars created by `shape.star` can be customized live.
pub fn detect_star_from_path(path: &PathData) -> Option<Self> {
let sp = path.subpaths.first()?;
let n = sp.anchors.len();
if path.subpaths.len() != 1 || !sp.closed || n < 6 || !n.is_multiple_of(2) {
return None;
}
let mut cx = 0.0;
let mut cy = 0.0;
for a in &sp.anchors {
cx += a.p.x;
cy += a.p.y;
}
let center = Point::new(cx / (n as f64), cy / (n as f64));
let r0 = sp.anchors.first()?.p.distance(center);
let r1 = sp.anchors.get(1)?.p.distance(center);
let (r_outer, r_inner) = if r0 >= r1 { (r0, r1) } else { (r1, r0) };
if r_outer <= 1e-3 || (r_outer - r_inner).abs() <= 1e-3 * r_outer {
return None;
}
for (i, a) in sp.anchors.iter().enumerate() {
let d = a.p.distance(center);
let target = if i % 2 == 0 { r0 } else { r1 };
if (d - target).abs() > 0.15 * r_outer {
return None;
}
}
let ratio = (r_inner / r_outer).clamp(0.01, 1.0);
let first = sp.anchors.first()?.p - center;
let rot = first.y.atan2(first.x) + std::f64::consts::FRAC_PI_2;
Some(CustomShape::Polygon {
radius: r_outer,
sides: (n / 2) as u32,
xf: Affine::translate((center.x, center.y)) * Affine::rotate(rot),
radii: vec![],
kinds: vec![],
radii_relative: false,
spoke_ratio: Some(ratio),
rounded: 0.0,
randomized: 0.0,
pie: (0.0, 360.0),
arc_mode: EllipseMode::Whole,
})
}
}
/// The customizable shapes & rotatable 3D Box add-on tool (`box3d`).
pub struct Box3dTool {
start: Option<Point>,
last: Point,
mods: Mods,
began: bool,
pub shape: &'static str,
pub corner_radius: f64,
pub corner_radii: [f64; 4],
pub corner_symmetry: bool,
pub arc_start: f64,
pub arc_end: f64,
pub arc_mode: &'static str,
pub sides: u32,
pub points: u32,
pub poly_is_star: bool,
pub star_ratio: f64,
pub rounded: f64,
pub randomized: f64,
pub turns: f64,
pub divergence: f64,
pub inner_radius: f64,
pub clockwise: bool,
pub angle_x: f64,
pub angle_y: f64,
pub angle_z: f64,
pub parallel_x: bool,
pub parallel_y: bool,
pub parallel_z: bool,
snap: DrawSnap,
}
impl Default for Box3dTool {
fn default() -> Self {
Self {
start: None,
last: Point::ZERO,
mods: Mods::default(),
began: false,
shape: "box3d",
corner_radius: 12.0,
corner_radii: [12.0; 4],
corner_symmetry: true,
arc_start: 0.0,
arc_end: 0.0,
arc_mode: "whole",
sides: 6,
points: 5,
poly_is_star: false,
star_ratio: 0.5,
rounded: 0.0,
randomized: 0.0,
turns: 3.0,
divergence: 1.0,
inner_radius: 0.0,
clockwise: true,
angle_x: 30.0,
angle_y: 35.0,
angle_z: 0.0,
parallel_x: true,
parallel_y: true,
parallel_z: true,
snap: DrawSnap::default(),
}
}
}
impl Box3dTool {
pub fn command_at(&self, start: Point, p: Point, m: Mods, base: f64) -> (String, Value) {
let mut out = match self.shape {
"rectangle" => {
let mut v = box_params(start, p, m, base);
if self.corner_symmetry {
v["radius"] = json!(self.corner_radius);
} else {
v["radii"] = json!(self.corner_radii);
}
("addon.shapes.rectangle".into(), v)
}
"ellipse" => {
let v = box_params(start, p, m, base);
("addon.shapes.ellipse".into(), v)
}
"polygon" | "star" => {
let d = p - start;
let r = d.hypot().max(0.01);
let rot = if m.shift { vectorcraft_geom::constrain_rotation(base).unwrap_or(0.0) } else { (d.atan2().to_degrees() + 90.0) % 360.0 };
let is_star = self.shape == "star" || self.poly_is_star;
let count = if is_star { self.points } else { self.sides };
let mut v = if is_star {
json!({
"cx": start.x,
"cy": start.y,
"radius1": r,
"radius2": r * self.star_ratio,
"points": count,
"rotation": rot,
})
} else {
json!({
"cx": start.x,
"cy": start.y,
"radius": r,
"sides": count,
"rotation": rot,
})
};
if self.rounded.abs() > 1e-6 {
v["rounded"] = json!(self.rounded);
}
if self.randomized.abs() > 1e-6 {
v["randomized"] = json!(self.randomized);
}
(if is_star { "addon.shapes.star" } else { "addon.shapes.polygon" }.into(), v)
}
"spiral" => {
let r = p.distance(start).max(0.01);
(
"addon.shapes.spiral".into(),
json!({
"cx": start.x,
"cy": start.y,
"radius": r,
"turns": self.turns,
"divergence": self.divergence,
"innerRadius": self.inner_radius,
"clockwise": self.clockwise,
}),
)
}
_ => {
let mut v = box_params(start, p, m, base);
v["angleX"] = json!(self.angle_x);
v["angleY"] = json!(self.angle_y);
v["angleZ"] = json!(self.angle_z);
v["parallelX"] = json!(self.parallel_x);
v["parallelY"] = json!(self.parallel_y);
v["parallelZ"] = json!(self.parallel_z);
return ("addon.shapes.box3d".into(), v);
}
};
if matches!(self.shape, "rectangle" | "ellipse" | "polygon" | "star") {
let span = (self.arc_end - self.arc_start).rem_euclid(360.0);
if self.arc_mode != "whole" || (span > 1e-4 && (span - 360.0).abs() > 1e-4) {
out.1["startAngle"] = json!(self.arc_start);
out.1["endAngle"] = json!(self.arc_end);
out.1["arcMode"] = json!(self.arc_mode);
}
}
out
}
fn undo_label(&self) -> &'static str {
match self.shape {
"rectangle" => "Rounded Rectangle",
"ellipse" => "Ellipse",
"polygon" => "Polygon",
"star" => "Star",
"spiral" => "Spiral",
_ => "3D Box",
}
}
}
impl Tool for Box3dTool {
fn id(&self) -> &'static str {
ID
}
fn busy(&self) -> bool {
self.start.is_some()
}
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
match ev.kind {
PointerKind::Move => {
self.snap.hover(cx, ev.pos, &[], None);
vec![]
}
PointerKind::Down => {
let p = self.snap.press(cx, ev.pos, &[], None);
self.start = Some(p);
self.last = ev.pos;
self.began = false;
vec![]
}
PointerKind::Drag => {
let Some(mut s) = self.start else { return vec![] };
let radial = matches!(self.shape, "polygon" | "star" | "spiral");
let leave = (!radial).then(|| Leave::diagonal(s, ev.mods.shift && vectorcraft_geom::constrain_axes(cx.constrain_angle).is_none()));
let pos = self.snap.drag(cx, ev.pos, leave.as_ref());
let ev = &PointerEvent { pos, ..*ev };
space_moves(&mut s, &mut self.last, ev, self.began);
self.start = Some(s);
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.undo_label().into()));
}
let (c, v) = self.command_at(s, ev.pos, ev.mods, cx.constrain_angle);
out.push(Action::Preview(c, v));
out
}
PointerKind::Up => {
let Some(s) = self.start.take() else { return vec![] };
self.snap.clear();
if self.began {
self.began = false;
vec![Action::Commit]
} else {
let (c, v) = self.command_at(s, s + Vec2::new(100.0, 100.0), ev.mods, cx.constrain_angle);
vec![Action::Begin(self.undo_label().into()), Action::Preview(c, v), Action::Commit]
}
}
_ => 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.shape, 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
}
(_, ToolKey::Up) => {
self.angle_x = (self.angle_x + 5.0).clamp(-180.0, 180.0);
true
}
(_, ToolKey::Down) => {
self.angle_x = (self.angle_x - 5.0).clamp(-180.0, 180.0);
true
}
(_, ToolKey::Right) => {
self.angle_y = (self.angle_y + 5.0).clamp(-180.0, 180.0);
true
}
(_, ToolKey::Left) => {
self.angle_y = (self.angle_y - 5.0).clamp(-180.0, 180.0);
true
}
_ => false,
};
if changed {
let (c, v) = self.command_at(s, self.last, self.mods, cx.constrain_angle);
vec![Action::Preview(c, v)]
} else if key == ToolKey::Escape {
self.start = None;
self.began = false;
self.snap.clear();
vec![Action::Cancel]
} else {
vec![]
}
}
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
let mut o = self.snap.guides().to_vec();
if let Some(s) = self.start.filter(|_| self.began && cx.measurement_labels) {
let p = self.last + Vec2::new(cx.tol(12.0), cx.tol(12.0));
let (r, _) = turned_box(s, self.last, self.mods, cx.constrain_angle);
o.push(Overlay::Measure { p, text: cx.size_label(r.width(), r.height()) });
}
o
}
fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor {
Cursor::Crosshair
}
fn options(&self) -> Value {
json!({
"shape": self.shape,
"cornerRadius": self.corner_radius,
"cornerRadii": self.corner_radii,
"cornerSymmetry": self.corner_symmetry,
"arcStart": self.arc_start,
"arcEnd": self.arc_end,
"arcMode": self.arc_mode,
"sides": self.sides,
"points": self.points,
"polyIsStar": self.poly_is_star,
"starRatio": self.star_ratio,
"rounded": self.rounded,
"randomized": self.randomized,
"turns": self.turns,
"divergence": self.divergence,
"innerRadius": self.inner_radius,
"clockwise": self.clockwise,
"angleX": self.angle_x,
"angleY": self.angle_y,
"angleZ": self.angle_z,
"parallelX": self.parallel_x,
"parallelY": self.parallel_y,
"parallelZ": self.parallel_z,
})
}
fn set_option(&mut self, key: &str, v: &Value) {
let finite = v.as_f64().filter(|n| n.is_finite());
match key {
"shape" => {
if let Some(s) = v.as_str() {
self.shape = match s {
"rectangle" => "rectangle",
"ellipse" => "ellipse",
"polygon" => "polygon",
"star" => "star",
"spiral" => "spiral",
_ => "box3d",
};
}
}
"cornerRadius" => {
self.corner_radius = finite.unwrap_or(self.corner_radius).clamp(0.0, crate::shape::MAX_CORNER_RADIUS);
if self.corner_symmetry {
self.corner_radii = [self.corner_radius; 4];
}
}
"cornerSymmetry" => {
self.corner_symmetry = v.as_bool().unwrap_or(self.corner_symmetry);
if self.corner_symmetry {
self.corner_radii = [self.corner_radius; 4];
}
}
"cornerRadii" => {
if let Some(arr) = v.as_array()
&& arr.len() == 4
{
for (i, item) in arr.iter().enumerate() {
if let Some(r) = item.as_f64().filter(|n| n.is_finite()) {
self.corner_radii[i] = r.clamp(0.0, crate::shape::MAX_CORNER_RADIUS);
}
}
self.corner_radius = self.corner_radii[0];
}
}
"arcStart" => self.arc_start = finite.map_or(self.arc_start, |n| n.rem_euclid(360.0)),
"arcEnd" => self.arc_end = finite.map_or(self.arc_end, |n| n.rem_euclid(360.0)),
"arcMode" => {
if let Some(s) = v.as_str() {
self.arc_mode = match s {
"pie" => "pie",
"chord" => "chord",
"arc" => "arc",
_ => "whole",
};
}
}
"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,
"polyIsStar" => self.poly_is_star = v.as_bool().unwrap_or(self.poly_is_star),
"starRatio" => self.star_ratio = finite.unwrap_or(self.star_ratio).clamp(0.01, 1.0),
"rounded" => self.rounded = finite.unwrap_or(self.rounded).clamp(-10.0, 10.0),
"randomized" => self.randomized = finite.unwrap_or(self.randomized).clamp(-10.0, 10.0),
"turns" => self.turns = finite.unwrap_or(self.turns).clamp(0.01, 1024.0),
"divergence" => self.divergence = finite.unwrap_or(self.divergence).clamp(0.0, 1000.0),
"innerRadius" => self.inner_radius = finite.unwrap_or(self.inner_radius).clamp(0.0, 0.999),
"clockwise" => self.clockwise = v.as_bool().unwrap_or(self.clockwise),
"angleX" => self.angle_x = finite.unwrap_or(self.angle_x).clamp(-180.0, 180.0),
"angleY" => self.angle_y = finite.unwrap_or(self.angle_y).clamp(-180.0, 180.0),
"angleZ" => self.angle_z = finite.unwrap_or(self.angle_z).clamp(-180.0, 180.0),
"parallelX" => self.parallel_x = v.as_bool().unwrap_or(self.parallel_x),
"parallelY" => self.parallel_y = v.as_bool().unwrap_or(self.parallel_y),
"parallelZ" => self.parallel_z = v.as_bool().unwrap_or(self.parallel_z),
_ => {}
}
}
}
+2
View File
@@ -10,6 +10,7 @@ pub mod enclosing_lasso;
pub mod pathtools;
pub mod pen;
pub mod select;
pub mod shapes;
pub mod typetools;
use crate::menus::Item;
@@ -17,6 +18,7 @@ use crate::menus::Item;
/// The add-ons' submenus, then a separator: the head of Object › Plug-ins.
pub fn plugin_menu() -> Vec<Item> {
vec![
Item::Sub("3D Box", shapes::menu()),
Item::Sub("Arrange Tools", arrange::menu()),
Item::Sub("Artwork Cleanup", cleanup::menu()),
Item::Sub("Color Effects", color::menu()),
+311
View File
@@ -0,0 +1,311 @@
//! The 3D Box and customizable shapes add-on in the interface: its submenu (Object › Plug-ins ›
//! 3D Box), which selects the 3D Box tool, and the context-sensitive Control bar options shown
//! when a shape tool (`rectangle`, `roundedRectangle`, `ellipse`, `polygon`, `star`, `spiral`,
//! `box3d`) is active.
use egui::Ui;
use serde_json::{Value, json};
use vectorcraft_geom::shapes::EllipseMode;
use vectorcraft_tools::addons::box3d::{self, CustomShape};
use crate::menus::Item;
use crate::theme::Tokens;
use crate::{VectorcraftApp, widgets};
pub fn menu() -> Vec<Item> {
vec![Item::Cmd("3D Box Tool", "tool.select", json!({"tool": box3d::ID}))]
}
/// Draw context-sensitive shape customization controls in the top Control bar when a shape tool is
/// active. Returns `true` when controls were drawn.
pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) -> bool {
let tool = app.session.tool_id().to_string();
if !matches!(tool.as_str(), "rectangle" | "roundedRectangle" | "ellipse" | "polygon" | "star" | "spiral" | "box3d") {
return false;
}
let opts = app.session.tool_options();
let active_shape = if tool == "box3d" { opts["shape"].as_str().unwrap_or("box3d").to_string() } else { tool.clone() };
let selected_custom: Option<CustomShape> = app.session.active().and_then(|st| {
st.selection.objects.iter().find_map(|&id| st.doc.node(id).and_then(|n| CustomShape::read_for_update(n, active_shape == "spiral")))
});
if tool == "box3d" {
for (s_id, lbl, icon) in [
("box3d", "3D Box", "box"),
("rectangle", "Rectangle", "square"),
("ellipse", "Ellipse", "dc-ellipse"),
("polygon", "Polygon", "dc-polygon"),
("star", "Star", "star"),
("spiral", "Spiral", "tornado"),
] {
if widgets::icon_button(ui, icon, tl!(lbl), active_shape == s_id, 22.0).clicked() {
app.run("tool.setOption", json!({ "key": "shape", "value": s_id })).ok();
}
}
ui.separator();
}
match active_shape.as_str() {
"ellipse" => draw_ellipse_controls(app, ui, &opts, selected_custom),
"rectangle" | "roundedRectangle" => draw_rectangle_controls(app, ui, &opts, selected_custom),
"polygon" | "star" => draw_polygon_controls(app, ui, &opts, &active_shape, selected_custom),
"spiral" => draw_spiral_controls(app, ui, &opts, selected_custom),
"box3d" => draw_box3d_controls(app, ui, &opts, selected_custom),
_ => return false,
}
true
}
fn apply_shape(app: &mut VectorcraftApp, tool_kvs: &[(&str, Value)], live_patch: Value) {
for (k, v) in tool_kvs {
app.run("tool.setOption", json!({ "key": *k, "value": v.clone() })).ok();
}
if app.session.active().is_some_and(|st| !st.selection.is_empty()) {
app.run("addon.shapes.configure", live_patch).ok();
}
}
fn draw_arc_controls(app: &mut VectorcraftApp, ui: &mut Ui, start_deg: f64, end_deg: f64, mode: &str) {
let t = Tokens::get(ui.ctx());
widgets::field_label(ui, egui::RichText::new(tl!("Start:")).size(12.0).color(t.text_dim));
if let Some(v) = widgets::plain_field(ui, ("cb-shape", "arc-start"), start_deg, "°", 1, 52.0) {
let next_mode = if mode == "whole" { "pie" } else { mode };
apply_shape(app, &[("arcStart", json!(v)), ("arcMode", json!(next_mode))], json!({ "startAngle": v, "arcMode": next_mode }));
}
widgets::field_label(ui, egui::RichText::new(tl!("End:")).size(12.0).color(t.text_dim));
if let Some(v) = widgets::plain_field(ui, ("cb-shape", "arc-end"), end_deg, "°", 1, 52.0) {
let next_mode = if mode == "whole" { "pie" } else { mode };
apply_shape(app, &[("arcEnd", json!(v)), ("arcMode", json!(next_mode))], json!({ "endAngle": v, "arcMode": next_mode }));
}
for (m_id, tip_label, icon) in
[("pie", "Slice", "circle-dot"), ("chord", "Closed", "circle-off"), ("arc", "Arc", "dc-arc"), ("whole", "Open", "dc-ellipse")]
{
if widgets::icon_button(ui, icon, tl!(tip_label), mode == m_id, 22.0).clicked() {
let (s0, e0) = if m_id == "whole" {
(0.0, 0.0)
} else if (end_deg - start_deg).abs() < 1e-4 || ((end_deg - start_deg).abs() - 360.0).abs() < 1e-4 {
(0.0, 270.0)
} else {
(start_deg, end_deg)
};
apply_shape(
app,
&[("arcMode", json!(m_id)), ("arcStart", json!(s0)), ("arcEnd", json!(e0))],
json!({ "arcMode": m_id, "startAngle": s0, "endAngle": if m_id == "whole" { 360.0 } else { e0 } }),
);
}
}
}
fn draw_ellipse_controls(app: &mut VectorcraftApp, ui: &mut Ui, opts: &Value, selected_custom: Option<CustomShape>) {
let (start_deg, end_deg, mode) = match selected_custom {
Some(CustomShape::Ellipse { pie: (a0, a1), arc_mode, .. }) => (a0, a1, mode_str(arc_mode)),
_ => (opts["arcStart"].as_f64().unwrap_or(0.0), opts["arcEnd"].as_f64().unwrap_or(0.0), opts["arcMode"].as_str().unwrap_or("whole")),
};
draw_arc_controls(app, ui, start_deg, end_deg, mode);
if widgets::flat_button(ui, tl!("Reset"), 52.0).clicked() {
apply_shape(app, &[("arcMode", json!("whole")), ("arcStart", json!(0.0)), ("arcEnd", json!(0.0))], json!({ "reset": true }));
}
ui.separator();
}
fn draw_rectangle_controls(app: &mut VectorcraftApp, ui: &mut Ui, opts: &Value, selected_custom: Option<CustomShape>) {
let t = Tokens::get(ui.ctx());
let unit = app.session.general_unit();
let sym = opts["cornerSymmetry"].as_bool().unwrap_or(true);
let mut radii = [opts["cornerRadius"].as_f64().unwrap_or(12.0); 4];
if let Some(arr) = opts["cornerRadii"].as_array()
&& arr.len() == 4
{
for (i, item) in arr.iter().enumerate() {
radii[i] = item.as_f64().unwrap_or(radii[i]);
}
}
let (mut start_deg, mut end_deg, mut mode) =
(opts["arcStart"].as_f64().unwrap_or(0.0), opts["arcEnd"].as_f64().unwrap_or(0.0), opts["arcMode"].as_str().unwrap_or("whole"));
if let Some(CustomShape::Rectangle { radii: nr, pie: (a0, a1), arc_mode, .. }) = selected_custom {
radii = nr;
start_deg = a0;
end_deg = a1;
mode = mode_str(arc_mode);
}
if widgets::icon_button(ui, if sym { "link" } else { "unlink" }, tl!("Link"), sym, 22.0).clicked() {
let next_sym = !sym;
apply_shape(app, &[("cornerSymmetry", json!(next_sym))], if next_sym { json!({ "radius": radii[0] }) } else { json!({ "radii": radii }) });
}
if sym {
widgets::field_label(ui, egui::RichText::new(tl!("Radius:")).size(12.0).color(t.text_dim));
if let Some(v) = widgets::num_field(ui, ("cb-shape", "rect-r"), Some(radii[0]), unit, 54.0) {
let r = v.max(0.0);
apply_shape(app, &[("cornerRadius", json!(r)), ("cornerRadii", json!([r, r, r, r]))], json!({ "radius": r }));
}
} else {
for (i, lbl) in [(0, "TL"), (1, "TR"), (2, "BR"), (3, "BL")] {
widgets::field_label(ui, egui::RichText::new(lbl).size(11.0).color(t.text_dim));
if let Some(v) = widgets::num_field(ui, ("cb-shape-r", i), Some(radii[i]), unit, 44.0) {
let mut next = radii;
next[i] = v.max(0.0);
apply_shape(app, &[("cornerRadii", json!(next))], json!({ "radii": next }));
}
}
}
ui.separator();
draw_arc_controls(app, ui, start_deg, end_deg, mode);
if widgets::flat_button(ui, tl!("Reset"), 52.0).clicked() {
apply_shape(
app,
&[
("cornerRadius", json!(0.0)),
("cornerRadii", json!([0.0, 0.0, 0.0, 0.0])),
("arcMode", json!("whole")),
("arcStart", json!(0.0)),
("arcEnd", json!(0.0)),
],
json!({ "reset": true }),
);
}
ui.separator();
}
fn draw_polygon_controls(app: &mut VectorcraftApp, ui: &mut Ui, opts: &Value, active_shape: &str, selected_custom: Option<CustomShape>) {
let t = Tokens::get(ui.ctx());
let mut is_star = if active_shape == "star" { true } else { opts["polyIsStar"].as_bool().unwrap_or(false) };
let mut count = if is_star { opts["points"].as_u64().unwrap_or(5) } else { opts["sides"].as_u64().unwrap_or(6) } as u32;
let mut spoke = opts["starRatio"].as_f64().unwrap_or(0.5);
let mut rounded = opts["rounded"].as_f64().unwrap_or(0.0);
let mut randomized = opts["randomized"].as_f64().unwrap_or(0.0);
let (mut start_deg, mut end_deg, mut mode) =
(opts["arcStart"].as_f64().unwrap_or(0.0), opts["arcEnd"].as_f64().unwrap_or(0.0), opts["arcMode"].as_str().unwrap_or("whole"));
if let Some(CustomShape::Polygon { sides, spoke_ratio, rounded: rnd, randomized: rand, pie: (a0, a1), arc_mode, .. }) = selected_custom {
count = sides;
is_star = spoke_ratio.is_some();
if let Some(sr) = spoke_ratio {
spoke = sr;
}
rounded = rnd;
randomized = rand;
start_deg = a0;
end_deg = a1;
mode = mode_str(arc_mode);
}
if widgets::icon_button(ui, "dc-polygon", tl!("Polygon"), !is_star, 22.0).clicked() {
apply_shape(app, &[("polyIsStar", json!(false))], json!({ "isStar": false }));
}
if widgets::icon_button(ui, "star", tl!("Star"), is_star, 22.0).clicked() {
apply_shape(app, &[("polyIsStar", json!(true))], json!({ "isStar": true, "spokeRatio": spoke }));
}
widgets::field_label(ui, egui::RichText::new(if is_star { tl!("Points:") } else { tl!("Sides:") }).size(12.0).color(t.text_dim));
if let Some(v) = widgets::plain_field(ui, ("cb-shape", "poly-corners"), f64::from(count), "", 0, 44.0) {
let c = (v.round() as i64).clamp(3, 1000) as u32;
apply_shape(app, &[("sides", json!(c)), ("points", json!(c))], json!({ "sides": c }));
}
if is_star {
widgets::field_label(ui, egui::RichText::new(tl!("Ratio")).size(12.0).color(t.text_dim));
if let Some(v) = widgets::plain_field(ui, ("cb-shape", "poly-spoke"), spoke, "", 2, 46.0) {
let sr = v.clamp(0.01, 1.0);
apply_shape(app, &[("starRatio", json!(sr))], json!({ "spokeRatio": sr }));
}
}
widgets::field_label(ui, egui::RichText::new(tl!("Round")).size(12.0).color(t.text_dim));
if let Some(v) = widgets::plain_field(ui, ("cb-shape", "poly-round"), rounded, "", 2, 46.0) {
let rnd = v.clamp(-10.0, 10.0);
apply_shape(app, &[("rounded", json!(rnd))], json!({ "rounded": rnd }));
}
widgets::field_label(ui, egui::RichText::new(tl!("Random")).size(12.0).color(t.text_dim));
if let Some(v) = widgets::plain_field(ui, ("cb-shape", "poly-rand"), randomized, "", 2, 46.0) {
let rand = v.clamp(-10.0, 10.0);
apply_shape(app, &[("randomized", json!(rand))], json!({ "randomized": rand }));
}
ui.separator();
draw_arc_controls(app, ui, start_deg, end_deg, mode);
if widgets::flat_button(ui, tl!("Reset"), 52.0).clicked() {
apply_shape(
app,
&[("rounded", json!(0.0)), ("randomized", json!(0.0)), ("arcMode", json!("whole")), ("arcStart", json!(0.0)), ("arcEnd", json!(0.0))],
json!({ "reset": true }),
);
}
ui.separator();
}
fn draw_spiral_controls(app: &mut VectorcraftApp, ui: &mut Ui, opts: &Value, selected_custom: Option<CustomShape>) {
let t = Tokens::get(ui.ctx());
let (mut turns, mut divergence, mut inner_r) =
(opts["turns"].as_f64().unwrap_or(3.0), opts["divergence"].as_f64().unwrap_or(1.0), opts["innerRadius"].as_f64().unwrap_or(0.0));
if let Some(CustomShape::Spiral { turns: t_v, divergence: div_v, inner_radius: ir_v, .. }) = selected_custom {
turns = t_v;
divergence = div_v;
inner_r = ir_v;
}
widgets::field_label(ui, egui::RichText::new(tl!("Segments")).size(12.0).color(t.text_dim));
if let Some(v) = widgets::plain_field(ui, ("cb-shape", "spiral-turns"), turns, "", 2, 50.0) {
let tv = v.clamp(0.01, 1024.0);
apply_shape(app, &[("turns", json!(tv))], json!({ "turns": tv }));
}
widgets::field_label(ui, egui::RichText::new(tl!("Ratio")).size(12.0).color(t.text_dim));
if let Some(v) = widgets::plain_field(ui, ("cb-shape", "spiral-div"), divergence, "", 2, 50.0) {
let dv = v.clamp(0.0, 1000.0);
apply_shape(app, &[("divergence", json!(dv))], json!({ "divergence": dv }));
}
widgets::field_label(ui, egui::RichText::new(tl!("Inner Radius:")).size(12.0).color(t.text_dim));
if let Some(v) = widgets::plain_field(ui, ("cb-shape", "spiral-inner"), inner_r, "", 2, 50.0) {
let iv = v.clamp(0.0, 0.999);
apply_shape(app, &[("innerRadius", json!(iv))], json!({ "innerRadius": iv }));
}
if widgets::flat_button(ui, tl!("Reset"), 52.0).clicked() {
apply_shape(app, &[("turns", json!(3.0)), ("divergence", json!(1.0)), ("innerRadius", json!(0.0))], json!({ "reset": true }));
}
ui.separator();
}
fn draw_box3d_controls(app: &mut VectorcraftApp, ui: &mut Ui, opts: &Value, selected_custom: Option<CustomShape>) {
let t = Tokens::get(ui.ctx());
let (mut ax, mut ay, mut az) =
(opts["angleX"].as_f64().unwrap_or(30.0), opts["angleY"].as_f64().unwrap_or(30.0), opts["angleZ"].as_f64().unwrap_or(0.0));
let (mut px, mut py, mut pz) =
(opts["parallelX"].as_bool().unwrap_or(false), opts["parallelY"].as_bool().unwrap_or(true), opts["parallelZ"].as_bool().unwrap_or(false));
if let Some(CustomShape::Box3d { angle_x, angle_y, angle_z, parallel_x, parallel_y, parallel_z, .. }) = selected_custom {
ax = angle_x;
ay = angle_y;
az = angle_z;
px = parallel_x;
py = parallel_y;
pz = parallel_z;
}
for (lbl, a_key, p_key, a_val, p_val) in
[("X:", "angleX", "parallelX", ax, px), ("Y:", "angleY", "parallelY", ay, py), ("Angle:", "angleZ", "parallelZ", az, pz)]
{
widgets::field_label(ui, egui::RichText::new(tl!(lbl)).size(12.0).color(t.text_dim));
if let Some(v) = widgets::plain_field(ui, ("cb-box3d", a_key), a_val, "°", 1, 52.0) {
apply_shape(app, &[(a_key, json!(v))], json!({ a_key: v }));
}
if widgets::icon_button(ui, "dc-line", tl!("Link"), p_val, 20.0).clicked() {
let next = !p_val;
apply_shape(app, &[(p_key, json!(next))], json!({ p_key: next }));
}
}
if widgets::flat_button(ui, tl!("Reset"), 52.0).clicked() {
apply_shape(
app,
&[
("angleX", json!(30.0)),
("angleY", json!(30.0)),
("angleZ", json!(0.0)),
("parallelX", json!(false)),
("parallelY", json!(true)),
("parallelZ", json!(false)),
],
json!({ "reset": true }),
);
}
ui.separator();
}
fn mode_str(m: EllipseMode) -> &'static str {
match m {
EllipseMode::Slice => "pie",
EllipseMode::Chord => "chord",
EllipseMode::Arc => "arc",
EllipseMode::Whole => "whole",
}
}
+5 -2
View File
@@ -530,6 +530,7 @@ fn addon_tools_are_in_the_drawer_not_the_toolbars() {
let mut app = app();
let enclosing = vectorcraft_tools::addons::enclosing_lasso::ID;
let contour = vectorcraft_tools::addons::contour::ID;
let box3d = vectorcraft_tools::addons::box3d::ID;
let addon: Vec<&str> = vectorcraft_tools::addons::GROUPS.iter().flat_map(|g| g.iter().map(|t| t.id)).collect();
assert_eq!(
addon,
@@ -543,7 +544,8 @@ fn addon_tools_are_in_the_drawer_not_the_toolbars() {
"sharedEdge",
"smartPen",
enclosing,
contour
contour,
box3d
]
);
for advanced in [false, true] {
@@ -559,7 +561,8 @@ fn addon_tools_are_in_the_drawer_not_the_toolbars() {
assert_eq!(listed("smartPen"), Some(("Smart Pen", vec!["smartPen"])));
assert_eq!(listed(enclosing), Some(("Enclosing Lasso", vec![enclosing])));
assert_eq!(listed(contour), Some(("Contour", vec![contour])));
for tool in ["extendPath", "smartPen", enclosing, contour] {
assert_eq!(listed(box3d), Some(("3D Box", vec![box3d])));
for tool in ["extendPath", "smartPen", enclosing, contour, box3d] {
app.run("toolbar.addTool", json!({"tool": tool})).unwrap();
assert!(slot_of(&app, tool).is_some(), "{tool}");
}
+4
View File
@@ -6243,3 +6243,7 @@ breakText Glyphs Глифи
# Compound Shapes add-on
Compound Shapes Съставни фигури
Shape Mode Buttons Make Compound Shapes Бутоните за режими на фигури създават съставни фигури
# 3D Box add-on
3D Box 3D кутия
3D Box Tool Инструмент за 3D кутия
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -6278,3 +6278,7 @@ shape Open Otvoren
# Compound Shapes add-on
Compound Shapes Složeni oblici
Shape Mode Buttons Make Compound Shapes Tasteri načina oblika prave složene oblike
# 3D Box add-on
3D Box 3D kutija
3D Box Tool Alat za 3D kutiju
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -6379,3 +6379,7 @@ breakText Glyphs Glifs
# Compound Shapes add-on
Compound Shapes Formes compostes
Shape Mode Buttons Make Compound Shapes Els botons de mode de forma creen formes compostes
# 3D Box add-on
3D Box Caixa 3D
3D Box Tool Eina Caixa 3D
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -6393,3 +6393,7 @@ breakText Glyphs Glyfy
# Compound Shapes add-on
Compound Shapes Složené tvary
Shape Mode Buttons Make Compound Shapes Tlačítka režimů tvarů vytvářejí složené tvary
# 3D Box add-on
3D Box 3D kvádr
3D Box Tool Nástroj 3D kvádr
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -6259,3 +6259,7 @@ breakText Glyphs Glyffer
# Compound Shapes add-on
Compound Shapes Sammensatte figurer
Shape Mode Buttons Make Compound Shapes Knapper til formtilstande opretter sammensatte figurer
# 3D Box add-on
3D Box 3D-boks
3D Box Tool 3D-boksværktøj
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -5979,3 +5979,7 @@ breakText Glyphs Glyphen
# Compound Shapes add-on
Compound Shapes Zusammengesetzte Formen
Shape Mode Buttons Make Compound Shapes Formmodus-Schaltflächen erstellen zusammengesetzte Formen
# 3D Box add-on
3D Box 3D-Quader
3D Box Tool 3D-Quader-Werkzeug
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -6345,3 +6345,7 @@ breakText Glyphs Γλύφοι
# Compound Shapes add-on
Compound Shapes Σύνθετα σχήματα
Shape Mode Buttons Make Compound Shapes Τα κουμπιά λειτουργιών σχημάτων δημιουργούν σύνθετα σχήματα
# 3D Box add-on
3D Box Κουτί 3D
3D Box Tool Εργαλείο κουτιού 3D
Can't render this file because it contains an unexpected character in line 163 and column 5.
+4
View File
@@ -6335,3 +6335,7 @@ breakText Glyphs Glifos
# Compound Shapes add-on
Compound Shapes Formas compuestas
Shape Mode Buttons Make Compound Shapes Los botones de modo de forma crean formas compuestas
# 3D Box add-on
3D Box Caja 3D
3D Box Tool Herramienta Caja 3D
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -6428,3 +6428,7 @@ breakText Glyphs Merkit
# Compound Shapes add-on
Compound Shapes Yhdistelmämuodot
Shape Mode Buttons Make Compound Shapes Muototilapainikkeet luovat yhdistelmämuotoja
# 3D Box add-on
3D Box 3D-laatikko
3D Box Tool 3D-laatikkotyökalu
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -6388,3 +6388,7 @@ breakText Glyphs Glyphes
# Compound Shapes add-on
Compound Shapes Formes composées
Shape Mode Buttons Make Compound Shapes Les boutons de mode de forme créent des formes composées
# 3D Box add-on
3D Box Boîte 3D
3D Box Tool Outil Boîte 3D
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -6377,3 +6377,7 @@ breakText Glyphs Glifák
# Compound Shapes add-on
Compound Shapes Összetett alakzatok
Shape Mode Buttons Make Compound Shapes Az alakzatmód-gombok összetett alakzatokat hoznak létre
# 3D Box add-on
3D Box 3D doboz
3D Box Tool 3D doboz eszköz
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -6259,3 +6259,7 @@ breakText Glyphs Glif
# Compound Shapes add-on
Compound Shapes Bentuk majemuk
Shape Mode Buttons Make Compound Shapes Tombol mode bentuk membuat bentuk majemuk
# 3D Box add-on
3D Box Kotak 3D
3D Box Tool Alat Kotak 3D
Can't render this file because it contains an unexpected character in line 2235 and column 5.
+4
View File
@@ -6013,3 +6013,7 @@ breakText Glyphs Glifi
# Compound Shapes add-on
Compound Shapes Forme composte
Shape Mode Buttons Make Compound Shapes I pulsanti dei metodi forma creano forme composte
# 3D Box add-on
3D Box Scatola 3D
3D Box Tool Strumento Scatola 3D
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -6078,3 +6078,7 @@ breakText Glyphs 字形
# Compound Shapes add-on
Compound Shapes 複合シェイプ
Shape Mode Buttons Make Compound Shapes シェイプモードボタンで複合シェイプを作成
# 3D Box add-on
3D Box 3Dボックス
3D Box Tool 3Dボックスツール
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -6405,3 +6405,7 @@ font-script Japanese იაპონური
# Compound Shapes add-on
Compound Shapes შედგენილი ფიგურები
Shape Mode Buttons Make Compound Shapes ფიგურის რეჟიმის ღილაკები ქმნის შედგენილ ფიგურებს
# 3D Box add-on
3D Box 3D ყუთი
3D Box Tool 3D ყუთის ხელსაწყო
Can't render this file because it is too large.
+4
View File
@@ -5974,3 +5974,7 @@ breakText Glyphs 글리프
# Compound Shapes add-on
Compound Shapes 컴파운드 모양
Shape Mode Buttons Make Compound Shapes 모양 모드 버튼으로 컴파운드 모양 만들기
# 3D Box add-on
3D Box 3D 상자
3D Box Tool 3D 상자 도구
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -6303,3 +6303,7 @@ breakText Glyphs Glyphs
# Compound Shapes add-on
Compound Shapes Samengestelde vormen
Shape Mode Buttons Make Compound Shapes Vormmodus-knoppen maken samengestelde vormen
# 3D Box add-on
3D Box 3D-doos
3D Box Tool 3D-doosgereedschap
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -6248,3 +6248,7 @@ breakText Glyphs Glify
# Compound Shapes add-on
Compound Shapes Kształty złożone
Shape Mode Buttons Make Compound Shapes Przyciski trybów kształtu tworzą kształty złożone
# 3D Box add-on
3D Box Bryła 3D
3D Box Tool Narzędzie Bryła 3D
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -6466,3 +6466,7 @@ swatch Yellow Green Verde-amarelado
# Compound Shapes add-on
Compound Shapes Formas Compostas
Shape Mode Buttons Make Compound Shapes Os Botões de Modo de Forma Criam Formas Compostas
# 3D Box add-on
3D Box Caixa 3D
3D Box Tool Ferramenta Caixa 3D
Can't render this file because it contains an unexpected character in line 6 and column 47.
+4
View File
@@ -6482,3 +6482,7 @@ shape Open Открытый
# Compound Shapes add-on
Compound Shapes Составные фигуры
Shape Mode Buttons Make Compound Shapes Кнопки режимов фигур создают составные фигуры
# 3D Box add-on
3D Box 3D-параллелепипед
3D Box Tool Инструмент «3D-параллелепипед»
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -6449,3 +6449,7 @@ digits Hindi Hindi
# Compound Shapes add-on
Compound Shapes Sammansatta former
Shape Mode Buttons Make Compound Shapes Formlägesknapparna skapar sammansatta former
# 3D Box add-on
3D Box 3D-låda
3D Box Tool 3D-lådverktyg
Can't render this file because it contains an unexpected character in line 5 and column 25.
+4
View File
@@ -6350,3 +6350,7 @@ breakText Glyphs Glifler
# Compound Shapes add-on
Compound Shapes Bileşik Şekiller
Shape Mode Buttons Make Compound Shapes Şekil Modu Düğmeleri Bileşik Şekiller Yapar
# 3D Box add-on
3D Box 3B Kutu
3D Box Tool 3B Kutu Aracı
Can't render this file because it contains an unexpected character in line 5 and column 25.
+4
View File
@@ -5993,3 +5993,7 @@ breakText Glyphs Гліфи
# Compound Shapes add-on
Compound Shapes Складені фігури
Shape Mode Buttons Make Compound Shapes Кнопки режимів фігур створюють складені фігури
# 3D Box add-on
3D Box 3D-паралелепіпед
3D Box Tool Інструмент «3D-паралелепіпед»
Can't render this file because it contains an unexpected character in line 177 and column 5.
+4
View File
@@ -5994,3 +5994,7 @@ breakText Glyphs Glyph
# Compound Shapes add-on
Compound Shapes Hình phức hợp
Shape Mode Buttons Make Compound Shapes Nút chế độ hình dạng tạo hình phức hợp
# 3D Box add-on
3D Box Hộp 3D
3D Box Tool Công cụ Hộp 3D
Can't render this file because it contains an unexpected character in line 199 and column 5.
+4
View File
@@ -6234,3 +6234,7 @@ breakText Glyphs 字形
# Compound Shapes add-on
Compound Shapes 复合形状
Shape Mode Buttons Make Compound Shapes 形状模式按钮创建复合形状
# 3D Box add-on
3D Box 3D 盒子
3D Box Tool 3D 盒子工具
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -6031,3 +6031,7 @@ breakText Glyphs 字符
# Compound Shapes add-on
Compound Shapes 複合形狀
Shape Mode Buttons Make Compound Shapes 形狀模式按鈕製作複合形狀
# 3D Box add-on
3D Box 3D 盒子
3D Box Tool 3D 盒子工具
Can't render this file because it contains an unexpected character in line 6 and column 47.
+3
View File
@@ -426,6 +426,9 @@ pub fn open_options(app: &mut VectorcraftApp, tool: &str) -> Result<serde_json::
/// Warp's mesh and pins (set through `tool.setOption`). The Artboard tool's options are drawn by
/// [`crate::panels::artboards::control_bar`].
pub fn control_bar_options(app: &mut VectorcraftApp, ui: &mut Ui) {
if crate::addons::shapes::control_bar(app, ui) {
return;
}
if app.session.tool_id() == "dimension" {
return crate::dialogs::dimension::controls(app, ui);
}