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