mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-11 02:19:56 -06:00
- 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/.
138 lines
5.9 KiB
Rust
138 lines
5.9 KiB
Rust
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));
|
|
}
|
|
}
|