diff --git a/crates/engine/src/cmd/addons.rs b/crates/engine/src/cmd/addons.rs index 15ba1dad4..324d7bde5 100644 --- a/crates/engine/src/cmd/addons.rs +++ b/crates/engine/src/cmd/addons.rs @@ -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 { v.extend(pathtools::specs()); v.extend(pen::specs()); v.extend(select::specs()); + v.extend(shapes::specs()); v.extend(typetools::specs()); v } diff --git a/crates/engine/src/cmd/addons/shapes.rs b/crates/engine/src/cmd/addons/shapes.rs new file mode 100644 index 000000000..c40173390 --- /dev/null +++ b/crates/engine/src/cmd/addons/shapes.rs @@ -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 { + 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 { + 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, cs: CustomShape) -> Result { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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, 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, + } +} diff --git a/crates/engine/src/cmd/addons/shapes/tests.rs b/crates/engine/src/cmd/addons/shapes/tests.rs new file mode 100644 index 000000000..793ae6074 --- /dev/null +++ b/crates/engine/src/cmd/addons/shapes/tests.rs @@ -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)); + } +} diff --git a/crates/geom/src/shapes.rs b/crates/geom/src/shapes.rs index 2747fdf45..d66ab3860 100644 --- a/crates/geom/src/shapes.rs +++ b/crates/geom/src/shapes.rs @@ -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 = (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 = (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 = 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)); + } } diff --git a/crates/tools/src/addons.rs b/crates/tools/src/addons.rs index 69bb4858a..e88721b04 100644 --- a/crates/tools/src/addons.rs +++ b/crates/tools/src/addons.rs @@ -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> { 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()); diff --git a/crates/tools/src/addons/box3d.rs b/crates/tools/src/addons/box3d.rs new file mode 100644 index 000000000..b0ecdc350 --- /dev/null +++ b/crates/tools/src/addons/box3d.rs @@ -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 { + 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, + #[serde(default)] + kinds: Vec, + #[serde(default)] + radii_relative: bool, + #[serde(default)] + spoke_ratio: Option, + #[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 { + 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 { + 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::(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 { + 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) -> Option { + 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 { + 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, + 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 { + 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 { + 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 { + 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), + _ => {} + } + } +} diff --git a/crates/ui-egui/src/addons.rs b/crates/ui-egui/src/addons.rs index 3f0909842..6cacf1c0d 100644 --- a/crates/ui-egui/src/addons.rs +++ b/crates/ui-egui/src/addons.rs @@ -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 { 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()), diff --git a/crates/ui-egui/src/addons/shapes.rs b/crates/ui-egui/src/addons/shapes.rs new file mode 100644 index 000000000..e7971bb88 --- /dev/null +++ b/crates/ui-egui/src/addons/shapes.rs @@ -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 { + 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 = 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) { + 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) { + 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) { + 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) { + 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) { + 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", + } +} diff --git a/crates/ui-egui/src/edit_toolbar/tests.rs b/crates/ui-egui/src/edit_toolbar/tests.rs index c474cea58..b467f0555 100644 --- a/crates/ui-egui/src/edit_toolbar/tests.rs +++ b/crates/ui-egui/src/edit_toolbar/tests.rs @@ -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}"); } diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 0429245b2..a40240ba4 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -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 кутия diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 648a394da..a2464b883 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 7f1ae8508..fe905d5ac 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 6474f2cb6..8231ee0f6 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 0015d0d8f..1375be430 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 4602d1a3a..acc8fad4f 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 5753e5747..56467eee2 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 925630c8f..7f803f468 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 489ccafae..899e4b14a 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index f00fd1ba1..ba0a97227 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 7f350c633..9a0724c15 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 1511db123..00fc62d15 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 7a4768dda..ccff8235f 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index e17e99d22..294240791 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -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ボックスツール diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 8b26add37..9da177b99 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -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 ყუთის ხელსაწყო diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 680bfce8e..5c215668c 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -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 상자 도구 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index f46c0a715..300fb3de1 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 76551ff0a..197f6b9d6 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 6bcc5a619..3a9a0c7f8 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 8b7ce252f..3a717f676 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -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-параллелепипед» diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 689bad0d5..839596958 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 18f29eb36..f84137f80 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -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ı diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index e0583951b..257723846 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -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-паралелепіпед» diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index aa47c6762..cad18a0d6 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index c3976d7d9..e0030aa39 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -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 盒子工具 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 1fcda1b61..36e562b2e 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -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 盒子工具 diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 94054a0d4..2280853e1 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -426,6 +426,9 @@ pub fn open_options(app: &mut VectorcraftApp, tool: &str) -> Result