Merge origin/main into fix/flush-pad-over-hole
This commit is contained in:
commit
5c4785f55c
62 files changed
+3591
-347
No files matched your search
@@ -35,8 +35,13 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.params("feature: id|name → features deleted with it, and features that would fail without it"),
|
||||
CommandSpec::new("timeline.suppress", "Suppress Feature", suppress).params("feature: id|name, suppressed?: bool (default toggles)"),
|
||||
CommandSpec::new("timeline.edit", "Edit Feature", edit_feature).params(
|
||||
"feature: id|name, set: {fields to change, e.g. {\"extent\": {\"distance\": \"30\"}}} → recomputed: features rebuilt, \
|
||||
restored: features whose earlier result for the same inputs was reused",
|
||||
"feature: id|name, set: {fields to change, merged into the feature's definition as timeline.inspect returns it, \
|
||||
e.g. {\"extent\": {\"distance\": \"30\"}} (extrude), {\"angle\": \"90 deg\"} (revolve); \
|
||||
expressions are text} → recomputed: features rebuilt, restored: features whose earlier result for the same inputs was reused",
|
||||
),
|
||||
CommandSpec::new("timeline.inspect", "Inspect Feature", inspect_feature).noundo().params(
|
||||
"feature: id|name → type, definition (the fields timeline.edit's set changes, with their current values), \
|
||||
inputs: [{label, param, expression, unit}] (named inputs parameters.change can also set), error, warning",
|
||||
),
|
||||
CommandSpec::new("select.set", "Select", select_set)
|
||||
.noundo()
|
||||
@@ -190,6 +195,52 @@ fn merge(dst: &mut Value, src: &Value, depth: usize) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fields of a stored feature left out of its definition: bookkeeping timeline.edit recomputes, and
|
||||
/// the name and suppression, which timeline.rename and timeline.suppress change.
|
||||
const NOT_EDITABLE: &[&str] = &["id", "type", "name", "suppressed", "param_names", "edge_refs", "edge_names", "face_names"];
|
||||
|
||||
fn inspect_feature(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "timeline.inspect";
|
||||
let id = feature_id(s, p.get("feature"), cmd)?;
|
||||
let f = s.doc.feature(id).ok_or_else(|| bad(cmd, "feature"))?;
|
||||
let mut def = serde_json::to_value(f).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let mut omitted = Vec::new();
|
||||
if let Some(o) = def.as_object_mut() {
|
||||
for k in NOT_EDITABLE {
|
||||
o.remove(*k);
|
||||
}
|
||||
// Large payloads that timeline.edit cannot change.
|
||||
let heavy: &[(&str, &str)] = match &f.kind {
|
||||
FeatureKind::Sketch { .. } => &[("sketch", "the sketch geometry (sketch.inspect; edit it with the sketch commands)")],
|
||||
FeatureKind::Import { .. } => &[("step", "the imported STEP text"), ("components", "the imported product tree")],
|
||||
FeatureKind::MeshImport { .. } => &[("meshes", "the imported meshes")],
|
||||
_ => &[],
|
||||
};
|
||||
for (k, why) in heavy {
|
||||
if o.remove(*k).is_some() {
|
||||
omitted.push(json!({"field": k, "what": why}));
|
||||
}
|
||||
}
|
||||
}
|
||||
let inputs: Vec<Value> = f
|
||||
.kind
|
||||
.inputs()
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, (label, e, k))| {
|
||||
json!({"label": label, "param": f.param_names.get(i).filter(|n| !n.is_empty()), "expression": e, "unit": k.default_unit()})
|
||||
})
|
||||
.collect();
|
||||
let marker = s.doc.marker.unwrap_or(usize::MAX);
|
||||
let rolled_back = s.doc.features.iter().position(|x| x.id == id).is_some_and(|i| i >= marker);
|
||||
let r = s.model.result(id);
|
||||
Ok(json!({
|
||||
"id": f.id, "name": f.name, "type": f.kind.type_name(), "suppressed": f.suppressed, "rolled_back": rolled_back,
|
||||
"definition": def, "omitted": omitted, "inputs": inputs,
|
||||
"error": r.and_then(|r| r.error.clone()), "warning": r.and_then(|r| r.warning.clone()),
|
||||
}))
|
||||
}
|
||||
|
||||
fn edit_feature(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "timeline.edit";
|
||||
let id = feature_id(s, p.get("feature"), cmd)?;
|
||||
|
||||
@@ -104,7 +104,10 @@ fn generated_profiles_and_press_pull_convert_rotated_occurrence_picks_once() {
|
||||
// Curved coincident Cut is an existing kernel limit; it must be atomic.
|
||||
let before = s.doc.to_json();
|
||||
let error = s.execute("solid.press_pull", &json!({"face":[35,-15,15],"distance":-5})).unwrap_err();
|
||||
assert!(error.to_string().contains("coincident faces on curved bodies"), "{error}");
|
||||
let message = error.to_string();
|
||||
assert!(message.contains("takes planar faces only"), "{error}");
|
||||
assert!(message.contains("the target has 2 curved faces (cylinder)"), "{error}");
|
||||
assert!(message.contains("the tool has 2 curved faces (cylinder)"), "{error}");
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
check(&mut s, area * 35.0, hole, 35.0);
|
||||
35.0
|
||||
|
||||
@@ -436,6 +436,54 @@ fn coil_and_loft_to_a_point() {
|
||||
assert!(s.execute("solid.loft", &json!({"sections": [{"sketch": "Base"}, {"sketch": "Top", "point": "nope"}]})).is_err());
|
||||
}
|
||||
|
||||
/// A 3 mm shelled basin (100 x 60 x 30, open top) with a fall lofted from its whole outline
|
||||
/// 10 up down to a drain pad (`pad`: corners, 1 up), in either section order. The loft's top
|
||||
/// edges lie on the outer walls; the cut used to fail for one order of the sections (an
|
||||
/// `Option::unwrap()` panic inside the kernel, or "shell not oriented and closed").
|
||||
fn basin_fall(pad: [f64; 4], reverse: bool, op: &str) -> (Session, std::result::Result<Value, String>) {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 100, "width": 60, "height": 30, "corner": [0, 0, 0]}));
|
||||
run(&mut s, "solid.shell", json!({"faces": [[50, 30, 30]], "thickness": 3}));
|
||||
run(&mut s, "sketch.create", json!({"plane": {"origin": [0, 0, 10], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}, "name": "Rim"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [100, 60]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "sketch.create", json!({"plane": {"origin": [0, 0, 1], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}, "name": "Pad"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [pad[0], pad[1]], "p1": [pad[2], pad[3]]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let mut secs = vec![json!({"sketch": "Rim"}), json!({"sketch": "Pad"})];
|
||||
if reverse {
|
||||
secs.reverse();
|
||||
}
|
||||
let r = s.execute("solid.loft", &json!({"sections": secs, "operation": op})).map_err(|e| e.to_string());
|
||||
(s, r)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loft_cut_through_a_shelled_basin_in_either_order() {
|
||||
// What is left after the cut: the basin less the loft's part of the floor and walls
|
||||
// (integrated over the height), the same for a pad in any corner.
|
||||
let want = 41582.028;
|
||||
for pad in [[80.0, 40.0, 90.0, 50.0], [10.0, 40.0, 20.0, 50.0], [10.0, 10.0, 20.0, 20.0], [80.0, 10.0, 90.0, 20.0]] {
|
||||
for reverse in [false, true] {
|
||||
let (mut s, r) = basin_fall(pad, reverse, "cut");
|
||||
assert!(r.is_ok(), "{pad:?} reverse {reverse}: {r:?}");
|
||||
let v = volume(&mut s);
|
||||
assert!((v - want).abs() < 1.0, "{pad:?} reverse {reverse}: {v} vs {want}");
|
||||
assert_eq!(measure(&mut s)["body_count"], 1);
|
||||
}
|
||||
}
|
||||
// As a new body and joined: a valid solid of positive volume either way.
|
||||
for op in ["new", "join"] {
|
||||
let mut vols = Vec::new();
|
||||
for reverse in [false, true] {
|
||||
let (mut s, r) = basin_fall([80.0, 40.0, 90.0, 50.0], reverse, op);
|
||||
assert!(r.is_ok(), "{op} reverse {reverse}: {r:?}");
|
||||
vols.push(volume(&mut s));
|
||||
}
|
||||
assert!(vols[0] > 42948.0 && rel(vols[0], vols[1]) < 1e-6, "{op}: {vols:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cylinder_auto_inference_saves_a_profile_anchor_and_replays_both_depth_signs() {
|
||||
use solvecraft_geom::Vec3;
|
||||
|
||||
@@ -2,6 +2,13 @@
|
||||
//! `sketch.rectangle.two_point`); the toolbar placement (`tab`, `panel`) follows the familiar
|
||||
//! Design workspace layout. Ids used before the rename still resolve (`legacy_ids`).
|
||||
|
||||
/// The params-doc note of every command that adds sketch geometry (see `sketch::created`).
|
||||
macro_rules! created_doc {
|
||||
() => {
|
||||
". Returns created: {points, curves, constraints, dimensions}: the ids of what it added (stable and never reused: usable right away in sketch.dimension, sketch.constraint.*, sketch.delete…)"
|
||||
};
|
||||
}
|
||||
|
||||
mod browser;
|
||||
mod clipboard;
|
||||
mod component;
|
||||
@@ -30,6 +37,7 @@ mod plastic;
|
||||
mod sheet;
|
||||
mod sketch;
|
||||
pub(crate) use sketch::author_path_for_rebind;
|
||||
pub(crate) use sketch::created as sketch_created;
|
||||
mod sketch_3d;
|
||||
mod sketch_canvas;
|
||||
mod sketch_constraints;
|
||||
|
||||
@@ -34,74 +34,74 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.icon("line")
|
||||
.key("L")
|
||||
.enabled(in_sketch)
|
||||
.params(concat!("points: [[x,y] | \"l1.end\" | {at, ref}…], closed?: bool, construction?: bool, ids?: [..], infer?: bool (add horizontal/vertical)", connect_doc!())),
|
||||
.params(concat!("points: [[x,y] | \"l1.end\" | {at, ref}…], closed?: bool, construction?: bool, ids?: [..], infer?: bool (add horizontal/vertical)", connect_doc!(), created_doc!())),
|
||||
CommandSpec::new("sketch.draw.dimensioned", "Draw Dimensioned Sketch Geometry", draw_dimensioned)
|
||||
.enabled(in_sketch)
|
||||
.params("command: sketch creation command, params: object, dimensions: object of role to numeric or expression value; creates geometry and all driving dimensions atomically in one undo step"),
|
||||
.params(concat!("command: sketch creation command, params: object, dimensions: object of role to numeric or expression value; creates geometry and all driving dimensions atomically in one undo step", created_doc!())),
|
||||
CommandSpec::new("sketch.rectangle.two_point", "2-Point Rectangle", rect_two_point)
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("rect")
|
||||
.key("R")
|
||||
.enabled(in_sketch)
|
||||
.params(concat!("p0, p1: opposite corners [x,y]; construction?. Returns sides {bottom, right, top, left} (line ids) and corners {bottom_left, bottom_right, top_right, top_left} (point ids)", connect_doc!())),
|
||||
.params(concat!("p0, p1: opposite corners [x,y]; construction?. Returns sides {bottom, right, top, left} (line ids) and corners {bottom_left, bottom_right, top_right, top_left} (point ids)", connect_doc!(), created_doc!())),
|
||||
CommandSpec::new("sketch.rectangle.three_point", "3-Point Rectangle", rect_three_point)
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("rect3")
|
||||
.enabled(in_sketch)
|
||||
.params(concat!("p0, p1: first edge; p2: point on the opposite edge", connect_doc!())),
|
||||
.params(concat!("p0, p1: first edge; p2: point on the opposite edge", connect_doc!(), created_doc!())),
|
||||
CommandSpec::new("sketch.rectangle.center", "Center Rectangle", rect_center)
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("rect_center")
|
||||
.enabled(in_sketch)
|
||||
.params(concat!("center, corner. Returns sides {bottom, right, top, left} (line ids) and corners {bottom_left, bottom_right, top_right, top_left} (point ids)", connect_doc!())),
|
||||
.params(concat!("center, corner. Returns sides {bottom, right, top, left} (line ids) and corners {bottom_left, bottom_right, top_right, top_left} (point ids)", connect_doc!(), created_doc!())),
|
||||
CommandSpec::new("sketch.circle.center", "Center Diameter Circle", circle_center)
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("circle")
|
||||
.key("C")
|
||||
.enabled(in_sketch)
|
||||
.params(concat!("center: [x,y] or point ref, radius | diameter: number", connect_doc!())),
|
||||
.params(concat!("center: [x,y] or point ref, radius | diameter: number", connect_doc!(), created_doc!())),
|
||||
CommandSpec::new("sketch.circle.two_point", "2-Point Circle", circle_two)
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("circle2")
|
||||
.enabled(in_sketch)
|
||||
.params(concat!("p0, p1: diameter ends", connect_doc!())),
|
||||
.params(concat!("p0, p1: diameter ends", connect_doc!(), created_doc!())),
|
||||
CommandSpec::new("sketch.circle.three_point", "3-Point Circle", circle_three)
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("circle3")
|
||||
.enabled(in_sketch)
|
||||
.params(concat!("p0, p1, p2", connect_doc!())),
|
||||
CommandSpec::new("sketch.arc.three_point", "3-Point Arc", arc_three).at("SKETCH", "CREATE").icon("arc3").enabled(in_sketch).params(concat!("start, end, through", connect_doc!())),
|
||||
.params(concat!("p0, p1, p2", connect_doc!(), created_doc!())),
|
||||
CommandSpec::new("sketch.arc.three_point", "3-Point Arc", arc_three).at("SKETCH", "CREATE").icon("arc3").enabled(in_sketch).params(concat!("start, end, through", connect_doc!(), created_doc!())),
|
||||
CommandSpec::new("sketch.arc.center_point", "Center Point Arc", arc_center)
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("arc_center")
|
||||
.enabled(in_sketch)
|
||||
.params(concat!("center, start, end (counter-clockwise from start) | center, start, sweep: signed degrees", connect_doc!())),
|
||||
.params(concat!("center, start, end (counter-clockwise from start) | center, start, sweep: signed degrees", connect_doc!(), created_doc!())),
|
||||
CommandSpec::new("sketch.polygon.inscribed", "Inscribed Polygon", polygon_inscribed)
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("polygon")
|
||||
.enabled(in_sketch)
|
||||
.params(concat!("center, radius (to vertices), sides, angle?: deg", connect_doc!())),
|
||||
.params(concat!("center, radius (to vertices), sides, angle?: deg", connect_doc!(), created_doc!())),
|
||||
CommandSpec::new("sketch.polygon.circumscribed", "Circumscribed Polygon", polygon_circumscribed)
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("polygon")
|
||||
.enabled(in_sketch)
|
||||
.params(concat!("center, radius (to edge midpoints), sides, angle?: deg", connect_doc!())),
|
||||
.params(concat!("center, radius (to edge midpoints), sides, angle?: deg", connect_doc!(), created_doc!())),
|
||||
CommandSpec::new("sketch.polygon.edge", "Edge Polygon", polygon_edge)
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("polygon")
|
||||
.enabled(in_sketch)
|
||||
.params(concat!("p0, p1: one edge, sides", connect_doc!())),
|
||||
.params(concat!("p0, p1: one edge, sides", connect_doc!(), created_doc!())),
|
||||
CommandSpec::new("sketch.slot.center_to_center", "Center to Center Slot", slot_c2c)
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("slot")
|
||||
.enabled(in_sketch)
|
||||
.params(concat!("p0, p1: arc centres, width", connect_doc!())),
|
||||
.params(concat!("p0, p1: arc centres, width", connect_doc!(), created_doc!())),
|
||||
CommandSpec::new("sketch.slot.overall", "Overall Slot", slot_overall)
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("slot")
|
||||
.enabled(in_sketch)
|
||||
.params(concat!("p0, p1: overall ends, width", connect_doc!())),
|
||||
CommandSpec::new("sketch.point", "Point", draw_point).at("SKETCH", "CREATE").icon("point").enabled(in_sketch).params(concat!("point: [x,y], id?", connect_doc!())),
|
||||
.params(concat!("p0, p1: overall ends, width", connect_doc!(), created_doc!())),
|
||||
CommandSpec::new("sketch.point", "Point", draw_point).at("SKETCH", "CREATE").icon("point").enabled(in_sketch).params(concat!("point: [x,y], id?", connect_doc!(), created_doc!())),
|
||||
CommandSpec::new("sketch.dimension", "Sketch Dimension", dimension).at("SKETCH", "CREATE").icon("dimension").key("D").enabled(in_sketch).params(
|
||||
"entities: [refs], type?: auto|distance|horizontal|vertical|length|radius|diameter|angle, value?: number or expression (default: current); text_at?: [x, y]. Two lines: the angle between them (acute, or 180° − it when the value is nearer that) unless text_at picks a sector. Returns measured in unit (mm or deg)",
|
||||
),
|
||||
@@ -353,6 +353,37 @@ pub(super) fn mark_construction(sk: &mut Sketch, curves: &[usize], p: &Value) {
|
||||
}
|
||||
}
|
||||
|
||||
/// What a command added to a sketch: the ids of the points, curves, constraints and dimensions
|
||||
/// in `after` that `before` does not have (`None`: a new sketch), in creation order, plus the
|
||||
/// 3D curves (`wires`) when there are any. Ids are stable and never reused in a sketch, so they
|
||||
/// can be used in later commands (`sketch.dimension`, `sketch.constraint.*`, `sketch.delete`…).
|
||||
pub(crate) fn created(before: Option<&Sketch>, after: &Sketch) -> Value {
|
||||
use std::collections::HashSet;
|
||||
let empty = Sketch::new();
|
||||
let before = before.unwrap_or(&empty);
|
||||
let old: HashSet<&str> = before
|
||||
.points
|
||||
.iter()
|
||||
.map(|x| x.id.as_str())
|
||||
.chain(before.curves.iter().map(|x| x.id.as_str()))
|
||||
.chain(before.constraints.iter().map(|x| x.id.as_str()))
|
||||
.chain(before.wires.iter().map(|x| x.id.as_str()))
|
||||
.collect();
|
||||
let new = |id: &&str| !old.contains(*id);
|
||||
let points: Vec<&str> = after.points.iter().map(|x| x.id.as_str()).filter(new).collect();
|
||||
let curves: Vec<&str> = after.curves.iter().map(|x| x.id.as_str()).filter(new).collect();
|
||||
let (dims, cons): (Vec<_>, Vec<_>) = after.constraints.iter().filter(|c| new(&c.id.as_str())).partition(|c| c.kind.is_dimension());
|
||||
let ids = |v: Vec<&solvecraft_sketch::Constraint>| v.into_iter().map(|c| c.id.clone()).collect::<Vec<_>>();
|
||||
let mut out = json!({"points": points, "curves": curves, "constraints": ids(cons), "dimensions": ids(dims)});
|
||||
let wires: Vec<&str> = after.wires.iter().map(|x| x.id.as_str()).filter(new).collect();
|
||||
if !wires.is_empty()
|
||||
&& let Some(o) = out.as_object_mut()
|
||||
{
|
||||
o.insert("wires".into(), json!(wires));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub(super) fn result(sk_out: (Vec<String>, Vec<String>), info: Value) -> Value {
|
||||
json!({"curves": sk_out.0, "constraints": sk_out.1, "sketch": info})
|
||||
}
|
||||
@@ -1909,3 +1940,7 @@ mod rect_tests;
|
||||
#[cfg(test)]
|
||||
#[path = "sketch_ux_tests.rs"]
|
||||
mod ux_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "sketch_ids_tests.rs"]
|
||||
mod ids_tests;
|
||||
@@ -12,13 +12,16 @@ use crate::params::{bad, req_vec3, vec3};
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("sketch.line3d", "3D Line", line3d)
|
||||
.enabled(in_sketch)
|
||||
.params("points: [[x,y,z]…] world points (2 or more): one 3D line per segment, joined end to end (needs 3D Sketch on)"),
|
||||
CommandSpec::new("sketch.line3d", "3D Line", line3d).enabled(in_sketch).params(concat!(
|
||||
"points: [[x,y,z]…] world points (2 or more): one 3D line per segment, joined end to end (needs 3D Sketch on)",
|
||||
created_doc!()
|
||||
)),
|
||||
CommandSpec::new("sketch.spline3d", "3D Spline", spline3d)
|
||||
.enabled(in_sketch)
|
||||
.params("points: [[x,y,z]…] world fit points (2 or more): a smooth 3D curve through them (needs 3D Sketch on)"),
|
||||
CommandSpec::new("sketch.point3d", "3D Point", point3d).enabled(in_sketch).params("point: [x,y,z] world point (needs 3D Sketch on)"),
|
||||
.params(concat!("points: [[x,y,z]…] world fit points (2 or more): a smooth 3D curve through them (needs 3D Sketch on)", created_doc!())),
|
||||
CommandSpec::new("sketch.point3d", "3D Point", point3d)
|
||||
.enabled(in_sketch)
|
||||
.params(concat!("point: [x,y,z] world point (needs 3D Sketch on)", created_doc!())),
|
||||
CommandSpec::new("sketch.move3d", "Move 3D Point", move3d)
|
||||
.enabled(in_sketch)
|
||||
.params("wire: drawn 3D curve or point id, index?: fit point (default 0), to: [x,y,z] | by: [dx,dy,dz]; points joined to it move too"),
|
||||
|
||||
@@ -96,3 +96,90 @@ fn fillet_and_chamfer_corners() {
|
||||
assert!(solved(&s));
|
||||
assert!((area(&s) - (a0 - 8.0)).abs() < 1e-6, "{}", area(&s));
|
||||
}
|
||||
|
||||
/// The ids of the constraints driven by the model parameter `name`.
|
||||
fn driven_by(s: &Session, name: &str) -> Vec<String> {
|
||||
sk(s).constraints.iter().filter(|c| c.param.as_deref() == Some(name)).map(|c| c.id.clone()).collect()
|
||||
}
|
||||
|
||||
/// The spout of a basin: a vertical riser and a horizontal top, the top's length dimensioned
|
||||
/// to the corner and the corner's height dimensioned from the origin. Filleting the corner used
|
||||
/// to drop the height and leave the length on the shortened straight part, so the top came out
|
||||
/// a fillet radius too long. Like Fusion, the corner now stays as a virtual sharp corner (a point
|
||||
/// on both lines' extensions) and the dimensions measure to it.
|
||||
#[test]
|
||||
fn a_fillet_keeps_the_dimensions_of_its_corner_on_a_virtual_sharp() {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.line", json!({"points": [[0, 0], [0, 100], [-80, 100]], "ids": ["riser", "top"]}));
|
||||
run(&mut s, "sketch.constraint.horizontal_vertical", json!({"line": "riser", "mode": "vertical"}));
|
||||
run(&mut s, "sketch.constraint.horizontal_vertical", json!({"line": "top", "mode": "horizontal"}));
|
||||
let height = run(&mut s, "sketch.dimension", json!({"entities": ["origin", "riser.end"], "type": "vertical", "value": 100}));
|
||||
let height = height["param"].as_str().unwrap().to_string();
|
||||
let len = run(&mut s, "sketch.dimension", json!({"entities": ["top"], "value": 80}));
|
||||
let len = len["param"].as_str().unwrap().to_string();
|
||||
let (height_ids, len_ids) = (driven_by(&s, &height), driven_by(&s, &len));
|
||||
let f = run(&mut s, "sketch.fillet", json!({"point": "riser.end", "radius": 20}));
|
||||
assert!(solved(&s), "{f}");
|
||||
// Both dimensions are kept (same ids) and reported as moved to the virtual corner.
|
||||
assert_eq!(driven_by(&s, &height), height_ids);
|
||||
assert_eq!(driven_by(&s, &len), len_ids);
|
||||
let corner = f["virtual_corners"][0].as_str().unwrap_or_default().to_string();
|
||||
assert!(!corner.is_empty(), "{f}");
|
||||
let moved: Vec<String> = f["moved_to_virtual_corner"].as_array().unwrap().iter().filter_map(|v| v.as_str().map(str::to_string)).collect();
|
||||
for id in height_ids.iter().chain(&len_ids) {
|
||||
assert!(moved.contains(id), "{id}: {f}");
|
||||
}
|
||||
// The virtual corner sits where the corner was, and the part keeps its size.
|
||||
let k = sk(&s);
|
||||
let vc = k.point(k.point_index(&corner).unwrap()).unwrap();
|
||||
assert!(vc.dist(solvecraft_geom::Vec2::new(0.0, 100.0)) < 1e-6, "{vc:?}");
|
||||
let top_end = k.point(k.resolve_point("top.end").unwrap()).unwrap();
|
||||
assert!(top_end.dist(solvecraft_geom::Vec2::new(-80.0, 100.0)) < 1e-6, "{top_end:?}");
|
||||
let (lo, hi) = k.bounds().unwrap();
|
||||
assert!((hi.x - lo.x - 80.0).abs() < 1e-6 && (hi.y - lo.y - 100.0).abs() < 1e-6, "{lo:?} {hi:?}");
|
||||
// It is drawn with extension lines from both lines' ends to it.
|
||||
let vcs = k.virtual_corners();
|
||||
assert_eq!(vcs.len(), 1, "{vcs:?}");
|
||||
assert_eq!(vcs[0].1.len(), 2, "{vcs:?}");
|
||||
assert!(vcs[0].1.iter().all(|(a, b)| a.dist(vc) < 1e-9 && (b.dist(vc) - 20.0).abs() < 1e-6), "{vcs:?}");
|
||||
// The dimensions still drive the part, measured to the virtual corner.
|
||||
run(&mut s, "parameters.change", json!({"name": len, "expression": "90 mm"}));
|
||||
run(&mut s, "parameters.change", json!({"name": height, "expression": "120 mm"}));
|
||||
assert!(solved(&s));
|
||||
let (lo, hi) = sk(&s).bounds().unwrap();
|
||||
assert!((hi.x - lo.x - 90.0).abs() < 1e-6 && (hi.y - lo.y - 120.0).abs() < 1e-6, "{lo:?} {hi:?}");
|
||||
// It is kept in the design file, and undo and redo take it away and bring it back.
|
||||
let id = s.active_sketch.unwrap();
|
||||
let doc = solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap();
|
||||
assert_eq!(doc.sketch(id).unwrap(), s.doc.sketch(id).unwrap());
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(sk(&s).point_index(&corner).is_none());
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert!(sk(&s).point_index(&corner).is_some());
|
||||
// A fillet without anything measuring to its corner leaves no virtual corner behind.
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.line", json!({"points": [[10, 10], [10, 100], [-80, 100]], "ids": ["riser", "top"]}));
|
||||
let n = sk(&s).points.len();
|
||||
let f = run(&mut s, "sketch.fillet", json!({"point": "riser.end", "radius": 20}));
|
||||
assert_eq!(f["virtual_corners"], json!([]), "{f}");
|
||||
// Corner gone, two tangent points and the arc's centre added.
|
||||
assert_eq!(sk(&s).points.len(), n + 2);
|
||||
}
|
||||
|
||||
/// Lines meeting almost straight or almost folded back: a fillet or chamfer with dimensions on
|
||||
/// the corner either works or refuses with an error, never a panic.
|
||||
#[test]
|
||||
fn a_fillet_of_a_nearly_straight_or_folded_corner_with_dimensions_does_not_panic() {
|
||||
for (x, y) in [(100.0, 0.05), (100.0, -0.05), (100.0, 1e-9), (-100.0, 0.05), (-100.0, 1e-9)] {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.line", json!({"points": [[0, 0], [100, 0], [100.0 + x, y]], "ids": ["a", "b"]}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["a"], "value": 100}));
|
||||
for r in [0.01, 1.0, 50.0] {
|
||||
let _ = s.execute("sketch.fillet", &json!({"point": "a.end", "radius": r}));
|
||||
let _ = s.execute("sketch.chamfer.equal_distance", &json!({"point": "a.end", "distance": r}));
|
||||
}
|
||||
assert!(sk(&s).points.iter().all(|p| p.pos.is_finite()));
|
||||
}
|
||||
}
|
||||
@@ -10,83 +10,81 @@ use super::{CommandSpec, in_sketch};
|
||||
use crate::params::{bad, num, req_vec2, str_, string_list};
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] =
|
||||
&[
|
||||
CommandSpec::new("sketch.line.midpoint", "Midpoint Line", midpoint_line)
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("line_mid")
|
||||
.enabled(in_sketch)
|
||||
.params("mid: [x,y] or point ref, end: [x,y]; construction?"),
|
||||
CommandSpec::new("sketch.arc.tangent", "Tangent Arc", arc_tangent).at("SKETCH", "CREATE").icon("arc_tangent").enabled(in_sketch).params(
|
||||
"start: end point of a line or arc (\"l1.end\"), end: [x,y]; curve?: the curve to be tangent to (default: the one ending at start)",
|
||||
),
|
||||
CommandSpec::new("sketch.circle.two_tangent", "2-Tangent Circle", circle_tan_tan)
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("circle_tt")
|
||||
.enabled(in_sketch)
|
||||
.params("curves: [two lines/circles/arcs], radius | diameter: expr, near: [x,y] (which of the solutions)"),
|
||||
CommandSpec::new("sketch.circle.three_tangent", "3-Tangent Circle", circle_three_tan)
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("circle_ttt")
|
||||
.enabled(in_sketch)
|
||||
.params("curves: [three lines/circles/arcs], near: [x,y] (which of the solutions)"),
|
||||
CommandSpec::new("sketch.slot.center_point", "Center Point Slot", slot_center_point)
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("slot")
|
||||
.enabled(in_sketch)
|
||||
.params("center: slot centre, end: one arc centre, width"),
|
||||
CommandSpec::new("sketch.slot.arc_three_point", "Three Point Arc Slot", arc_slot_three)
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("arc_slot")
|
||||
.enabled(in_sketch)
|
||||
.params("start, through, end: points on the centre arc, width"),
|
||||
CommandSpec::new("sketch.slot.arc_center", "Center Point Arc Slot", arc_slot_center)
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("arc_slot")
|
||||
.enabled(in_sketch)
|
||||
.params("center, start: centre arc start, end: direction of the centre arc end (counter-clockwise), width"),
|
||||
CommandSpec::new("sketch.ellipse", "Ellipse", ellipse)
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("ellipse")
|
||||
.enabled(in_sketch)
|
||||
.params("center, major: end of the major axis [x,y], minor: a point the ellipse passes through on the minor side [x,y] | minor_radius"),
|
||||
CommandSpec::new("sketch.spline.fit_point", "Fit Point Spline", spline_fit)
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("spline")
|
||||
.enabled(in_sketch)
|
||||
.params("points: [[x,y] or point refs…] (2…500), periodic?: boolean (default false); periodic cubic fit closes smoothly through at least 3 distinct samples"),
|
||||
CommandSpec::new("sketch.spline.control_point", "Control Point Spline", spline_cv3)
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("spline_cv")
|
||||
.enabled(in_sketch)
|
||||
.params("points: control points (2…500), cubic"),
|
||||
CommandSpec::new("sketch.spline.control_point_5", "Control Point Spline (Degree 5)", spline_cv5)
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("spline_cv")
|
||||
.enabled(in_sketch)
|
||||
.params("points: control points (2…500), degree 5"),
|
||||
CommandSpec::new("sketch.conic", "Conic Curve", conic)
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("conic")
|
||||
.enabled(in_sketch)
|
||||
.params("start, end, apex: [x,y] or point refs; rho?: 0…1 (default 0.5: parabola)"),
|
||||
CommandSpec::new("sketch.blend_curve", "Blend Curve", blend)
|
||||
.at("SKETCH", "MODIFY")
|
||||
.icon("blend")
|
||||
.enabled(in_sketch)
|
||||
.params("a, b: end points of two curves (\"l1.end\", \"a2.start\"): a smooth (tangent) spline joining them"),
|
||||
CommandSpec::new("sketch.centerline", "Centerline", centerline)
|
||||
.enabled(in_sketch)
|
||||
.params("curves: [line ids], value?: bool (default toggles): centerline line type (not in profiles; an axis)"),
|
||||
CommandSpec::new("sketch.text", "Text", text)
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("text")
|
||||
.enabled(in_sketch)
|
||||
.params("text: string (\\n breaks lines), at: baseline start [x,y], height?: capital height mm (default 5), angle?: deg"),
|
||||
CommandSpec::new("sketch.edit_text", "Edit Text", edit_text)
|
||||
.enabled(in_sketch)
|
||||
.params("link: the text's link id (or entity: one of its curves), text?, at?, height?, angle?: deg"),
|
||||
];
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("sketch.line.midpoint", "Midpoint Line", midpoint_line)
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("line_mid")
|
||||
.enabled(in_sketch)
|
||||
.params(concat!("mid: [x,y] or point ref, end: [x,y]; construction?", created_doc!())),
|
||||
CommandSpec::new("sketch.arc.tangent", "Tangent Arc", arc_tangent).at("SKETCH", "CREATE").icon("arc_tangent").enabled(in_sketch).params(concat!(
|
||||
"start: end point of a line or arc (\"l1.end\"), end: [x,y]; curve?: the curve to be tangent to (default: the one ending at start)",
|
||||
created_doc!()
|
||||
)),
|
||||
CommandSpec::new("sketch.circle.two_tangent", "2-Tangent Circle", circle_tan_tan)
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("circle_tt")
|
||||
.enabled(in_sketch)
|
||||
.params(concat!("curves: [two lines/circles/arcs], radius | diameter: expr, near: [x,y] (which of the solutions)", created_doc!())),
|
||||
CommandSpec::new("sketch.circle.three_tangent", "3-Tangent Circle", circle_three_tan)
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("circle_ttt")
|
||||
.enabled(in_sketch)
|
||||
.params(concat!("curves: [three lines/circles/arcs], near: [x,y] (which of the solutions)", created_doc!())),
|
||||
CommandSpec::new("sketch.slot.center_point", "Center Point Slot", slot_center_point)
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("slot")
|
||||
.enabled(in_sketch)
|
||||
.params(concat!("center: slot centre, end: one arc centre, width", created_doc!())),
|
||||
CommandSpec::new("sketch.slot.arc_three_point", "Three Point Arc Slot", arc_slot_three)
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("arc_slot")
|
||||
.enabled(in_sketch)
|
||||
.params(concat!("start, through, end: points on the centre arc, width", created_doc!())),
|
||||
CommandSpec::new("sketch.slot.arc_center", "Center Point Arc Slot", arc_slot_center)
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("arc_slot")
|
||||
.enabled(in_sketch)
|
||||
.params(concat!("center, start: centre arc start, end: direction of the centre arc end (counter-clockwise), width", created_doc!())),
|
||||
CommandSpec::new("sketch.ellipse", "Ellipse", ellipse).at("SKETCH", "CREATE").icon("ellipse").enabled(in_sketch).params(concat!(
|
||||
"center, major: end of the major axis [x,y], minor: a point the ellipse passes through on the minor side [x,y] | minor_radius",
|
||||
created_doc!()
|
||||
)),
|
||||
CommandSpec::new("sketch.spline.fit_point", "Fit Point Spline", spline_fit)
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("spline")
|
||||
.enabled(in_sketch)
|
||||
.params(concat!("points: [[x,y] or point refs…] (2…500): the spline passes through them, periodic?: boolean (default false); periodic cubic fit closes smoothly through at least 3 distinct samples", created_doc!())),
|
||||
CommandSpec::new("sketch.spline.control_point", "Control Point Spline", spline_cv3)
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("spline_cv")
|
||||
.enabled(in_sketch)
|
||||
.params(concat!("points: control points (2…500), cubic", created_doc!())),
|
||||
CommandSpec::new("sketch.spline.control_point_5", "Control Point Spline (Degree 5)", spline_cv5)
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("spline_cv")
|
||||
.enabled(in_sketch)
|
||||
.params(concat!("points: control points (2…500), degree 5", created_doc!())),
|
||||
CommandSpec::new("sketch.conic", "Conic Curve", conic)
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("conic")
|
||||
.enabled(in_sketch)
|
||||
.params(concat!("start, end, apex: [x,y] or point refs; rho?: 0…1 (default 0.5: parabola)", created_doc!())),
|
||||
CommandSpec::new("sketch.blend_curve", "Blend Curve", blend)
|
||||
.at("SKETCH", "MODIFY")
|
||||
.icon("blend")
|
||||
.enabled(in_sketch)
|
||||
.params(concat!("a, b: end points of two curves (\"l1.end\", \"a2.start\"): a smooth (tangent) spline joining them", created_doc!())),
|
||||
CommandSpec::new("sketch.centerline", "Centerline", centerline)
|
||||
.enabled(in_sketch)
|
||||
.params("curves: [line ids], value?: bool (default toggles): centerline line type (not in profiles; an axis)"),
|
||||
CommandSpec::new("sketch.text", "Text", text).at("SKETCH", "CREATE").icon("text").enabled(in_sketch).params(concat!(
|
||||
"text: string (\\n breaks lines), at: baseline start [x,y], height?: capital height mm (default 5), angle?: deg",
|
||||
created_doc!()
|
||||
)),
|
||||
CommandSpec::new("sketch.edit_text", "Edit Text", edit_text)
|
||||
.enabled(in_sketch)
|
||||
.params(concat!("link: the text's link id (or entity: one of its curves), text?, at?, height?, angle?: deg", created_doc!())),
|
||||
];
|
||||
|
||||
fn text_source(p: &Value, cmd: &str, old: Option<(String, Vec2, f64, f64)>) -> Result<LinkSource> {
|
||||
let (t0, at0, h0, a0) = old.unwrap_or((String::new(), Vec2::ZERO, 5.0, 0.0));
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
//! Every command that adds sketch geometry returns `created: {points, curves, constraints,
|
||||
//! dimensions}` with the real ids of what it added, so a script can dimension and constrain the
|
||||
//! new geometry right away; ids are never reused after a delete.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_sketch::{CurveKind, Sketch};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
match s.execute(id, &p) {
|
||||
Ok(v) => v,
|
||||
Err(e) => panic!("{id} {p}: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn strs(v: &Value) -> Vec<String> {
|
||||
v.as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn sketch(s: &Session) -> Sketch {
|
||||
s.doc.sketch(s.active_sketch.unwrap()).unwrap().clone()
|
||||
}
|
||||
|
||||
fn new_sketch() -> Session {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
s
|
||||
}
|
||||
|
||||
/// The ids in `r.created` exist in the active sketch, and each curve and point takes a
|
||||
/// dimension (driven, so it never conflicts) and a fix right away. `before`: the sketch before
|
||||
/// the command. Returns `created`.
|
||||
fn check(s: &mut Session, cmd: &str, r: &Value, before: &Sketch) -> Value {
|
||||
let c = r.get("created").cloned().unwrap_or_else(|| panic!("{cmd}: no `created` in {r}"));
|
||||
for k in ["points", "curves", "constraints", "dimensions"] {
|
||||
assert!(c[k].is_array(), "{cmd}: created.{k} in {c}");
|
||||
}
|
||||
let sk = sketch(s);
|
||||
let curves = strs(&c["curves"]);
|
||||
let points = strs(&c["points"]);
|
||||
assert!(!curves.is_empty() || !points.is_empty() || c.get("wires").is_some(), "{cmd}: nothing created: {c}");
|
||||
for id in &points {
|
||||
assert!(sk.point_index(id).is_some(), "{cmd}: point {id} not in the sketch");
|
||||
assert_ne!(id, "origin", "{cmd}");
|
||||
}
|
||||
for id in strs(&c["constraints"]).iter().chain(&strs(&c["dimensions"])) {
|
||||
assert!(sk.constraints.iter().any(|k| &k.id == id), "{cmd}: constraint {id} not in the sketch");
|
||||
}
|
||||
for id in strs(&c["dimensions"]) {
|
||||
assert!(sk.constraints.iter().any(|k| k.id == id && k.kind.is_dimension()), "{cmd}: {id} is not a dimension");
|
||||
}
|
||||
for id in strs(&c.get("wires").cloned().unwrap_or_default()) {
|
||||
assert!(sk.wire_index(&id).is_some(), "{cmd}: wire {id} not in the sketch");
|
||||
}
|
||||
// The new curves in the command's own list (where it has one) are in `created`.
|
||||
let wires = strs(&c.get("wires").cloned().unwrap_or_default());
|
||||
for id in strs(&r["curves"]) {
|
||||
let old = before.curve_index(&id).is_some() || before.wire_index(&id).is_some();
|
||||
assert!(old || curves.contains(&id) || wires.contains(&id), "{cmd}: returned curve {id} missing from created {c}");
|
||||
}
|
||||
for id in curves.iter().chain(&points) {
|
||||
assert!(before.curve_index(id).is_none() && before.point_index(id).is_none(), "{cmd}: {id} is not new");
|
||||
}
|
||||
for id in &curves {
|
||||
let i = sk.curve_index(id).unwrap_or_else(|| panic!("{cmd}: curve {id} not in the sketch"));
|
||||
let measurable = matches!(sk.curves[i].kind, CurveKind::Line { .. } | CurveKind::Circle { .. } | CurveKind::Arc { .. });
|
||||
if measurable {
|
||||
run(s, "sketch.dimension", json!({"entities": [id], "driven": true}));
|
||||
}
|
||||
run(s, "sketch.constraint.fix", json!({"entity": id, "fixed": true}));
|
||||
run(s, "edit.undo", json!({}));
|
||||
if measurable {
|
||||
run(s, "edit.undo", json!({}));
|
||||
}
|
||||
}
|
||||
for id in &points {
|
||||
run(s, "sketch.dimension", json!({"entities": [id, "origin"], "driven": true}));
|
||||
run(s, "edit.undo", json!({}));
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
/// Run `cmd` in `s` and check what it reports.
|
||||
fn made(s: &mut Session, cmd: &str, p: Value) -> Value {
|
||||
let before = sketch(s);
|
||||
let r = run(s, cmd, p);
|
||||
check(s, cmd, &r, &before)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drawing_commands_return_the_ids_they_create() {
|
||||
let cases: Vec<(&str, Value)> = vec![
|
||||
("sketch.line", json!({"points": [[0, 0], [10, 0], [10, 5]]})),
|
||||
("sketch.rectangle.two_point", json!({"p0": [1, 1], "p1": [21, 15]})),
|
||||
("sketch.rectangle.three_point", json!({"p0": [0, 0], "p1": [20, 5], "p2": [18, 15]})),
|
||||
("sketch.rectangle.center", json!({"center": [3, 2], "corner": [13, 9]})),
|
||||
("sketch.circle.center", json!({"center": [5, 5], "diameter": 8})),
|
||||
("sketch.circle.two_point", json!({"p0": [0, 0], "p1": [10, 0]})),
|
||||
("sketch.circle.three_point", json!({"p0": [0, 0], "p1": [10, 0], "p2": [5, 6]})),
|
||||
("sketch.arc.three_point", json!({"start": [0, 0], "end": [10, 0], "through": [5, 4]})),
|
||||
("sketch.arc.center_point", json!({"center": [0, 0], "start": [10, 0], "end": [0, 10]})),
|
||||
("sketch.polygon.inscribed", json!({"center": [0, 0], "radius": 10, "sides": 6})),
|
||||
("sketch.polygon.circumscribed", json!({"center": [0, 0], "radius": 10, "sides": 5})),
|
||||
("sketch.polygon.edge", json!({"p0": [0, 0], "p1": [10, 0], "sides": 4})),
|
||||
("sketch.slot.center_to_center", json!({"p0": [0, 0], "p1": [20, 0], "width": 6})),
|
||||
("sketch.slot.overall", json!({"p0": [0, 0], "p1": [20, 0], "width": 6})),
|
||||
("sketch.slot.center_point", json!({"center": [0, 0], "end": [10, 0], "width": 4})),
|
||||
("sketch.slot.arc_three_point", json!({"start": [0, 0], "through": [10, 5], "end": [20, 0], "width": 3})),
|
||||
("sketch.slot.arc_center", json!({"center": [0, 0], "start": [10, 0], "end": [0, 10], "width": 3})),
|
||||
("sketch.point", json!({"point": [3, 4]})),
|
||||
("sketch.line.midpoint", json!({"mid": [0, 0], "end": [10, 5]})),
|
||||
("sketch.ellipse", json!({"center": [0, 0], "major": [10, 0], "minor_radius": 4})),
|
||||
("sketch.spline.fit_point", json!({"points": [[0, 0], [5, 5], [10, 0]]})),
|
||||
("sketch.spline.fit_point", json!({"points": [[0, 0], [5, 5], [10, 0]], "periodic": true})),
|
||||
("sketch.spline.control_point", json!({"points": [[0, 0], [5, 5], [10, 0], [15, 5]]})),
|
||||
("sketch.spline.control_point_5", json!({"points": [[0, 0], [3, 5], [6, 0], [9, 5], [12, 0], [15, 5]]})),
|
||||
("sketch.conic", json!({"start": [0, 0], "end": [10, 0], "apex": [5, 6]})),
|
||||
("sketch.text", json!({"text": "IT", "at": [0, 0], "height": 10})),
|
||||
];
|
||||
for (cmd, p) in cases {
|
||||
let mut s = new_sketch();
|
||||
made(&mut s, cmd, p);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drawn_ids_take_driving_dimensions_and_constraints() {
|
||||
let mut s = new_sketch();
|
||||
let c = made(&mut s, "sketch.line", json!({"points": [[0, 0], [10, 0]], "connect": false}));
|
||||
let l = strs(&c["curves"]);
|
||||
let pts = strs(&c["points"]);
|
||||
assert_eq!((l.len(), pts.len()), (1, 2), "{c}");
|
||||
run(&mut s, "sketch.constraint.coincident", json!({"a": pts[0], "b": "origin"}));
|
||||
run(&mut s, "sketch.constraint.horizontal_vertical", json!({"line": l[0]}));
|
||||
let d = run(&mut s, "sketch.dimension", json!({"entities": [l[0]], "value": 30}));
|
||||
assert_eq!(strs(&d["created"]["dimensions"]).len(), 1, "{d}");
|
||||
let sk = sketch(&s);
|
||||
let end = sk.point(sk.point_index(&pts[1]).unwrap()).unwrap();
|
||||
assert!((end.x - 30.0).abs() < 1e-6 && end.y.abs() < 1e-6, "{end:?}");
|
||||
|
||||
let c = made(&mut s, "sketch.circle.center", json!({"center": [50, 0], "radius": 4}));
|
||||
let circle = strs(&c["curves"]);
|
||||
run(&mut s, "sketch.dimension", json!({"entities": [circle[0]], "type": "diameter", "value": 12}));
|
||||
let sk = sketch(&s);
|
||||
let i = sk.curve_index(&circle[0]).unwrap();
|
||||
assert!(matches!(sk.curves[i].kind, CurveKind::Circle { r, .. } if (r - 6.0).abs() < 1e-6));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modify_commands_return_the_ids_they_create() {
|
||||
// Corner tools: an open L of two lines.
|
||||
for (cmd, extra) in [
|
||||
("sketch.fillet", json!({"radius": 2})),
|
||||
("sketch.chamfer.equal_distance", json!({"distance": 2})),
|
||||
("sketch.chamfer.distance_angle", json!({"distance": 2, "angle": 40})),
|
||||
("sketch.chamfer.two_distance", json!({"distance": 2, "distance2": 3})),
|
||||
] {
|
||||
let mut s = new_sketch();
|
||||
let c = made(&mut s, "sketch.line", json!({"points": [[0, 10], [0, 0], [10, 0]]}));
|
||||
let l = strs(&c["curves"]);
|
||||
let mut p = extra;
|
||||
p["point"] = json!(format!("{}.end", l[0]));
|
||||
let c = made(&mut s, cmd, p);
|
||||
assert!(!strs(&c["curves"]).is_empty(), "{cmd}: {c}");
|
||||
}
|
||||
|
||||
let mut s = new_sketch();
|
||||
let rect = strs(&made(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [20, 10]}))["curves"]);
|
||||
let c = made(&mut s, "sketch.offset", json!({"curves": [rect[0]], "distance": 2, "side": [10, 5]}));
|
||||
assert_eq!(strs(&c["curves"]).len(), 4, "{c}");
|
||||
made(&mut s, "sketch.move", json!({"entities": [rect[0]], "translate": [0, -20], "copy": true}));
|
||||
made(&mut s, "sketch.pattern.rectangular", json!({"entities": [rect[2]], "count": 3, "spacing": 30}));
|
||||
let circle = strs(&made(&mut s, "sketch.circle.center", json!({"center": [100, 0], "radius": 2}))["curves"]);
|
||||
made(&mut s, "sketch.pattern.circular", json!({"entities": [circle[0]], "center": [90, 0], "count": 4}));
|
||||
let axis = strs(&made(&mut s, "sketch.line", json!({"points": [[200, -10], [200, 10]]}))["curves"]);
|
||||
let sm = strs(&made(&mut s, "sketch.circle.center", json!({"center": [190, 0], "radius": 2}))["curves"]);
|
||||
made(&mut s, "sketch.mirror", json!({"entities": [sm[0]], "line": axis[0]}));
|
||||
|
||||
// Break and trim split a curve where another crosses it.
|
||||
let mut s = new_sketch();
|
||||
let h = strs(&made(&mut s, "sketch.line", json!({"points": [[0, 0], [20, 0]]}))["curves"]);
|
||||
made(&mut s, "sketch.line", json!({"points": [[10, -5], [10, 5]]}));
|
||||
made(&mut s, "sketch.break", json!({"curve": h[0], "at": [5, 0]}));
|
||||
let mut s = new_sketch();
|
||||
let h = strs(&made(&mut s, "sketch.line", json!({"points": [[0, 0], [30, 0]]}))["curves"]);
|
||||
made(&mut s, "sketch.line", json!({"points": [[10, -5], [10, 5]]}));
|
||||
made(&mut s, "sketch.line", json!({"points": [[20, -5], [20, 5]]}));
|
||||
made(&mut s, "sketch.trim", json!({"curve": h[0], "at": [15, 0]}));
|
||||
|
||||
// Tangent tools and blends start from existing curves.
|
||||
let mut s = new_sketch();
|
||||
let l = strs(&made(&mut s, "sketch.line", json!({"points": [[0, 0], [10, 0]]}))["curves"]);
|
||||
made(&mut s, "sketch.arc.tangent", json!({"start": format!("{}.end", l[0]), "end": [20, 10]}));
|
||||
let a = strs(&made(&mut s, "sketch.line", json!({"points": [[0, 30], [10, 30]]}))["curves"]);
|
||||
let b = strs(&made(&mut s, "sketch.line", json!({"points": [[20, 40], [30, 40]]}))["curves"]);
|
||||
made(&mut s, "sketch.blend_curve", json!({"a": format!("{}.end", a[0]), "b": format!("{}.start", b[0])}));
|
||||
let x = strs(&made(&mut s, "sketch.line", json!({"points": [[100, 0], [140, 0]], "connect": false}))["curves"]);
|
||||
let y = strs(&made(&mut s, "sketch.line", json!({"points": [[100, 0], [100, 40]], "connect": false}))["curves"]);
|
||||
made(&mut s, "sketch.circle.two_tangent", json!({"curves": [x[0], y[0]], "radius": 5, "near": [106, 4]}));
|
||||
let z = strs(&made(&mut s, "sketch.line", json!({"points": [[100, 40], [140, 40]], "connect": false}))["curves"]);
|
||||
made(&mut s, "sketch.circle.three_tangent", json!({"curves": [x[0], y[0], z[0]], "near": [120, 20]}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dimensioned_drawing_reports_its_geometry_and_driving_dimensions() {
|
||||
// The creator runs nested inside sketch.draw.dimensioned; `created` must still cover the
|
||||
// dimensions the wrapper adds afterwards, not just the nested creator's geometry.
|
||||
for (cmd, p, dims) in [
|
||||
("sketch.line", json!({"points": [[0, 0], [5, 5]]}), json!({"length": 10, "angle": 45})),
|
||||
("sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [20, 10]}), json!({"width": 30, "height": 15})),
|
||||
("sketch.circle.center", json!({"center": [0, 0], "radius": 3}), json!({"diameter": 12})),
|
||||
("sketch.polygon.inscribed", json!({"center": [0, 0], "radius": 10, "sides": 6}), json!({"radius": 8})),
|
||||
] {
|
||||
let mut s = new_sketch();
|
||||
let c = made(&mut s, "sketch.draw.dimensioned", json!({"command": cmd, "params": p, "dimensions": dims}));
|
||||
let n = dims.as_object().map_or(0, |d| d.len());
|
||||
assert_eq!(strs(&c["dimensions"]).len(), n, "{cmd}: {c}");
|
||||
assert!(!strs(&c["curves"]).is_empty(), "{cmd}: {c}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn breaking_an_offset_association_creates_nothing() {
|
||||
let mut s = new_sketch();
|
||||
let e = strs(&made(&mut s, "sketch.ellipse", json!({"center": [0, 0], "major": [10, 0], "minor_radius": 5, "connect": false}))["curves"]);
|
||||
let c = made(&mut s, "sketch.offset", json!({"curves": [e[0]], "distance": -1, "chain": false}));
|
||||
let out = strs(&c["curves"]);
|
||||
let r = run(&mut s, "sketch.offset.break", json!({"curve": out[0]}));
|
||||
let c = &r["created"];
|
||||
for k in ["points", "curves", "constraints", "dimensions"] {
|
||||
assert_eq!(c[k], json!([]), "{r}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_edits_and_imports_return_the_ids_they_create() {
|
||||
let mut s = new_sketch();
|
||||
let r = run(&mut s, "sketch.text", json!({"text": "I", "at": [0, 0], "height": 10}));
|
||||
check(&mut s, "sketch.text", &r, &Sketch::new());
|
||||
let before = sketch(&s);
|
||||
let link = r["link"].as_str().map(str::to_string).unwrap_or_else(|| panic!("{r}"));
|
||||
let r = run(&mut s, "sketch.edit_text", json!({"link": link, "text": "II"}));
|
||||
check(&mut s, "sketch.edit_text", &r, &before);
|
||||
|
||||
let svg = r#"<svg width="40mm" height="40mm" viewBox="0 0 40 40"><path d="M0 0 H40 V40 H0 Z"/></svg>"#;
|
||||
let mut s = new_sketch();
|
||||
let c = made(&mut s, "sketch.insert_svg", json!({"text": svg}));
|
||||
assert_eq!(strs(&c["curves"]).len(), 4, "{c}");
|
||||
let dxf = run(&mut s, "sketch.export_dxf", json!({}))["dxf"].as_str().unwrap_or_default().to_string();
|
||||
let mut s = new_sketch();
|
||||
let c = made(&mut s, "sketch.insert_dxf", json!({"text": dxf}));
|
||||
assert_eq!(strs(&c["curves"]).len(), 4, "{c}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projections_and_3d_curves_return_the_ids_they_create() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"corner": [0, 0, 0], "length": 20, "width": 20, "height": 10}));
|
||||
let body = s.model.state().bodies.first().map(|b| b.name.clone()).unwrap_or_default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "offset": 5}));
|
||||
made(&mut s, "sketch.project", json!({"refs": [{"edge": [10, 0, 10]}]}));
|
||||
made(&mut s, "sketch.intersect", json!({"refs": [{"body": body}]}));
|
||||
made(&mut s, "sketch.include_3d", json!({"refs": [{"edge": [20, 10, 10]}]}));
|
||||
made(&mut s, "sketch.fit_curves_to_section", json!({"body": body}));
|
||||
made(&mut s, "sketch.isoparametric_curve", json!({"face": [10, 10, 10]}));
|
||||
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.options", json!({"sketch_3d": true}));
|
||||
let c = made(&mut s, "sketch.line3d", json!({"points": [[0, 0, 0], [0, 0, 10], [10, 0, 10]]}));
|
||||
assert_eq!(strs(&c["wires"]).len(), 2, "{c}");
|
||||
made(&mut s, "sketch.spline3d", json!({"points": [[0, 0, 0], [5, 5, 5], [10, 0, 10]]}));
|
||||
made(&mut s, "sketch.point3d", json!({"point": [5, 5, 5]}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_sketch_reports_its_projected_face_edges() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"corner": [0, 0, 0], "length": 20, "width": 20, "height": 10}));
|
||||
let r = run(&mut s, "sketch.create", json!({"plane": {"face": [10, 10, 10]}}));
|
||||
let c = check(&mut s, "sketch.create", &r, &Sketch::new());
|
||||
assert_eq!(strs(&c["curves"]).len(), 4, "{c}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ids_are_never_reused_after_a_delete() {
|
||||
let mut s = new_sketch();
|
||||
let first = made(&mut s, "sketch.line", json!({"points": [[0, 0], [10, 0]]}));
|
||||
let circle = made(&mut s, "sketch.circle.center", json!({"center": [30, 0], "radius": 3}));
|
||||
let dim = run(&mut s, "sketch.dimension", json!({"entities": [strs(&circle["curves"])[0]], "value": 8}));
|
||||
let mut gone: Vec<String> = ["points", "curves"].iter().flat_map(|k| strs(&first[k]).into_iter().chain(strs(&circle[k]))).collect();
|
||||
gone.extend(strs(&dim["created"]["dimensions"]));
|
||||
// Deleting the curves takes their own end and centre points (and the dimension) with them.
|
||||
run(&mut s, "sketch.delete", json!({"entities": strs(&first["curves"]).into_iter().chain(strs(&circle["curves"])).collect::<Vec<_>>()}));
|
||||
let sk = sketch(&s);
|
||||
assert!(sk.curves.is_empty() && sk.points.len() == 1 && sk.constraints.is_empty(), "{:?}", sk.curves);
|
||||
// The same commands again: every id is new.
|
||||
let again = made(&mut s, "sketch.line", json!({"points": [[0, 0], [10, 0]]}));
|
||||
let circle2 = made(&mut s, "sketch.circle.center", json!({"center": [30, 0], "radius": 3}));
|
||||
let dim2 = run(&mut s, "sketch.dimension", json!({"entities": [strs(&circle2["curves"])[0]], "value": 8}));
|
||||
let mut fresh: Vec<String> = ["points", "curves"].iter().flat_map(|k| strs(&again[k]).into_iter().chain(strs(&circle2[k]))).collect();
|
||||
fresh.extend(strs(&dim2["created"]["dimensions"]));
|
||||
assert_eq!(fresh.len(), gone.len(), "{fresh:?} {gone:?}");
|
||||
for id in &fresh {
|
||||
assert!(!gone.contains(id), "id {id} reused: {gone:?} -> {fresh:?}");
|
||||
}
|
||||
// Undo of a delete brings the old ids back unchanged (they still name the same geometry).
|
||||
let line = strs(&again["curves"])[0].clone();
|
||||
run(&mut s, "sketch.delete", json!({"entities": [line]}));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(sketch(&s).curve_index(&line).is_some());
|
||||
}
|
||||
|
||||
/// The example in docs/control-protocol.md.
|
||||
#[test]
|
||||
fn documented_example_holds() {
|
||||
let mut s = new_sketch();
|
||||
let r = run(&mut s, "sketch.line", json!({"points": [[0, 0], [40, 0]]}));
|
||||
assert_eq!(r["curves"], json!(["l1"]));
|
||||
assert_eq!(r["created"], json!({"points": ["l1.start", "l1.end"], "curves": ["l1"], "constraints": ["k1"], "dimensions": []}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 50}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_that_add_nothing_report_empty_lists() {
|
||||
let mut s = new_sketch();
|
||||
let c = made(&mut s, "sketch.line", json!({"points": [[0, 0], [10, 5]]}));
|
||||
let r = run(&mut s, "sketch.move_point", json!({"point": strs(&c["points"])[1], "to": [12, 6]}));
|
||||
assert_eq!(r["created"], json!({"points": [], "curves": [], "constraints": [], "dimensions": []}), "{r}");
|
||||
}
|
||||
@@ -16,11 +16,11 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("sketch.insert_dxf", "Insert DXF", insert_dxf)
|
||||
.at("SKETCH", "INSERT")
|
||||
.icon("dxf")
|
||||
.params("path | text: ASCII DXF (blocks expanded); at?: [x,y] offset, scale?, tolerance?: mm within which ends join (default 0.0001); plane?: (when no sketch is active, a new sketch on it; default XY)"),
|
||||
.params(concat!("path | text: ASCII DXF (blocks expanded); at?: [x,y] offset, scale?, tolerance?: mm within which ends join (default 0.0001); plane?: (when no sketch is active, a new sketch on it; default XY)", created_doc!())),
|
||||
CommandSpec::new("sketch.insert_svg", "Insert SVG", insert_svg)
|
||||
.at("SKETCH", "INSERT")
|
||||
.icon("svg")
|
||||
.params("path | text: SVG; at?: [x,y] offset, scale?, tolerance?: mm within which ends join (default 0.0001); plane?: (when no sketch is active, a new sketch on it; default XY)"),
|
||||
.params(concat!("path | text: SVG; at?: [x,y] offset, scale?, tolerance?: mm within which ends join (default 0.0001); plane?: (when no sketch is active, a new sketch on it; default XY)", created_doc!())),
|
||||
CommandSpec::new("sketch.export_dxf", "Save As DXF", export_dxf)
|
||||
.noundo()
|
||||
.params("sketch?: id|name (default active); path?: file to write (else the DXF text is returned)"),
|
||||
|
||||
@@ -20,28 +20,28 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.at("SKETCH", "MODIFY")
|
||||
.icon("sketch_fillet")
|
||||
.enabled(in_sketch)
|
||||
.params("point: corner shared by two curves (\"l1.end\") | points: [corners] (all rounded alike: one radius, the rest equal) | a, b: two lines or arcs meeting at a corner; radius: expr"),
|
||||
.params(concat!("point: corner shared by two curves (\"l1.end\") | points: [corners] (all rounded alike: one radius, the rest equal) | a, b: two lines or arcs meeting at a corner; radius: expr. A corner that dimensions or constraints measure to stays as a virtual sharp corner on both curves' extensions (`virtual_corners`; the moved ones in `moved_to_virtual_corner`)", created_doc!())),
|
||||
CommandSpec::new("sketch.chamfer.equal_distance", "Equal Distance Chamfer", chamfer_equal)
|
||||
.at("SKETCH", "MODIFY")
|
||||
.icon("sketch_chamfer")
|
||||
.enabled(in_sketch)
|
||||
.params("point | a, b: a corner of two lines; distance"),
|
||||
.params(concat!("point | a, b: a corner of two lines; distance", created_doc!())),
|
||||
CommandSpec::new("sketch.chamfer.distance_angle", "Distance and Angle Chamfer", chamfer_dist_angle)
|
||||
.at("SKETCH", "MODIFY")
|
||||
.icon("sketch_chamfer")
|
||||
.enabled(in_sketch)
|
||||
.params("point | a, b: a corner of two lines; distance: along a, angle: deg from a"),
|
||||
.params(concat!("point | a, b: a corner of two lines; distance: along a, angle: deg from a", created_doc!())),
|
||||
CommandSpec::new("sketch.chamfer.two_distance", "Two Distance Chamfer", chamfer_two)
|
||||
.at("SKETCH", "MODIFY")
|
||||
.icon("sketch_chamfer")
|
||||
.enabled(in_sketch)
|
||||
.params("point | a, b: a corner of two lines; distance: along a, distance2: along b"),
|
||||
.params(concat!("point | a, b: a corner of two lines; distance: along a, distance2: along b", created_doc!())),
|
||||
CommandSpec::new("sketch.offset", "Offset", offset)
|
||||
.at("SKETCH", "MODIFY")
|
||||
.icon("offset")
|
||||
.key("O")
|
||||
.enabled(in_sketch)
|
||||
.params("curves: [ids], distance: number (positive = left of the chain, or toward `side`), side?: [x,y], chain?: bool (default true: the connected chain)"),
|
||||
.params(concat!("curves: [ids], distance: number (positive = left of the chain, or toward `side`), side?: [x,y], chain?: bool (default true: the connected chain)", created_doc!())),
|
||||
CommandSpec::new("sketch.offset.break", "Break Offset Association", break_offset)
|
||||
.enabled(in_sketch)
|
||||
.params("curve: derived offset curve id (retains geometry and makes its points editable)"),
|
||||
@@ -50,7 +50,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.icon("trim")
|
||||
.key("T")
|
||||
.enabled(in_sketch)
|
||||
.params("curve: id, at: [x,y] (the piece to remove: between the nearest intersections around it)"),
|
||||
.params(concat!("curve: id, at: [x,y] (the piece to remove: between the nearest intersections around it)", created_doc!())),
|
||||
CommandSpec::new("sketch.extend", "Extend", extend)
|
||||
.at("SKETCH", "MODIFY")
|
||||
.icon("extend")
|
||||
@@ -60,7 +60,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.at("SKETCH", "MODIFY")
|
||||
.icon("break")
|
||||
.enabled(in_sketch)
|
||||
.params("curve: id, at: [x,y] (split at intersections) | points: [[x,y]…] (1…1000 positions projected onto the curve; duplicates are merged)"),
|
||||
.params(concat!("curve: id, at: [x,y] (split at intersections) | points: [[x,y]…] (1…1000 positions projected onto the curve; duplicates are merged)", created_doc!())),
|
||||
CommandSpec::new("sketch.scale", "Sketch Scale", scale)
|
||||
.at("SKETCH", "MODIFY")
|
||||
.icon("scale")
|
||||
@@ -68,22 +68,22 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.params("entities: [curve/point ids], base: [x,y] or point ref, factor: number"),
|
||||
CommandSpec::new("sketch.move", "Move/Copy Sketch Entities", move_copy)
|
||||
.enabled(in_sketch)
|
||||
.params("entities: [ids], translate?: [dx,dy], angle?: deg, center?: [x,y], copy?: bool"),
|
||||
.params(concat!("entities: [ids], translate?: [dx,dy], angle?: deg, center?: [x,y], copy?: bool", created_doc!())),
|
||||
CommandSpec::new("sketch.mirror", "Mirror", mirror)
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("sketch_mirror")
|
||||
.enabled(in_sketch)
|
||||
.params("entities: [curve/point ids], line: mirror line id (symmetry constraints are added)"),
|
||||
.params(concat!("entities: [curve/point ids], line: mirror line id (symmetry constraints are added)", created_doc!())),
|
||||
CommandSpec::new("sketch.pattern.circular", "Circular Pattern", pattern_circular)
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("sketch_circ_pattern")
|
||||
.enabled(in_sketch)
|
||||
.params("entities: [ids], center: [x,y] or point ref, count: 2…500, angle?: total deg (default 360)"),
|
||||
.params(concat!("entities: [ids], center: [x,y] or point ref, count: 2…500, angle?: total deg (default 360)", created_doc!())),
|
||||
CommandSpec::new("sketch.pattern.rectangular", "Rectangular Pattern", pattern_rect)
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("sketch_rect_pattern")
|
||||
.enabled(in_sketch)
|
||||
.params("entities: [ids], dir?: [x,y] (default x), count, spacing; count2?, spacing2? (perpendicular direction)"),
|
||||
.params(concat!("entities: [ids], dir?: [x,y] (default x), count, spacing; count2?, spacing2? (perpendicular direction)", created_doc!())),
|
||||
];
|
||||
|
||||
const TOL: f64 = 1e-7;
|
||||
@@ -505,8 +505,71 @@ fn reattach(sk: &mut Sketch, l: usize, corner: usize, np: usize) {
|
||||
}
|
||||
}
|
||||
|
||||
/// What became of a rounded or cut corner's point: kept as a virtual sharp corner (its id),
|
||||
/// and the constraints and dimensions that now measure to it.
|
||||
#[derive(Default)]
|
||||
struct CornerKept {
|
||||
virtual_corner: Option<String>,
|
||||
moved: Vec<String>,
|
||||
}
|
||||
|
||||
/// Keep corner point `c` of curves `ca`, `cb` (whose corner ends were just moved to `na`, `nb`)
|
||||
/// when anything measures to it: a constraint or dimension on the point, the length of either
|
||||
/// line, or it is the origin or still ends another curve. It stays as a virtual sharp corner,
|
||||
/// held on both curves' extensions (their intersection), and the line lengths become distances
|
||||
/// from the far end to it, so the part keeps its size, as in Fusion. Otherwise None: the
|
||||
/// caller removes the point.
|
||||
fn keep_corner(sk: &mut Sketch, c: usize, [(ca, na), (cb, nb)]: [(usize, usize); 2]) -> Result<Option<CornerKept>> {
|
||||
let used = sk.curves.iter().any(|x| uses(x, c));
|
||||
let mut moved: Vec<String> = sk.constraints.iter().filter(|k| k.kind.refers_to_point(c)).map(|k| k.id.clone()).collect();
|
||||
for k in sk.constraints.iter_mut() {
|
||||
let ConstraintKind::Length { l, value } = k.kind else { continue };
|
||||
let new_end = if l == ca {
|
||||
na
|
||||
} else if l == cb {
|
||||
nb
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
let Some(CurveKind::Line { a, b }) = sk.curves.get(l).map(|x| &x.kind) else { continue };
|
||||
// Same direction as the line, so the dimension keeps its side and text place.
|
||||
k.kind = if *a == new_end { ConstraintKind::Distance { p: c, q: *b, value } } else { ConstraintKind::Distance { p: *a, q: c, value } };
|
||||
moved.push(k.id.clone());
|
||||
}
|
||||
if moved.is_empty() && !used && c != 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
for cu in [ca, cb] {
|
||||
let k = ConstraintKind::PointOnCurve { p: c, c: cu };
|
||||
if !sk.constraints.iter().any(|x| x.kind == k) {
|
||||
add_c(sk, k)?;
|
||||
}
|
||||
}
|
||||
// A point of its own now: `l1.end` names the line's new end, not the virtual corner.
|
||||
let virtual_corner = if used || c == 0 {
|
||||
None
|
||||
} else {
|
||||
let id = sk.fresh("corner");
|
||||
if let Some(pt) = sk.points.get_mut(c) {
|
||||
pt.id = id.clone();
|
||||
}
|
||||
Some(id)
|
||||
};
|
||||
Ok(Some(CornerKept { virtual_corner, moved }))
|
||||
}
|
||||
|
||||
/// Points at distances `da` along a and `db` along b from the corner; lines shortened to them.
|
||||
fn cut_corner(sk: &mut Sketch, la: usize, lb: usize, c: usize, fa: usize, fb: usize, da: f64, db: f64, cmd: &str) -> Result<(usize, usize, Vec2)> {
|
||||
fn cut_corner(
|
||||
sk: &mut Sketch,
|
||||
la: usize,
|
||||
lb: usize,
|
||||
c: usize,
|
||||
fa: usize,
|
||||
fb: usize,
|
||||
da: f64,
|
||||
db: f64,
|
||||
cmd: &str,
|
||||
) -> Result<(usize, usize, Vec2, CornerKept)> {
|
||||
let (pc, pa, pb) = (sk.point(c).unwrap_or_default(), sk.point(fa).unwrap_or_default(), sk.point(fb).unwrap_or_default());
|
||||
if !(da > 1e-9 && db > 1e-9 && da < pc.dist(pa) - 1e-9 && db < pc.dist(pb) - 1e-9) {
|
||||
return Err(bad(cmd, "too large for these lines"));
|
||||
@@ -517,13 +580,14 @@ fn cut_corner(sk: &mut Sketch, la: usize, lb: usize, c: usize, fa: usize, fb: us
|
||||
let tb = sk.add_point(pc + v * db, None)?;
|
||||
reattach(sk, la, c, ta);
|
||||
reattach(sk, lb, c, tb);
|
||||
let used = sk.curves.iter().any(|x| uses(x, c));
|
||||
if !used && c != 0 {
|
||||
let kept = keep_corner(sk, c, [(la, ta), (lb, tb)])?;
|
||||
let removed = kept.is_none();
|
||||
if removed {
|
||||
sk.remove_points(&[c]);
|
||||
}
|
||||
// Indices may have shifted when the corner point was removed.
|
||||
let fix = |i: usize| if !used && c != 0 && i > c { i - 1 } else { i };
|
||||
Ok((fix(ta), fix(tb), pc))
|
||||
// Indices shift when the corner point was removed.
|
||||
let fix = |i: usize| if removed && i > c { i - 1 } else { i };
|
||||
Ok((fix(ta), fix(tb), pc, kept.unwrap_or_default()))
|
||||
}
|
||||
|
||||
fn fillet(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -541,9 +605,10 @@ fn fillet(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(cmd, "radius must be positive"));
|
||||
}
|
||||
let pname = doc.new_model_param(&r_expr, "mm");
|
||||
let (mut arcs, mut cons) = (Vec::new(), Vec::new());
|
||||
let (mut arcs, mut cons, mut kept) = (Vec::new(), Vec::new(), Vec::new());
|
||||
for q in &corners {
|
||||
let (arc, mut c) = fillet_corner(sk, q, r, cmd)?;
|
||||
let (arc, mut c, k) = fillet_corner(sk, q, r, cmd)?;
|
||||
kept.push(k);
|
||||
// One radius for them all: the first carries the dimension, the rest are equal to it.
|
||||
match arcs.first() {
|
||||
None => c.push(sk.add_constraint(ConstraintKind::Radius { c: arc, value: r }, Some(pname.clone()))?),
|
||||
@@ -552,14 +617,27 @@ fn fillet(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
cons.extend(c);
|
||||
arcs.push(arc);
|
||||
}
|
||||
Ok((ids_of(sk, &arcs), cons, pname))
|
||||
Ok((ids_of(sk, &arcs), cons, pname, kept_json(kept)))
|
||||
})?;
|
||||
Ok(json!({"curves": out.0, "constraints": out.1, "param": out.2, "sketch": info}))
|
||||
let (vc, moved) = out.3;
|
||||
Ok(json!({"curves": out.0, "constraints": out.1, "param": out.2, "virtual_corners": vc, "moved_to_virtual_corner": moved, "sketch": info}))
|
||||
}
|
||||
|
||||
/// The virtual corners kept and the constraints moved to them, for a response.
|
||||
fn kept_json(kept: Vec<CornerKept>) -> (Vec<String>, Vec<String>) {
|
||||
let vcs = kept.iter().filter_map(|k| k.virtual_corner.clone()).collect();
|
||||
let mut moved: Vec<String> = Vec::new();
|
||||
for id in kept.into_iter().flat_map(|k| k.moved) {
|
||||
if !moved.contains(&id) {
|
||||
moved.push(id);
|
||||
}
|
||||
}
|
||||
(vcs, moved)
|
||||
}
|
||||
|
||||
/// Round one corner (`point` or `a`, `b`) with an arc of radius `r` tangent to both curves:
|
||||
/// the arc and the tangent constraints.
|
||||
fn fillet_corner(sk: &mut Sketch, p: &Value, r: f64, cmd: &str) -> Result<(usize, Vec<String>)> {
|
||||
/// the arc, the tangent constraints and what became of the corner point.
|
||||
fn fillet_corner(sk: &mut Sketch, p: &Value, r: f64, cmd: &str) -> Result<(usize, Vec<String>, CornerKept)> {
|
||||
let (la, lb, c, fa, fb) = match corner(sk, p, cmd) {
|
||||
Ok(x) => x,
|
||||
// Not two lines: a corner with an arc.
|
||||
@@ -578,7 +656,7 @@ fn fillet_corner(sk: &mut Sketch, p: &Value, r: f64, cmd: &str) -> Result<(usize
|
||||
}
|
||||
let bis = (u + v).normalized().ok_or_else(|| bad(cmd, "the lines are parallel"))?;
|
||||
let center = pc + bis * (r / half.sin());
|
||||
let (ta, tb, _) = cut_corner(sk, la, lb, c, fa, fb, d, d, cmd)?;
|
||||
let (ta, tb, _, kept) = cut_corner(sk, la, lb, c, fa, fb, d, d, cmd)?;
|
||||
let (qa, qb) = (sk.point(ta).unwrap_or_default(), sk.point(tb).unwrap_or_default());
|
||||
let ccw = (qa - center).cross(qb - center) > 0.0;
|
||||
let arc = if ccw {
|
||||
@@ -587,13 +665,13 @@ fn fillet_corner(sk: &mut Sketch, p: &Value, r: f64, cmd: &str) -> Result<(usize
|
||||
sk.add_arc(center, qb, qa, [None, Some(tb), Some(ta)], None)?
|
||||
};
|
||||
let cons = vec![add_c(sk, ConstraintKind::Tangent { a: la, b: arc })?, add_c(sk, ConstraintKind::Tangent { a: lb, b: arc })?];
|
||||
Ok((arc, cons))
|
||||
Ok((arc, cons, kept))
|
||||
}
|
||||
|
||||
/// Fillet a corner where a line meets an arc, or two arcs meet: the arc of radius `r` touching
|
||||
/// both, nearest the corner; both curves are cut back to it. None when the corner is not of
|
||||
/// that kind.
|
||||
fn round_corner(sk: &mut Sketch, p: &Value, r: f64, cmd: &str) -> Result<Option<(usize, Vec<String>)>> {
|
||||
fn round_corner(sk: &mut Sketch, p: &Value, r: f64, cmd: &str) -> Result<Option<(usize, Vec<String>, CornerKept)>> {
|
||||
let ends = |c: usize| match sk.curves.get(c).map(|c| &c.kind) {
|
||||
Some(CurveKind::Line { a, b }) | Some(CurveKind::Arc { a, b, .. }) => Some((*a, *b)),
|
||||
_ => None,
|
||||
@@ -655,14 +733,15 @@ fn round_corner(sk: &mut Sketch, p: &Value, r: f64, cmd: &str) -> Result<Option<
|
||||
let arc =
|
||||
if ccw { sk.add_arc(cen, ta, tb, [None, Some(na), Some(nb)], None)? } else { sk.add_arc(cen, tb, ta, [None, Some(nb), Some(na)], None)? };
|
||||
let cons = vec![add_c(sk, ConstraintKind::Tangent { a: ca, b: arc })?, add_c(sk, ConstraintKind::Tangent { a: cb, b: arc })?];
|
||||
let used = sk.curves.iter().any(|x| x.kind.uses(q));
|
||||
if !used && q != 0 {
|
||||
let arc_id = sk.curves.get(arc).map(|c| c.id.clone()).unwrap_or_default();
|
||||
sk.remove_points(&[q]);
|
||||
let arc = sk.curve_index(&arc_id).unwrap_or(arc);
|
||||
return Ok(Some((arc, cons)));
|
||||
match keep_corner(sk, q, [(ca, na), (cb, nb)])? {
|
||||
Some(kept) => Ok(Some((arc, cons, kept))),
|
||||
None => {
|
||||
let arc_id = sk.curves.get(arc).map(|c| c.id.clone()).unwrap_or_default();
|
||||
sk.remove_points(&[q]);
|
||||
let arc = sk.curve_index(&arc_id).unwrap_or(arc);
|
||||
Ok(Some((arc, cons, CornerKept::default())))
|
||||
}
|
||||
}
|
||||
Ok(Some((arc, cons)))
|
||||
}
|
||||
|
||||
fn chamfer(s: &mut Session, p: &Value, cmd: &str, dists: impl FnOnce(f64) -> Result<(f64, f64)>) -> Result<Value> {
|
||||
@@ -672,11 +751,12 @@ fn chamfer(s: &mut Session, p: &Value, cmd: &str, dists: impl FnOnce(f64) -> Res
|
||||
let (u, v) = ((pa - pc).normalized().unwrap_or(Vec2::X), (pb - pc).normalized().unwrap_or(Vec2::Y));
|
||||
let corner_angle = u.cross(v).atan2(u.dot(v)).abs();
|
||||
let (da, db) = dists(corner_angle)?;
|
||||
let (ta, tb, _) = cut_corner(sk, la, lb, c, fa, fb, da, db, cmd)?;
|
||||
let (ta, tb, _, kept) = cut_corner(sk, la, lb, c, fa, fb, da, db, cmd)?;
|
||||
let l = sk.add_line_pts(ta, tb, None)?;
|
||||
Ok(ids_of(sk, &[l]))
|
||||
Ok((ids_of(sk, &[l]), kept_json(vec![kept])))
|
||||
})?;
|
||||
Ok(json!({"curves": out, "sketch": info}))
|
||||
let (curves, (vc, moved)) = out;
|
||||
Ok(json!({"curves": curves, "virtual_corners": vc, "moved_to_virtual_corner": moved, "sketch": info}))
|
||||
}
|
||||
|
||||
fn chamfer_equal(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
@@ -13,41 +13,47 @@ use super::{CommandSpec, in_sketch};
|
||||
use crate::params::{bad, bool_, str_, vec3};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
macro_rules! refs_doc {
|
||||
() => {
|
||||
"refs: [{edge: [x,y,z], body?} | {face: [x,y,z], body?} (its edge loops) | {body: name} (silhouette) | {vertex: [x,y,z], body?} | {sketch, curve} | {sketch, point} | \"origin\" | {axis: X|Y|Z} | {plane: XY|XZ|YZ|name}]"
|
||||
};
|
||||
}
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("sketch.project", "Project", project_cmd).at("SKETCH", "CREATE").icon("project").key("P").enabled(in_sketch).params(REFS_DOC),
|
||||
CommandSpec::new("sketch.project", "Project", project_cmd).at("SKETCH", "CREATE").icon("project").key("P").enabled(in_sketch).params(concat!(refs_doc!(), created_doc!())),
|
||||
CommandSpec::new("sketch.intersect", "Intersect", intersect_cmd).at("SKETCH", "CREATE").icon("intersect").enabled(in_sketch).params(
|
||||
"refs: [{body}, {face: [x,y,z], body?}, {edge: [x,y,z], body?}, {sketch, curve}]: section curves (or points) with the sketch plane, linked",
|
||||
concat!("refs: [{body}, {face: [x,y,z], body?}, {edge: [x,y,z], body?}, {sketch, curve}]: section curves (or points) with the sketch plane, linked", created_doc!()),
|
||||
),
|
||||
CommandSpec::new("sketch.include_3d", "Include 3D Geometry", include_cmd)
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("include")
|
||||
.enabled(in_sketch)
|
||||
.params("refs: like Project (edges, vertices, sketch curves and points); curves out of the sketch plane are flattened onto it"),
|
||||
.params(concat!("refs: like Project (edges, vertices, sketch curves and points); curves out of the sketch plane are flattened onto it", created_doc!())),
|
||||
CommandSpec::new("sketch.project_to_surface", "Project To Surface", project_to_surface)
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("project_surface")
|
||||
.enabled(in_sketch)
|
||||
.params("curves: [{sketch, curve}…] (curves of other sketches), face: [x,y,z] point on the target face, body?: projected along the active sketch's normal onto the face, as linked 3D curves"),
|
||||
.params(concat!("curves: [{sketch, curve}…] (curves of other sketches), face: [x,y,z] point on the target face, body?: projected along the active sketch's normal onto the face, as linked 3D curves", created_doc!())),
|
||||
CommandSpec::new("sketch.intersection_curve", "Intersection Curve", intersection_curve)
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("intersection_curve")
|
||||
.enabled(in_sketch)
|
||||
.params("a, b: {body} | {face: [x,y,z], body?}: where they meet, as linked 3D curves"),
|
||||
.params(concat!("a, b: {body} | {face: [x,y,z], body?}: where they meet, as linked 3D curves", created_doc!())),
|
||||
CommandSpec::new("sketch.spun_profile", "Spun Profile", spun_profile)
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("spun")
|
||||
.enabled(in_sketch)
|
||||
.params("body: name, axis: a line of the active sketch (curve id) | X|Y|Z: the body's outline revolved about the axis, in the sketch plane"),
|
||||
.params(concat!("body: name, axis: a line of the active sketch (curve id) | X|Y|Z: the body's outline revolved about the axis, in the sketch plane", created_doc!())),
|
||||
CommandSpec::new("sketch.isoparametric_curve", "Isoparametric Curve", iso_curve)
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("iso_curve")
|
||||
.enabled(in_sketch)
|
||||
.params("face: [x,y,z] point on the face (the curve passes through it), body?, direction?: u|v|[x,y,z] (default u): linked 3D curve"),
|
||||
.params(concat!("face: [x,y,z] point on the face (the curve passes through it), body?, direction?: u|v|[x,y,z] (default u): linked 3D curve", created_doc!())),
|
||||
CommandSpec::new("sketch.fit_curves_to_section", "Fit Curves to Mesh Section", fit_section)
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("fit_section")
|
||||
.enabled(in_sketch)
|
||||
.params("body: name (a mesh or solid body): its section with the sketch plane, fitted with lines and arcs, as normal (unlinked) geometry"),
|
||||
.params(concat!("body: name (a mesh or solid body): its section with the sketch plane, fitted with lines and arcs, as normal (unlinked) geometry", created_doc!())),
|
||||
CommandSpec::new("sketch.break_link", "Break Link", break_link)
|
||||
.enabled(in_sketch)
|
||||
.params("link?: link id (j1) | entities?: [curve or point ids]: linked geometry becomes normal sketch geometry"),
|
||||
@@ -56,8 +62,6 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.params("value?: bool (default toggles): project a face's edges when a sketch is created on it"),
|
||||
];
|
||||
|
||||
const REFS_DOC: &str = "refs: [{edge: [x,y,z], body?} | {face: [x,y,z], body?} (its edge loops) | {body: name} (silhouette) | {vertex: [x,y,z], body?} | {sketch, curve} | {sketch, point} | \"origin\" | {axis: X|Y|Z} | {plane: XY|XZ|YZ|name}]";
|
||||
|
||||
/// Parse one reference.
|
||||
fn source(s: &Session, v: &Value, cmd: &str) -> Result<LinkSource> {
|
||||
if let Some(name) = v.as_str() {
|
||||
|
||||
@@ -82,7 +82,7 @@ fn classify(e: &EngineError) -> Outcome {
|
||||
match e {
|
||||
EngineError::Internal(..) => Outcome::new("panic", msg),
|
||||
EngineError::BadParams { .. } | EngineError::Disabled(..) | EngineError::UnknownCommand(_) => Outcome::new("rejected", msg),
|
||||
// A failed boolean is a failure even when it says "not supported" (coincident faces on
|
||||
// A failed boolean is a failure even when it says "not supported" (the planar fallback on
|
||||
// curved bodies): those are the cases to fix. Features the kernel doesn't do yet (a
|
||||
// fillet between curved faces) are limitations.
|
||||
// Picks the generator got wrong (a centroid off its face).
|
||||
|
||||
@@ -395,6 +395,7 @@ impl Session {
|
||||
let changed = !Arc::ptr_eq(&self.doc, &before.doc);
|
||||
match r {
|
||||
Ok(v) => {
|
||||
let v = if changed { self.with_created(spec.id, params, &before, v) } else { v };
|
||||
if changed {
|
||||
if spec.undoable {
|
||||
self.undo.push(before);
|
||||
@@ -419,6 +420,27 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// A sketch command's result gets `created`: the ids of the points, curves, constraints and
|
||||
/// dimensions it added to its sketch (the `sketch` param, else the active sketch), so every
|
||||
/// drawing and modify command reports them the same way (see `cmd::sketch_created`). A command
|
||||
/// that runs another one (`sketch.draw.dimensioned`) gets the whole diff, replacing the nested
|
||||
/// command's narrower `created`.
|
||||
fn with_created(&self, id: &str, params: &Value, before: &Snapshot, mut v: Value) -> Value {
|
||||
if !id.starts_with("sketch.") {
|
||||
return v;
|
||||
}
|
||||
let Some(out) = v.as_object_mut() else { return v };
|
||||
let named = match params.get("sketch") {
|
||||
Some(Value::Number(n)) => self.doc.find_feature(&n.to_string()).map(|f| f.id),
|
||||
Some(Value::String(x)) => self.doc.find_feature(x).map(|f| f.id),
|
||||
_ => None,
|
||||
};
|
||||
let Some(sk) = named.or(self.active_sketch).or(before.active_sketch) else { return v };
|
||||
let Ok(after) = self.doc.sketch(sk) else { return v };
|
||||
out.insert("created".into(), cmd::sketch_created(before.doc.sketch(sk).ok(), after));
|
||||
v
|
||||
}
|
||||
|
||||
/// Remember the design, active sketch, pending positions and undo history; returns the checkpoint's id.
|
||||
pub fn checkpoint(&mut self) -> u64 {
|
||||
let id = self.next_checkpoint;
|
||||
|
||||
@@ -419,6 +419,46 @@ fn sample_edges_chain_and_faces() {
|
||||
assert!(m.edge_faces.iter().all(|f| !f.is_empty()));
|
||||
}
|
||||
|
||||
/// timeline.inspect returns a feature's definition in the shape timeline.edit's `set` takes, so
|
||||
/// an agent can read the field names instead of guessing them.
|
||||
#[test]
|
||||
fn timeline_inspect_shows_what_timeline_edit_changes() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Base"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 30]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": 10, "name": "Slab"}));
|
||||
run(&mut s, "solid.fillet", json!({"edges": [[20, 0, 10]], "radius": 2, "name": "Round"}));
|
||||
let i = run(&mut s, "timeline.inspect", json!({"feature": "Slab"}));
|
||||
assert_eq!(i["type"], "ExtrudeFeature", "{i}");
|
||||
assert_eq!(i["definition"]["extent"]["distance"], "10", "{i}");
|
||||
assert_eq!(i["definition"]["operation"], "new_body", "{i}");
|
||||
assert!(i["definition"].get("type").is_none() && i["definition"].get("param_names").is_none(), "{i}");
|
||||
assert_eq!(i["inputs"][0]["label"], "Distance", "{i}");
|
||||
assert_eq!(i["inputs"][0]["unit"], "mm", "{i}");
|
||||
let d = i["inputs"][0]["param"].as_str().unwrap().to_string();
|
||||
// The definition's field names are what timeline.edit takes.
|
||||
run(&mut s, "timeline.edit", json!({"feature": "Slab", "set": {"extent": {"distance": "12"}}}));
|
||||
let i = run(&mut s, "timeline.inspect", json!({"feature": i["id"]}));
|
||||
assert_eq!(i["definition"]["extent"]["distance"], "12", "{i}");
|
||||
assert_eq!(i["inputs"][0]["param"], d.as_str(), "{i}");
|
||||
let f = run(&mut s, "timeline.inspect", json!({"feature": "Round"}));
|
||||
assert_eq!(f["definition"]["radius"], "2", "{f}");
|
||||
run(&mut s, "timeline.edit", json!({"feature": "Round", "set": {"radius": "3"}}));
|
||||
assert_eq!(run(&mut s, "timeline.inspect", json!({"feature": "Round"}))["definition"]["radius"], "3");
|
||||
// A sketch leaves its geometry out (sketch.inspect has it) and says so.
|
||||
let k = run(&mut s, "timeline.inspect", json!({"feature": "Base"}));
|
||||
assert!(k["definition"].get("sketch").is_none() && k["definition"].get("plane").is_some(), "{k}");
|
||||
assert_eq!(k["omitted"][0]["field"], "sketch", "{k}");
|
||||
// Read-only: no undo step; bad input is refused.
|
||||
let undo = s.undo.len();
|
||||
run(&mut s, "timeline.inspect", json!({"feature": "Slab"}));
|
||||
assert_eq!(s.undo.len(), undo);
|
||||
for bad in [json!({}), json!({"feature": "Nope"}), json!({"feature": [1]}), json!({"feature": 1e300}), json!({"feature": null})] {
|
||||
assert!(s.execute("timeline.inspect", &bad).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timeline_edits_reresolve_references() {
|
||||
let mut s = Session::default();
|
||||
|
||||
@@ -35,6 +35,8 @@ pub mod colors {
|
||||
pub const AXIS_Z: Rgb = Rgb(60, 100, 230);
|
||||
pub const BG_TOP: Rgb = Rgb(240, 243, 247);
|
||||
pub const BG_BOTTOM: Rgb = Rgb(203, 210, 221);
|
||||
/// What the section cap is tinted toward.
|
||||
pub const SECTION_CAP: Rgb = solvecraft_render::SECTION_CAP;
|
||||
}
|
||||
|
||||
/// Bounds of everything visible.
|
||||
@@ -95,6 +97,13 @@ pub fn sketch_lines(sk: &Sketch, plane: &Plane, active: bool, determined: &[bool
|
||||
out.push((seg.polyline(0.02).iter().map(|p| plane.to_world(*p)).collect(), col, c.construction || c.centerline));
|
||||
}
|
||||
}
|
||||
// Virtual sharp corners (kept by fillets and chamfers): thin extension lines from the
|
||||
// curves to the corner, like construction geometry.
|
||||
for (_, ext) in sk.virtual_corners() {
|
||||
for (a, b) in ext {
|
||||
out.push((vec![plane.to_world(a), plane.to_world(b)], colors::SKETCH_CONSTRUCTION, true));
|
||||
}
|
||||
}
|
||||
// 3D curves are in world coordinates already.
|
||||
for w in sk.wires.iter().filter(|w| !w.fit.is_empty() || !sk.view.hide_projected) {
|
||||
// Curves drawn in a 3D sketch look like the sketch's own; included ones are references.
|
||||
@@ -119,10 +128,23 @@ pub fn sketch_point_visible(sk: &Sketch, i: usize) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// The scene for a headless render.
|
||||
/// The scene for a headless render, cut by the session's section.
|
||||
pub fn scene(s: &Session, cam: &Camera) -> Scene {
|
||||
scene_with_section(s, cam, s.section)
|
||||
}
|
||||
|
||||
/// The scene for a headless render, cut by `section` (origin, normal) instead of the session's:
|
||||
/// the app passes the cut its viewport shows (an open Section Analysis dialog's preview, a
|
||||
/// sketch's Slice), so model renders match the window.
|
||||
pub fn scene_with_section(s: &Session, cam: &Camera, section: Option<(Vec3, Vec3)>) -> Scene {
|
||||
let st = s.world_state();
|
||||
let mut sc = Scene { background: Some((colors::BG_TOP, colors::BG_BOTTOM)), clip: s.section.map(|(o, n)| (n, n.dot(o))), ..Default::default() };
|
||||
let section = section.filter(|(o, n)| o.is_finite() && n.is_finite() && n.len() > 1e-9);
|
||||
let mut sc = Scene {
|
||||
background: Some((colors::BG_TOP, colors::BG_BOTTOM)),
|
||||
clip: section.map(|(o, n)| (n, n.dot(o))),
|
||||
cap: Some(colors::SECTION_CAP),
|
||||
..Default::default()
|
||||
};
|
||||
let b = bounds(s);
|
||||
sc.radius = (b.diagonal() * 0.5).max(cam.half_height()).max(10.0) + b.center().dist(cam.target);
|
||||
// Grid on the ground plane (XY with Z up, XZ with Y up).
|
||||
@@ -181,6 +203,12 @@ pub fn scene(s: &Session, cam: &Camera) -> Scene {
|
||||
}
|
||||
sc.lines.push(SceneLine { points: e.clone(), color: colors::EDGE, width: 1.2, on_top: false });
|
||||
}
|
||||
// The outline of the body where the section cuts it.
|
||||
if let Some((o, n)) = section {
|
||||
for (a, b) in section_segments(&mesh, o, n) {
|
||||
sc.lines.push(SceneLine { points: vec![a, b], color: colors::EDGE, width: 1.6, on_top: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
for ss in &st.sketches {
|
||||
let active = s.active_sketch == Some(ss.feature);
|
||||
@@ -194,6 +222,28 @@ pub fn scene(s: &Session, cam: &Camera) -> Scene {
|
||||
sc
|
||||
}
|
||||
|
||||
/// Where the plane through `o` with normal `n` cuts a mesh: one segment per crossed triangle,
|
||||
/// nudged a hair to the kept side (so the clip leaves them drawn).
|
||||
pub fn section_segments(m: &solvecraft_geom::Mesh, o: Vec3, n: Vec3) -> Vec<(Vec3, Vec3)> {
|
||||
let eps = 1e-4 * (1.0 + m.bounds().diagonal());
|
||||
let mut out = Vec::new();
|
||||
for t in &m.triangles {
|
||||
let Some(p) = m.tri(t) else { continue };
|
||||
let d = p.map(|q| n.dot(q - o));
|
||||
let mut pts = Vec::with_capacity(2);
|
||||
for (i, j) in [(0, 1), (1, 2), (2, 0)] {
|
||||
if (d[i] < 0.0) != (d[j] < 0.0) {
|
||||
let s = d[i] / (d[i] - d[j]);
|
||||
pts.push(p[i] + (p[j] - p[i]) * s - n * eps);
|
||||
}
|
||||
}
|
||||
if let [a, b] = pts[..] {
|
||||
out.push((a, b));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Is a sketch drawn? The active one is; others unless hidden. Finished sketches are hidden
|
||||
/// once a feature uses them, like Fusion's default, unless shown one by one.
|
||||
pub fn sketch_shown(s: &Session, id: u64) -> bool {
|
||||
@@ -324,4 +374,31 @@ mod tests {
|
||||
let changed = whole.chunks(4).zip(cut.chunks(4)).filter(|(a, b)| a != b).count();
|
||||
assert!(changed > 500, "{changed} pixels changed");
|
||||
}
|
||||
|
||||
/// The section's cut face hides what lies inside the body behind it (it is drawn at the
|
||||
/// plane's depth, not on the far wall like a pane of glass), shows in the cap colour, and
|
||||
/// the cut's outline is drawn.
|
||||
#[test]
|
||||
fn snapshot_section_caps_hide_the_inside() {
|
||||
let mut s = Session::default();
|
||||
s.execute("solid.box", &json!({"length": 40, "width": 30, "height": 20})).unwrap();
|
||||
// Cut away the front half (y < 15), so the iso camera looks into the cut.
|
||||
s.execute("inspect.section", &json!({"plane": {"origin": [0, 15, 0], "normal": [0, -1, 0]}})).unwrap();
|
||||
for fov in [0.0, 0.6] {
|
||||
let mut cam = home_camera(&s);
|
||||
cam.fov = fov;
|
||||
let mut sc = scene(&s, &cam);
|
||||
assert!(!section_segments(&s.world_state().bodies[0].mesh(), Vec3::new(0.0, 15.0, 0.0), -Vec3::Y).is_empty());
|
||||
// A marker inside the body, behind the cut face.
|
||||
let red = Rgb(255, 0, 0);
|
||||
sc.lines.push(SceneLine { points: vec![Vec3::new(5.0, 25.0, 10.0), Vec3::new(35.0, 25.0, 10.0)], color: red, width: 3.0, on_top: false });
|
||||
let img = solvecraft_render::render(&sc, &cam, 300, 220);
|
||||
let px = || img.rgba.chunks(4);
|
||||
let marker = px().filter(|p| p[0] > 200 && p[1] < 60 && p[2] < 60).count();
|
||||
assert_eq!(marker, 0, "fov {fov}: the inside shows through the cut face");
|
||||
// The cap: warm (red over blue), unlike the grey-blue body and background.
|
||||
let cap = px().filter(|p| p[0] as i32 - p[2] as i32 > 20).count();
|
||||
assert!(cap > 500, "fov {fov}: cap pixels {cap}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -785,7 +785,8 @@ fn resample(lp: &Loop2, n: usize, angles: &[f64]) -> Loop2 {
|
||||
Loop2 { segs }
|
||||
}
|
||||
|
||||
/// Ruled loft through two or more planar sections (outer loops only).
|
||||
/// Ruled loft through two or more planar sections (outer loops only). The sections and the
|
||||
/// same sections in reverse order give the same body.
|
||||
pub fn loft(sections: &[(Plane, Loop2)]) -> Result<Body> {
|
||||
if sections.len() < 2 || sections.len() > 50 {
|
||||
return Err(KernelError::Invalid("a loft needs 2…50 sections".into()));
|
||||
@@ -794,10 +795,21 @@ pub fn loft(sections: &[(Plane, Loop2)]) -> Result<Body> {
|
||||
if n == 0 || n > MAX_SEGS {
|
||||
return Err(KernelError::Invalid("empty section".into()));
|
||||
}
|
||||
// Orient every section counter-clockwise about the first section's normal, then match
|
||||
// segment counts and starting points to the section with the most segments.
|
||||
let n0 = sections[0].0.normal();
|
||||
let oriented: Vec<(Plane, Loop2)> = sections
|
||||
// A canonical order, so that reversing the sections changes nothing: the one whose
|
||||
// first-to-last step has a positive largest component. The side faces' parameterisation
|
||||
// follows the order, and the boolean worked on one and failed on the other (a cut whose
|
||||
// loft touches the body along an edge: an internal panic, or "shell not oriented and
|
||||
// closed").
|
||||
let centre = |(p, l): &(Plane, Loop2)| p.to_world(l.centroid());
|
||||
let (Some(first), Some(last)) = (sections.first(), sections.last()) else { return Err(KernelError::Invalid("no sections".into())) };
|
||||
let step = centre(last) - centre(first);
|
||||
let big = [step.x, step.y, step.z].into_iter().max_by(|a, b| a.abs().total_cmp(&b.abs())).unwrap_or(0.0);
|
||||
let ordered: Vec<&(Plane, Loop2)> = if big < 0.0 { sections.iter().rev().collect() } else { sections.iter().collect() };
|
||||
// Orient every section counter-clockwise about the first section's normal (`Body::new`
|
||||
// turns the solid outward), then match segment counts and starting points to the section
|
||||
// with the most segments.
|
||||
let n0 = ordered.first().map(|(p, _)| p.normal()).unwrap_or(Vec3::Z);
|
||||
let oriented: Vec<(Plane, Loop2)> = ordered
|
||||
.iter()
|
||||
.map(|(p, l)| {
|
||||
let l = if p.normal().dot(n0) >= 0.0 { l.ccw() } else { l.ccw().reversed() };
|
||||
|
||||
+222
-30
@@ -2,8 +2,10 @@
|
||||
//! faces). Each face moves along its outward normal by its own distance: a plane is translated,
|
||||
//! a cylinder changes radius about its axis. Every vertex and edge goes to where its faces'
|
||||
//! moved surfaces meet; edges are lines or circular arcs. This is exact when the moved
|
||||
//! surfaces meet the way the old ones did (prismatic parts, bosses, fillets between walls);
|
||||
//! anything else is refused, so callers can fall back.
|
||||
//! surfaces meet the way the old ones did (prismatic parts, bosses, fillets between walls).
|
||||
//! A corner where more than three planes meet, whose moved planes miss a common point (a
|
||||
//! floor falling to a drain, a pyramid's apex), becomes several corners joined by short edges.
|
||||
//! Anything else is refused, so callers can fall back.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -422,6 +424,143 @@ fn span_step(ns: &[Vec3], s: &[f64]) -> Option<Vec3> {
|
||||
Some(v)
|
||||
}
|
||||
|
||||
/// Most faces round one corner that [`split_corner`] takes on.
|
||||
const MAX_SPLIT_FACES: usize = 48;
|
||||
|
||||
/// The corners in place of a corner where more than three planar faces meet and whose moved
|
||||
/// planes miss a common point (a hip floor's corner, a pyramid's apex): each new corner with
|
||||
/// the faces through it. Corners sharing two faces are joined by a new edge on those faces.
|
||||
/// Only corners whose edges are all convex or all concave: the faces there bound a convex
|
||||
/// region (the body's side or the outside's), and after the move its corners are the points on
|
||||
/// three moved planes inside all the others.
|
||||
fn split_corner(
|
||||
v: &mt::Vertex,
|
||||
at: &[usize],
|
||||
faces: &[mt::Face],
|
||||
efaces: &HashMap<mt::EdgeID, Vec<usize>>,
|
||||
old: &[Surf],
|
||||
new: &[Surf],
|
||||
size: f64,
|
||||
) -> Option<Vec<(Vec3, Vec<usize>)>> {
|
||||
if at.len() < 4 || at.len() > MAX_SPLIT_FACES {
|
||||
return None;
|
||||
}
|
||||
let p = from_p3(v.point());
|
||||
// (face, outward normal, old offset, moved offset).
|
||||
let planes: Vec<(usize, Vec3, f64, f64)> = at
|
||||
.iter()
|
||||
.map(|i| match (old.get(*i)?, new.get(*i)?) {
|
||||
(Surf::Plane { n, d }, Surf::Plane { d: d2, .. }) => Some((*i, *n, *d, *d2)),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Option<_>>()?;
|
||||
let normal = |fi: usize| planes.iter().find(|x| x.0 == fi).map(|x| x.1);
|
||||
// Edge kinds round the corner: a face's loop runs counter-clockwise about its outward
|
||||
// normal, so its inside is to the left; the edge is convex when the face beyond it falls
|
||||
// away from there.
|
||||
let mut sign = 0.0f64;
|
||||
let mut pairs: Vec<(usize, usize)> = Vec::new();
|
||||
for &(fi, na, ..) in &planes {
|
||||
for w in faces.get(fi)?.boundaries() {
|
||||
for e in w.edge_iter() {
|
||||
if e.front().id() != v.id() && e.back().id() != v.id() {
|
||||
continue;
|
||||
}
|
||||
let fb = efaces.get(&e.id())?.iter().copied().find(|j| *j != fi)?;
|
||||
pairs.push((fi, fb));
|
||||
let dir = (from_p3(e.back().point()) - from_p3(e.front().point())).normalized()?;
|
||||
let s = normal(fb)?.dot(na.cross(dir));
|
||||
if s.abs() < 1e-9 {
|
||||
continue;
|
||||
}
|
||||
if sign * s < 0.0 {
|
||||
return None;
|
||||
}
|
||||
sign = s.signum();
|
||||
}
|
||||
}
|
||||
}
|
||||
if sign == 0.0 {
|
||||
return None;
|
||||
}
|
||||
// Convex: the body's side, n·x ≤ d; concave: the outside's, n·x ≥ d.
|
||||
let side = -sign;
|
||||
let merge = size * 1e-6;
|
||||
let mut nodes: Vec<(Vec3, Vec<usize>)> = Vec::new();
|
||||
for (ia, a) in planes.iter().enumerate() {
|
||||
for (ib, b) in planes.iter().enumerate().skip(ia + 1) {
|
||||
for c in planes.iter().skip(ib + 1) {
|
||||
let den = a.1.dot(b.1.cross(c.1));
|
||||
if den.abs() < 1e-9 {
|
||||
continue;
|
||||
}
|
||||
let x = (b.1.cross(c.1) * a.3 + c.1.cross(a.1) * b.3 + a.1.cross(b.1) * c.3) * (1.0 / den);
|
||||
if !x.is_finite() || x.dist(p) > size || planes.iter().any(|q| side * (q.1.dot(x) - q.3) > merge) {
|
||||
continue;
|
||||
}
|
||||
let fs = [a.0, b.0, c.0];
|
||||
match nodes.iter_mut().find(|(q, _)| q.dist(x) < merge) {
|
||||
Some((_, nf)) => {
|
||||
for f in fs {
|
||||
if !nf.contains(&f) {
|
||||
nf.push(f);
|
||||
}
|
||||
}
|
||||
}
|
||||
None => nodes.push((x, fs.to_vec())),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Every face keeps a corner here, and every edge an end.
|
||||
if planes.iter().any(|q| !nodes.iter().any(|(_, nf)| nf.contains(&q.0)))
|
||||
|| pairs.iter().any(|(x, y)| !nodes.iter().any(|(_, nf)| nf.contains(x) && nf.contains(y)))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
// Each corner where its planes meet best (least squares, for planes meeting at one point
|
||||
// to within the merge distance).
|
||||
for (q, nf) in nodes.iter_mut() {
|
||||
let (ns, ss): (Vec<Vec3>, Vec<f64>) = nf.iter().filter_map(|f| planes.iter().find(|x| x.0 == *f)).map(|x| (x.1, x.3 - x.2)).unzip();
|
||||
*q = p + span_step(&ns, &ss)?;
|
||||
}
|
||||
Some(nodes)
|
||||
}
|
||||
|
||||
/// The corners of a split corner (see [`split_corner`]) on face `face`, in order from corner
|
||||
/// `from` to corner `to`, along the new edges between them.
|
||||
fn split_path(nodes: &[(Vec3, Vec<usize>)], face: usize, from: usize, to: usize) -> Option<Vec<usize>> {
|
||||
let joined = |x: usize, y: usize| {
|
||||
let (Some((_, a)), Some((_, b))) = (nodes.get(x), nodes.get(y)) else { return false };
|
||||
a.contains(&face) && b.contains(&face) && a.iter().filter(|f| b.contains(f)).count() >= 2
|
||||
};
|
||||
// Breadth first; `prev` leads back to `from`.
|
||||
let mut prev: Vec<Option<usize>> = vec![None; nodes.len()];
|
||||
let mut queue = std::collections::VecDeque::from([from]);
|
||||
let mut seen = vec![false; nodes.len()];
|
||||
*seen.get_mut(from)? = true;
|
||||
while let Some(x) = queue.pop_front() {
|
||||
if x == to {
|
||||
let mut path = vec![to];
|
||||
let mut cur = to;
|
||||
while let Some(p) = prev.get(cur).copied().flatten() {
|
||||
path.push(p);
|
||||
cur = p;
|
||||
}
|
||||
path.reverse();
|
||||
return Some(path);
|
||||
}
|
||||
for y in 0..nodes.len() {
|
||||
if !seen.get(y).copied().unwrap_or(true) && joined(x, y) {
|
||||
*seen.get_mut(y)? = true;
|
||||
*prev.get_mut(y)? = Some(x);
|
||||
queue.push_back(y);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// A line or circular arc through an edge (by samples): `None` for other curves.
|
||||
/// Maximum straight edges per face checked pairwise for crossings.
|
||||
const MAX_CROSSING_EDGES: usize = 2000;
|
||||
@@ -431,7 +570,7 @@ const MAX_CROSSING_EDGES: usize = 2000;
|
||||
fn crossed_loops(
|
||||
faces: &[mt::Face],
|
||||
old: &[Surf],
|
||||
newpos: &HashMap<mt::VertexID, Vec3>,
|
||||
pos: &dyn Fn(&mt::Vertex, &mt::Edge) -> Option<Vec3>,
|
||||
edge_collapsed: &dyn Fn(&mt::Edge) -> bool,
|
||||
size: f64,
|
||||
) -> Option<Vec3> {
|
||||
@@ -443,32 +582,32 @@ fn crossed_loops(
|
||||
}
|
||||
let (u, w) = (n.any_perp(), n.cross(n.any_perp()));
|
||||
let flat = |p: Vec3| (p.dot(u), p.dot(w));
|
||||
// Straight edges as (vertex ids, old ends, new ends) in the plane.
|
||||
let mut segs: Vec<([mt::VertexID; 2], [(f64, f64); 2])> = Vec::new();
|
||||
// Straight edges as (vertex ids, new ends in the plane, the plane's new offset).
|
||||
let mut segs: Vec<([mt::VertexID; 2], [(f64, f64); 2], f64)> = Vec::new();
|
||||
for wire in f.absolute_boundaries() {
|
||||
let (mut area_old, mut area_new, mut straight) = (0.0, 0.0, true);
|
||||
for e in wire.edge_iter() {
|
||||
let (v0, v1) = (e.front(), e.back());
|
||||
let (Some(q0), Some(q1)) = (newpos.get(&v0.id()), newpos.get(&v1.id())) else { return None };
|
||||
let (o0, o1, n0, n1) = (flat(from_p3(v0.point())), flat(from_p3(v1.point())), flat(*q0), flat(*q1));
|
||||
let (Some(q0), Some(q1)) = (pos(v0, e), pos(v1, e)) else { return None };
|
||||
let (o0, o1, n0, n1) = (flat(from_p3(v0.point())), flat(from_p3(v1.point())), flat(q0), flat(q1));
|
||||
area_old += o0.0 * o1.1 - o1.0 * o0.1;
|
||||
area_new += n0.0 * n1.1 - n1.0 * n0.1;
|
||||
if arc_mid(e, size * 1e-6) == Some(None) {
|
||||
segs.push(([v0.id(), v1.id()], [n0, n1]));
|
||||
segs.push(([v0.id(), v1.id()], [n0, n1], n.dot(q0)));
|
||||
} else {
|
||||
straight = false;
|
||||
}
|
||||
}
|
||||
if straight && area_old.abs() > eps && area_old * area_new <= 0.0 {
|
||||
return wire.vertex_iter().next().and_then(|v| newpos.get(&v.id()).copied());
|
||||
return wire.edge_iter().next().and_then(|e| pos(e.front(), e));
|
||||
}
|
||||
}
|
||||
if segs.len() > MAX_CROSSING_EDGES {
|
||||
continue;
|
||||
}
|
||||
let side = |a: (f64, f64), c: (f64, f64), p: (f64, f64)| (c.0 - a.0) * (p.1 - a.1) - (c.1 - a.1) * (p.0 - a.0);
|
||||
for (i, (ia, [a0, a1])) in segs.iter().enumerate() {
|
||||
for (ib, [b0, b1]) in segs.iter().skip(i + 1) {
|
||||
for (i, (ia, [a0, a1], d)) in segs.iter().enumerate() {
|
||||
for (ib, [b0, b1], _) in segs.iter().skip(i + 1) {
|
||||
if ia.iter().any(|v| ib.contains(v)) {
|
||||
continue;
|
||||
}
|
||||
@@ -478,8 +617,7 @@ fn crossed_loops(
|
||||
if ((s0 > tol && s1 < -tol) || (s0 < -tol && s1 > tol)) && ((t0 > tol && t1 < -tol) || (t0 < -tol && t1 > tol)) {
|
||||
let k = t0 / (t0 - t1);
|
||||
let (x, y) = (a0.0 + (a1.0 - a0.0) * k, a0.1 + (a1.1 - a0.1) * k);
|
||||
let d = n.dot(newpos.get(&ia[0]).copied().unwrap_or_default());
|
||||
return Some(u * x + w * y + n * d);
|
||||
return Some(u * x + w * y + n * *d);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -618,6 +756,8 @@ pub(crate) fn offset_body(b: &Body, shift: impl Fn(usize, Vec3) -> f64) -> Resul
|
||||
})
|
||||
.collect();
|
||||
let mut newpos: HashMap<mt::VertexID, Vec3> = HashMap::new();
|
||||
// Corners whose moved faces meet at several points (see `split_corner`).
|
||||
let mut splits: HashMap<mt::VertexID, Vec<(Vec3, Vec<usize>)>> = HashMap::new();
|
||||
for v in b.solid.vertex_iter() {
|
||||
let at = vfaces.get(&v.id()).ok_or_else(|| fail("vertex"))?;
|
||||
let p = from_p3(v.point());
|
||||
@@ -672,10 +812,33 @@ pub(crate) fn offset_body(b: &Body, shift: impl Fn(usize, Vec3) -> f64) -> Resul
|
||||
});
|
||||
let q = match apex {
|
||||
Some(q) if n.iter().all(|sf| sf.dist(q).abs() < size * 1e-6) => q,
|
||||
_ => move_point(p, &o, &s, &n, size * 1e-6).ok_or_else(|| fail("a corner whose faces don't meet the same way after the move"))?,
|
||||
_ => match move_point(p, &o, &s, &n, size * 1e-6) {
|
||||
Some(q) => q,
|
||||
None => {
|
||||
let nodes = (fs == *at)
|
||||
.then(|| split_corner(&v, at, &faces, &efaces, &old, &new, size))
|
||||
.flatten()
|
||||
.ok_or_else(|| fail("a corner whose faces don't meet the same way after the move"))?;
|
||||
let q = nodes.first().map(|x| x.0).ok_or_else(|| fail("corner"))?;
|
||||
splits.insert(v.id(), nodes);
|
||||
q
|
||||
}
|
||||
},
|
||||
};
|
||||
newpos.insert(v.id(), q);
|
||||
}
|
||||
// Which of a split corner's new corners an edge ends at.
|
||||
let node_of = |v: mt::VertexID, e: mt::EdgeID| -> Option<usize> {
|
||||
let fs = efaces.get(&e)?;
|
||||
splits.get(&v)?.iter().position(|(_, nf)| fs.iter().all(|i| nf.contains(i)))
|
||||
};
|
||||
// Where an edge's end goes.
|
||||
let end_pos = |v: &mt::Vertex, e: &mt::Edge| -> Option<Vec3> {
|
||||
match splits.get(&v.id()) {
|
||||
Some(nodes) => nodes.get(node_of(v.id(), e.id())?).map(|x| x.0),
|
||||
None => newpos.get(&v.id()).copied(),
|
||||
}
|
||||
};
|
||||
// An edge turned round: faces crossed over (a wall thinner than the move), which would
|
||||
// leave the body inside out.
|
||||
let edge_collapsed = |e: &mt::Edge| efaces.get(&e.id()).is_some_and(|fs| fs.iter().any(|i| is_collapsed(*i)));
|
||||
@@ -684,8 +847,8 @@ pub(crate) fn offset_body(b: &Body, shift: impl Fn(usize, Vec3) -> f64) -> Resul
|
||||
continue;
|
||||
}
|
||||
let (f0, f1) = (e.absolute_front(), e.absolute_back());
|
||||
let (Some(q0), Some(q1)) = (newpos.get(&f0.id()), newpos.get(&f1.id())) else { continue };
|
||||
let (old_v, new_v) = (from_p3(f1.point()) - from_p3(f0.point()), *q1 - *q0);
|
||||
let (Some(q0), Some(q1)) = (end_pos(f0, &e), end_pos(f1, &e)) else { continue };
|
||||
let (old_v, new_v) = (from_p3(f1.point()) - from_p3(f0.point()), q1 - q0);
|
||||
if old_v.len() > size * 1e-6 && old_v.dot(new_v) < 0.0 {
|
||||
return Err(fail("not supported yet: the move is larger than a wall or step is thick (faces would cross)"));
|
||||
}
|
||||
@@ -693,7 +856,7 @@ pub(crate) fn offset_body(b: &Body, shift: impl Fn(usize, Vec3) -> f64) -> Resul
|
||||
// A plane's loops crossing after the move: a rim or ledge narrower than the move (the hole
|
||||
// in a top face sliding past its outer edge) turns part of the face inside out, though no
|
||||
// edge turns round.
|
||||
if let Some(at) = crossed_loops(&faces, &old, &newpos, &edge_collapsed, size) {
|
||||
if let Some(at) = crossed_loops(&faces, &old, &end_pos, &edge_collapsed, size) {
|
||||
return Err(fail(&format!(
|
||||
"not supported yet: the move is larger than a rim or ledge near [{:.1}, {:.1}, {:.1}] is wide (a face would turn inside out)",
|
||||
at.x, at.y, at.z
|
||||
@@ -725,6 +888,16 @@ pub(crate) fn offset_body(b: &Body, shift: impl Fn(usize, Vec3) -> f64) -> Resul
|
||||
};
|
||||
verts.insert(v.id(), nv);
|
||||
}
|
||||
let node_verts: HashMap<mt::VertexID, Vec<mt::Vertex>> =
|
||||
splits.iter().map(|(id, nodes)| (*id, nodes.iter().map(|(q, _)| builder::vertex(p3(*q))).collect())).collect();
|
||||
let vert_at = |v: &mt::Vertex, e: &mt::Edge| -> Option<&mt::Vertex> {
|
||||
match node_verts.get(&v.id()) {
|
||||
Some(nv) => nv.get(node_of(v.id(), e.id())?),
|
||||
None => verts.get(&v.id()),
|
||||
}
|
||||
};
|
||||
// New edges between a split corner's corners (stored from the lower index).
|
||||
let mut links: HashMap<(mt::VertexID, usize, usize), mt::Edge> = HashMap::new();
|
||||
// Old edge → new edge, and whether it runs the other way (merged with its twin).
|
||||
let mut edges: HashMap<mt::EdgeID, (mt::Edge, bool)> = HashMap::new();
|
||||
let mut merged: HashMap<(mt::VertexID, mt::VertexID), mt::Edge> = HashMap::new();
|
||||
@@ -732,7 +905,7 @@ pub(crate) fn offset_body(b: &Body, shift: impl Fn(usize, Vec3) -> f64) -> Resul
|
||||
if edges.contains_key(&e.id()) {
|
||||
continue;
|
||||
}
|
||||
let (Some(a), Some(c)) = (verts.get(&e.absolute_front().id()), verts.get(&e.absolute_back().id())) else { return Err(fail("edge")) };
|
||||
let (Some(a), Some(c)) = (vert_at(e.absolute_front(), &e), vert_at(e.absolute_back(), &e)) else { return Err(fail("edge")) };
|
||||
let touches = edge_collapsed(&e);
|
||||
if a == c || from_p3(a.point()).dist(from_p3(c.point())) < size * 1e-9 {
|
||||
if touches {
|
||||
@@ -796,18 +969,37 @@ pub(crate) fn offset_body(b: &Body, shift: impl Fn(usize, Vec3) -> f64) -> Resul
|
||||
if is_collapsed(i) {
|
||||
continue;
|
||||
}
|
||||
let wires: Vec<mt::Wire> = f
|
||||
.absolute_boundaries()
|
||||
.iter()
|
||||
.map(|w| {
|
||||
w.edge_iter()
|
||||
.filter_map(|e| {
|
||||
edges.get(&e.id()).map(|(ne, flip)| if (e.front() == e.absolute_front()) != *flip { ne.clone() } else { ne.inverse() })
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.into()
|
||||
})
|
||||
.collect();
|
||||
let mut wires: Vec<mt::Wire> = Vec::new();
|
||||
for w in f.absolute_boundaries() {
|
||||
let old_edges: Vec<mt::Edge> = w.edge_iter().cloned().collect();
|
||||
let mut wire: Vec<mt::Edge> = Vec::new();
|
||||
for (j, e) in old_edges.iter().enumerate() {
|
||||
if let Some((ne, flip)) = edges.get(&e.id()) {
|
||||
wire.push(if (e.front() == e.absolute_front()) != *flip { ne.clone() } else { ne.inverse() });
|
||||
}
|
||||
// At a split corner, along the new edges from this edge's end to the next's.
|
||||
let v = e.back();
|
||||
let (Some(nodes), Some(next)) = (splits.get(&v.id()), old_edges.get((j + 1) % old_edges.len())) else { continue };
|
||||
let (Some(from), Some(to)) = (node_of(v.id(), e.id()), node_of(v.id(), next.id())) else { return Err(fail("split corner")) };
|
||||
let path = split_path(nodes, i, from, to).ok_or_else(|| fail("split corner"))?;
|
||||
for pair in path.windows(2) {
|
||||
let (Some(&x), Some(&y)) = (pair.first(), pair.get(1)) else { continue };
|
||||
let key = (v.id(), x.min(y), x.max(y));
|
||||
let link = match links.get(&key) {
|
||||
Some(l) => l.clone(),
|
||||
None => {
|
||||
let nv = node_verts.get(&v.id()).ok_or_else(|| fail("split corner"))?;
|
||||
let (Some(va), Some(vb)) = (nv.get(key.1), nv.get(key.2)) else { return Err(fail("split corner")) };
|
||||
let l = builder::line(va, vb);
|
||||
links.insert(key, l.clone());
|
||||
l
|
||||
}
|
||||
};
|
||||
wire.push(if x < y { link } else { link.inverse() });
|
||||
}
|
||||
}
|
||||
wires.push(wire.into());
|
||||
}
|
||||
let (Some(o), Some(n)) = (old.get(i), new.get(i)) else { return Err(fail("face")) };
|
||||
let surface = match (o.map_to(n), *o, *n) {
|
||||
(Some(mat), _, _) => mt::Transformed::transformed(&f.surface(), mat),
|
||||
|
||||
@@ -266,9 +266,12 @@ fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
|
||||
let size = a.size().max(b.size());
|
||||
// The checks mesh both bodies; where they meet in a region much thinner than they are (a
|
||||
// thin part trimmed by a long one), a step from their size would facet the small faces
|
||||
// there coarsely enough to fail the membership test on a correct result.
|
||||
// there coarsely enough to fail the membership test on a correct result. The meshes are
|
||||
// refined: a round side trimmed to a thin band (a short boss crossing a gently sloping
|
||||
// floor) is otherwise spanned by triangles cutting through it, which reads as missing
|
||||
// material.
|
||||
let tol_m = (size * 5e-4).min(meeting_size(a, b) * 2e-3).max(size * 1e-5);
|
||||
let meshes = (a.tessellate(tol_m).ok(), b.tessellate(tol_m).ok());
|
||||
let meshes = (a.tessellate_with(tol_m, true).ok(), b.tessellate_with(tol_m, true).ok());
|
||||
let (va, vb) = match &meshes {
|
||||
(Some(ma), Some(mb)) => (ma.measure().volume, mb.measure().volume),
|
||||
_ => (volume(a), volume(b)),
|
||||
@@ -277,7 +280,7 @@ fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
|
||||
// Volume bounds, then membership against the operands (catches misclassified pieces), on
|
||||
// one mesh of the result. Returns (plausible, volume).
|
||||
let check = |r: &Body| -> (bool, f64) {
|
||||
let Ok(mr) = r.tessellate(tol_m) else { return (false, f64::NAN) };
|
||||
let Ok(mr) = r.tessellate_with(tol_m, true) else { return (false, f64::NAN) };
|
||||
let v = mr.measure().volume;
|
||||
let bounds = v > 0.0
|
||||
&& match op {
|
||||
|
||||
@@ -199,16 +199,72 @@ impl Node {
|
||||
}
|
||||
}
|
||||
|
||||
/// Polygons of a body whose faces are all planar (None otherwise).
|
||||
fn planar_polys(b: &Body) -> Option<Vec<Poly>> {
|
||||
/// Why a body's polygons can't be had: in words that finish "the {role} has …" or "the
|
||||
/// {role}'s …".
|
||||
#[derive(Debug, PartialEq)]
|
||||
enum NotPlanar {
|
||||
/// Faces on curved surfaces, counted by kind.
|
||||
Curved(std::collections::BTreeMap<&'static str, usize>),
|
||||
/// Something else (a face that could not be meshed, too many polygons).
|
||||
Other(String),
|
||||
}
|
||||
|
||||
impl NotPlanar {
|
||||
fn describe(&self, role: &str) -> String {
|
||||
match self {
|
||||
NotPlanar::Curved(kinds) => {
|
||||
let n: usize = kinds.values().sum();
|
||||
let list: Vec<&str> = kinds.keys().copied().collect();
|
||||
format!("the {role} has {n} curved face{} ({})", if n == 1 { "" } else { "s" }, list.join(", "))
|
||||
}
|
||||
NotPlanar::Other(m) => format!("the {role}'s {m}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The plane of each face (from its mesh, which must be flat), or what stands in the way: the
|
||||
/// curved faces by the kind of surface they lie on.
|
||||
fn face_planes(b: &Body, faces: &[crate::FaceInfo]) -> std::result::Result<Vec<PlaneEq>, NotPlanar> {
|
||||
use crate::FaceSurface;
|
||||
let mut curved: std::collections::BTreeMap<&'static str, usize> = std::collections::BTreeMap::new();
|
||||
let mut out = Vec::with_capacity(faces.len());
|
||||
for f in faces {
|
||||
if f.area <= 0.0 {
|
||||
return Err(NotPlanar::Other(format!("face {} could not be meshed", f.index)));
|
||||
}
|
||||
match f.plane_normal {
|
||||
Some(n) => out.push(PlaneEq { n, w: n.dot(f.centroid) }),
|
||||
None => {
|
||||
let kind = match crate::face_surface(b, f.index) {
|
||||
Some(FaceSurface::Cylinder { .. }) => "cylinder",
|
||||
Some(FaceSurface::Cone { .. }) => "cone",
|
||||
Some(FaceSurface::Sphere { .. }) => "sphere",
|
||||
Some(FaceSurface::Torus { .. }) => "torus",
|
||||
Some(FaceSurface::Plane { .. }) => return Err(NotPlanar::Other(format!("face {} is planar but its mesh is not flat", f.index))),
|
||||
None => "freeform",
|
||||
};
|
||||
*curved.entry(kind).or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if curved.is_empty() { Ok(out) } else { Err(NotPlanar::Curved(curved)) }
|
||||
}
|
||||
|
||||
/// Polygons of a body whose faces are all planar.
|
||||
fn planar_polys(b: &Body) -> std::result::Result<Vec<Poly>, NotPlanar> {
|
||||
let tol = (b.size() * 1e-3).max(1e-3);
|
||||
let faces = b.faces(tol).ok()?;
|
||||
let mesh = b.tessellate(tol).ok()?;
|
||||
let planes: Vec<PlaneEq> = faces.iter().map(|f| f.plane_normal.map(|n| PlaneEq { n, w: n.dot(f.centroid) })).collect::<Option<_>>()?;
|
||||
let unmeshed = |e: KernelError| NotPlanar::Other(format!("faces could not be meshed ({e})"));
|
||||
let faces = b.faces(tol).map_err(unmeshed)?;
|
||||
let mesh = b.tessellate(tol).map_err(unmeshed)?;
|
||||
let planes = face_planes(b, &faces)?;
|
||||
if mesh.triangles.len() > MAX_POLYS {
|
||||
return Err(NotPlanar::Other(format!("mesh is too detailed ({} triangles)", mesh.triangles.len())));
|
||||
}
|
||||
let bad_mesh = || NotPlanar::Other("mesh is inconsistent".into());
|
||||
let mut out = Vec::new();
|
||||
for (t, fi) in mesh.triangles.iter().zip(&mesh.tri_face) {
|
||||
let [a, bb, c] = mesh.tri(t)?;
|
||||
let plane = *planes.get(*fi as usize)?;
|
||||
let [a, bb, c] = mesh.tri(t).ok_or_else(bad_mesh)?;
|
||||
let plane = *planes.get(*fi as usize).ok_or_else(bad_mesh)?;
|
||||
// Snap the vertices onto the exact face plane.
|
||||
let snap = |p: Vec3| p - plane.n * (plane.n.dot(p) - plane.w);
|
||||
let mut v = vec![snap(a), snap(bb), snap(c)];
|
||||
@@ -217,7 +273,7 @@ fn planar_polys(b: &Body) -> Option<Vec<Poly>> {
|
||||
}
|
||||
out.push(Poly { v, plane });
|
||||
}
|
||||
(out.len() <= MAX_POLYS).then_some(out)
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Polygons of any body: planar faces exactly, curved ones as their triangles (within `tol`).
|
||||
@@ -305,11 +361,19 @@ pub fn faceted_boolean(a: &Body, b: &Mesh, op: BoolOp, tol: f64) -> Result<Optio
|
||||
pub fn planar_boolean(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
|
||||
a.require_brep("a boolean")?;
|
||||
b.require_brep("a boolean")?;
|
||||
let (Some(pa), Some(pb)) = (planar_polys(a), planar_polys(b)) else {
|
||||
if let Some(r) = sliced_planar_boolean(a, b, op) {
|
||||
return r;
|
||||
let (pa, pb) = match (planar_polys(a), planar_polys(b)) {
|
||||
(Ok(pa), Ok(pb)) => (pa, pb),
|
||||
(ra, rb) => {
|
||||
if let Some(r) = sliced_planar_boolean(a, b, op) {
|
||||
return r;
|
||||
}
|
||||
let why: Vec<String> =
|
||||
[(ra.err(), "target"), (rb.err(), "tool")].into_iter().filter_map(|(e, role)| e.map(|e| e.describe(role))).collect();
|
||||
return Err(KernelError::Failed(format!(
|
||||
"not supported yet: the exact fallback for where the bodies meet (faces that touch, or cross at a shallow angle) takes planar faces only, and {}",
|
||||
why.join(" and ")
|
||||
)));
|
||||
}
|
||||
return Err(KernelError::Failed("not supported yet: coincident faces on curved bodies".into()));
|
||||
};
|
||||
let size = a.size().max(b.size());
|
||||
polys_boolean(pa, pb, size, op, size * 1e-9)
|
||||
@@ -320,7 +384,7 @@ pub fn planar_boolean(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
|
||||
/// hole): `a` is split across the slab, the middle piece takes the exact boolean, and the
|
||||
/// pieces outside are joined back on. `None` when no such slab exists.
|
||||
fn sliced_planar_boolean(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Option<Body>>> {
|
||||
let pb = planar_polys(b)?;
|
||||
let pb = planar_polys(b).ok()?;
|
||||
let size = a.size().max(b.size());
|
||||
let tol = (a.size() * 1e-3).max(1e-3);
|
||||
let infos = a.faces(tol).ok()?;
|
||||
@@ -391,7 +455,7 @@ fn sliced_planar_boolean(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Option
|
||||
outside.extend(crate::ops::half_space(&middle, &plane, !keep_positive)?);
|
||||
middle = crate::ops::half_space(&middle, &plane, keep_positive)?.ok_or_else(|| fail("nothing beside the tool"))?;
|
||||
}
|
||||
let pm = planar_polys(&middle).ok_or_else(|| fail("the middle piece is not planar"))?;
|
||||
let pm = planar_polys(&middle).map_err(|e| fail(&e.describe("middle piece")))?;
|
||||
let r = polys_boolean(pm, pb, size, op, size * 1e-9)?;
|
||||
if op == BoolOp::Intersect {
|
||||
return Ok(r);
|
||||
|
||||
@@ -336,6 +336,65 @@ fn drain_pads_flush_under_a_floor_with_a_hole() {
|
||||
assert!(rel(v, expect) < 2e-5, "disc across the hole: {v} vs {expect}");
|
||||
}
|
||||
|
||||
/// A trough whose floor falls 1° (or 2° toward a drain, in four faces), and short round pads
|
||||
/// and bosses crossing the fall. The B-rep results were right, but the plausibility check meshed
|
||||
/// them without refinement: on a round side trimmed to a thin band by the shallow cut, the plain
|
||||
/// mesh spans the band with triangles cutting through the cylinder, so the check saw missing
|
||||
/// material, rejected the result, and the planar fallback refused the round pad.
|
||||
#[test]
|
||||
fn round_pads_crossing_a_shallow_fall() {
|
||||
let hs = |n: Vec3, p: Vec3| {
|
||||
let n = n.normalized().unwrap();
|
||||
HalfSpace { n, d: n.dot(p) }
|
||||
};
|
||||
let walls = |floor: &[HalfSpace]| {
|
||||
let mut h = vec![
|
||||
hs(Vec3::X, Vec3::new(290.0, 0.0, 0.0)),
|
||||
hs(-Vec3::X, Vec3::new(10.0, 0.0, 0.0)),
|
||||
hs(Vec3::Y, Vec3::new(0.0, 140.0, 0.0)),
|
||||
hs(-Vec3::Y, Vec3::new(0.0, 10.0, 0.0)),
|
||||
hs(Vec3::Z, Vec3::new(0.0, 0.0, 90.0)),
|
||||
];
|
||||
h.extend_from_slice(floor);
|
||||
convex_polyhedron(&h).unwrap()
|
||||
};
|
||||
let block = box_solid(Vec3::ZERO, Vec3::new(300.0, 150.0, 80.0)).unwrap();
|
||||
// One fall along x at 1°: floor z = 8 + t (x − 10).
|
||||
let t = 1f64.to_radians().tan();
|
||||
let fall = boolean(&block, &walls(&[hs(Vec3::new(t, 0.0, -1.0), Vec3::new(10.0, 0.0, 8.0))]), BoolOp::Cut).unwrap().unwrap();
|
||||
let v0 = measure(&fall).unwrap().volume;
|
||||
for x in [60.0, 100.0] {
|
||||
// A pad from z = 9 to 12 (its bottom just under the floor, its top 2–3 mm above).
|
||||
let pad = cylinder(Vec3::new(x, 75.0, 9.0), Vec3::Z, 15.0, 3.0).unwrap();
|
||||
let u = boolean(&fall, &pad, BoolOp::Union).unwrap().unwrap();
|
||||
// The part of the pad above the floor: the floor under the disc is planar, so its mean
|
||||
// height is the height at the centre (where the floor is below z = 9 everywhere).
|
||||
let floor = |x: f64| 8.0 + t * (x - 10.0);
|
||||
let above = if floor(x - 15.0) >= 9.0 { PI * 225.0 * (12.0 - floor(x)) } else { f64::NAN };
|
||||
let v = measure(&u).unwrap().volume;
|
||||
if above.is_finite() {
|
||||
assert!(rel(v, v0 + above) < 1e-5, "pad at x {x}: {v} vs {}", v0 + above);
|
||||
} else {
|
||||
assert!(v > v0 && v < v0 + PI * 225.0 * 3.0, "pad at x {x}: {v}");
|
||||
}
|
||||
}
|
||||
// Four faces falling 2° toward a drain point, and a pad over a valley edge and a wall.
|
||||
let t2 = 2f64.to_radians().tan();
|
||||
let d = Vec3::new(240.0, 110.0, 8.0);
|
||||
let hopper = walls(&[
|
||||
hs(Vec3::new(t2, 0.0, -1.0), d),
|
||||
hs(Vec3::new(-t2, 0.0, -1.0), d),
|
||||
hs(Vec3::new(0.0, t2, -1.0), d),
|
||||
hs(Vec3::new(0.0, -t2, -1.0), d),
|
||||
]);
|
||||
let trough = boolean(&block, &hopper, BoolOp::Cut).unwrap().unwrap();
|
||||
let pad = cylinder(Vec3::new(270.0, 70.0, 8.0), Vec3::Z, 25.0, 5.0).unwrap();
|
||||
let u = boolean(&trough, &pad, BoolOp::Union).unwrap().unwrap();
|
||||
// Integrated over the disc (outside the wall at x = 290): about 6517 mm³ above the floor.
|
||||
let gain = measure(&u).unwrap().volume - measure(&trough).unwrap().volume;
|
||||
assert!((gain - 6517.0).abs() < 10.0, "{gain}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn planar_booleans_with_coincident_faces() {
|
||||
// L-bracket: a wall flush with three sides of its base.
|
||||
@@ -363,6 +422,38 @@ fn planar_booleans_with_coincident_faces() {
|
||||
assert!(rel(measure(&i).unwrap().volume, 250.0) < 1e-9);
|
||||
}
|
||||
|
||||
/// The planar fallback runs after any failed B-rep boolean, not only on coincident faces; when
|
||||
/// it can't run it says which body has which curved faces (it used to blame "coincident faces
|
||||
/// on curved bodies" whatever the cause).
|
||||
#[test]
|
||||
fn planar_fallback_names_the_curved_faces() {
|
||||
let slab = box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 10.0)).unwrap();
|
||||
let pad = cylinder(Vec3::new(20.0, 15.0, 5.0), Vec3::Z, 8.0, 10.0).unwrap();
|
||||
let e = planar_boolean(&slab, &pad, BoolOp::Union).unwrap_err().to_string();
|
||||
assert!(e.contains("not supported yet"), "{e}");
|
||||
assert!(e.contains("the tool has 2 curved faces (cylinder)"), "{e}");
|
||||
assert!(!e.contains("target"), "{e}");
|
||||
assert!(!e.contains("coincident faces on curved bodies"), "{e}");
|
||||
let ball = sphere(Vec3::new(20.0, 15.0, 10.0), 5.0).unwrap();
|
||||
let e = planar_boolean(&ball, &pad, BoolOp::Cut).unwrap_err().to_string();
|
||||
assert!(e.contains("the target has 6 curved faces (sphere) and the tool has 2 curved faces (cylinder)"), "{e}");
|
||||
// All-planar bodies, a face of one at a shallow angle (1°): the fallback takes them.
|
||||
let slope = Vec3::new(-1f64.to_radians().tan(), 0.0, 1.0).normalized().unwrap();
|
||||
let wedge = convex_polyhedron(&[
|
||||
HalfSpace { n: Vec3::X, d: 30.0 },
|
||||
HalfSpace { n: -Vec3::X, d: -10.0 },
|
||||
HalfSpace { n: Vec3::Y, d: 20.0 },
|
||||
HalfSpace { n: -Vec3::Y, d: -10.0 },
|
||||
HalfSpace { n: -Vec3::Z, d: -5.0 },
|
||||
HalfSpace { n: slope, d: slope.dot(Vec3::new(10.0, 0.0, 11.0)) },
|
||||
])
|
||||
.unwrap();
|
||||
let u = planar_boolean(&slab, &wedge, BoolOp::Union).unwrap().unwrap();
|
||||
let above = box_solid(Vec3::new(0.0, 0.0, 10.0), Vec3::new(40.0, 30.0, 20.0)).unwrap();
|
||||
let expect = 40.0 * 30.0 * 10.0 + measure(&boolean(&wedge, &above, BoolOp::Intersect).unwrap().unwrap()).unwrap().volume;
|
||||
assert!(rel(measure(&u).unwrap().volume, expect) < 1e-6, "{} vs {expect}", measure(&u).unwrap().volume);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connected_logo_join_preserves_exact_faces_at_edge_contact() {
|
||||
let mut body = box_solid(Vec3::ZERO, Vec3::new(10.0, 20.0, 10.0)).unwrap();
|
||||
@@ -1510,6 +1601,49 @@ fn smooth_loft_through_three_sections() {
|
||||
assert!(v > PI * 64.0 * 30.0 && v < 576.0 * 30.0, "{v}");
|
||||
}
|
||||
|
||||
/// A loft from the basin's whole outline (10 up) down to a drain pad in a corner (1 up), cut
|
||||
/// from a 3 mm shell: its top edges lie on the outer walls. One order of the sections failed
|
||||
/// the cut ("shell not oriented and closed", or a panic inside the intersection), the other
|
||||
/// worked; both orders now build the same body, valid and outward.
|
||||
#[test]
|
||||
fn loft_section_order_gives_the_same_body() {
|
||||
let r = |x0: f64, y0: f64, x1: f64, y1: f64| Loop2::polygon(&[Vec2::new(x0, y0), Vec2::new(x1, y0), Vec2::new(x1, y1), Vec2::new(x0, y1)]);
|
||||
let at = |z: f64| Plane::new(Vec3::new(0.0, 0.0, z), Vec3::X, Vec3::Y).unwrap();
|
||||
let big = (at(10.0), r(0.0, 0.0, 100.0, 60.0));
|
||||
let small = (at(1.0), r(80.0, 40.0, 90.0, 50.0));
|
||||
let basin = shell(&box_solid(Vec3::ZERO, Vec3::new(100.0, 60.0, 30.0)).unwrap(), &[Vec3::new(50.0, 30.0, 30.0)], 3.0).unwrap();
|
||||
// The material inside the loft: the floor from 1 to 3 up, the walls from 3 to 10.
|
||||
let v0 = measure(&basin).unwrap().volume;
|
||||
let removed = v0 - 41582.028;
|
||||
let mut volumes = Vec::new();
|
||||
for secs in [vec![big.clone(), small.clone()], vec![small.clone(), big.clone()]] {
|
||||
let l = loft(&secs).unwrap();
|
||||
let m = measure(&l).unwrap();
|
||||
assert!(m.volume > 0.0, "{}", m.volume);
|
||||
volumes.push(m.volume);
|
||||
let cut = boolean(&basin, &l, BoolOp::Cut).unwrap().unwrap();
|
||||
let v = measure(&cut).unwrap().volume;
|
||||
assert!((v0 - v - removed).abs() < 0.01 * removed, "{v} vs {}", v0 - removed);
|
||||
let joined = boolean(&basin, &l, BoolOp::Union).unwrap().unwrap();
|
||||
assert!(measure(&joined).unwrap().volume > v0);
|
||||
}
|
||||
assert!(rel(volumes[0], volumes[1]) < 1e-12, "{volumes:?}");
|
||||
// Reversed in z too (large below, small above, and sketched on planes facing down).
|
||||
let down = |z: f64| Plane::new(Vec3::new(0.0, 0.0, z), Vec3::Y, Vec3::X).unwrap();
|
||||
for secs in [
|
||||
vec![(at(0.0), r(0.0, 0.0, 100.0, 60.0)), (at(20.0), r(40.0, 20.0, 60.0, 40.0))],
|
||||
vec![(down(20.0), r(20.0, 40.0, 40.0, 60.0)), (at(0.0), r(0.0, 0.0, 100.0, 60.0))],
|
||||
] {
|
||||
// Prismoid: h/6 · (A0 + 4 Amid + A1).
|
||||
let want = 20.0 / 6.0 * (6000.0 + 4.0 * 60.0 * 40.0 + 400.0);
|
||||
let back: Vec<(Plane, Loop2)> = secs.iter().rev().cloned().collect();
|
||||
for b in [loft(&secs).unwrap(), loft(&back).unwrap()] {
|
||||
let v = measure(&b).unwrap().volume;
|
||||
assert!(rel(v, want) < 1e-6, "{v} vs {want}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tangent_holes_pinch_alike() {
|
||||
// Holes at ±15 and ±25 touch along a line (x = ±20); ±30 overlaps ±25. The ±25 wall is cut
|
||||
@@ -1684,3 +1818,104 @@ fn orientation_mesh_reuses_missing_face_check_at_matching_tolerance() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A trough whose floor falls about 1° toward a central drain, the fall starting right at the
|
||||
/// trough edges: four faces meet at each bottom corner (two walls, two fall facets) and at the
|
||||
/// drain (four facets). A tab on one end makes the body non-convex. The moved planes of four
|
||||
/// faces don't meet at one point, so each such corner of the cavity becomes two corners joined
|
||||
/// by a short edge. Checked against the volume integrated by hand.
|
||||
#[test]
|
||||
fn shell_where_four_faces_meet_at_a_corner() {
|
||||
let (w, l, ht, t) = (100.0, 60.0, 40.0, 2.0);
|
||||
// Drain depth: the long facets fall 1°.
|
||||
let h = 30.0 * 1f64.to_radians().tan();
|
||||
let facet = |n: Vec3, p: Vec3| {
|
||||
let n = n.normalized().unwrap();
|
||||
HalfSpace { n, d: n.dot(p) }
|
||||
};
|
||||
let facets = [
|
||||
facet(Vec3::new(0.0, -h, -30.0), Vec3::ZERO),
|
||||
facet(Vec3::new(0.0, h, -30.0), Vec3::new(0.0, l, 0.0)),
|
||||
facet(Vec3::new(-h, 0.0, -50.0), Vec3::ZERO),
|
||||
facet(Vec3::new(h, 0.0, -50.0), Vec3::new(w, 0.0, 0.0)),
|
||||
];
|
||||
let mut hs = vec![
|
||||
HalfSpace { n: -Vec3::X, d: 0.0 },
|
||||
HalfSpace { n: Vec3::X, d: w },
|
||||
HalfSpace { n: -Vec3::Y, d: 0.0 },
|
||||
HalfSpace { n: Vec3::Y, d: l },
|
||||
HalfSpace { n: Vec3::Z, d: ht },
|
||||
];
|
||||
hs.extend(facets);
|
||||
let trough = convex_polyhedron(&hs).unwrap();
|
||||
let pyramid = w * l * h / 3.0;
|
||||
assert!(rel(measure(&trough).unwrap().volume, w * l * ht + pyramid) < 1e-9);
|
||||
let tab = box_solid(Vec3::new(w, 20.0, 20.0), Vec3::new(w + 20.0, 40.0, ht)).unwrap();
|
||||
let body = boolean(&trough, &tab, BoolOp::Union).unwrap().unwrap();
|
||||
let s = shell(&body, &[Vec3::new(50.0, 30.0, ht)], t).unwrap();
|
||||
// The cavity: the walls in by t, above the floor facets moved in by t (midpoint rule; the
|
||||
// floor is linear between its creases).
|
||||
let floor = |x: f64, y: f64| facets.iter().map(|f| (f.d - t - f.n.x * x - f.n.y * y) / f.n.z).fold(f64::NEG_INFINITY, f64::max);
|
||||
let (nx, ny) = (960, 560);
|
||||
let (dx, dy) = ((w - 2.0 * t) / nx as f64, (l - 2.0 * t) / ny as f64);
|
||||
let mut cavity = 0.0;
|
||||
for i in 0..nx {
|
||||
for j in 0..ny {
|
||||
cavity += (ht - floor(t + (i as f64 + 0.5) * dx, t + (j as f64 + 0.5) * dy)) * dx * dy;
|
||||
}
|
||||
}
|
||||
let tab_cavity = 20.0 * (20.0 - 2.0 * t) * (20.0 - t);
|
||||
let want = w * l * ht + pyramid + 8000.0 - cavity - tab_cavity;
|
||||
let m = measure(&s).unwrap();
|
||||
assert!(rel(m.volume, want) < 1e-5, "{} vs {want}", m.volume);
|
||||
}
|
||||
|
||||
/// The same trough cut as a pocket into a slab (four faces meet at each concave corner of the
|
||||
/// pocket floor), shelled with its top open.
|
||||
#[test]
|
||||
fn shell_of_a_pocket_whose_floor_falls_to_a_drain() {
|
||||
let (px, py, pw, pl, top, depth, t) = (20.0, 15.0, 100.0, 60.0, 50.0, 30.0, 3.0);
|
||||
let h = 30.0 * 1f64.to_radians().tan();
|
||||
let z0 = top - depth;
|
||||
let facet = |n: Vec3, p: Vec3| {
|
||||
let n = n.normalized().unwrap();
|
||||
HalfSpace { n, d: n.dot(p) }
|
||||
};
|
||||
let facets = [
|
||||
facet(Vec3::new(0.0, -h, -30.0), Vec3::new(px, py, z0)),
|
||||
facet(Vec3::new(0.0, h, -30.0), Vec3::new(px, py + pl, z0)),
|
||||
facet(Vec3::new(-h, 0.0, -50.0), Vec3::new(px, py, z0)),
|
||||
facet(Vec3::new(h, 0.0, -50.0), Vec3::new(px + pw, py, z0)),
|
||||
];
|
||||
let mut hs = vec![
|
||||
HalfSpace { n: -Vec3::X, d: -px },
|
||||
HalfSpace { n: Vec3::X, d: px + pw },
|
||||
HalfSpace { n: -Vec3::Y, d: -py },
|
||||
HalfSpace { n: Vec3::Y, d: py + pl },
|
||||
HalfSpace { n: Vec3::Z, d: top + 10.0 },
|
||||
];
|
||||
hs.extend(facets);
|
||||
let pocket = convex_polyhedron(&hs).unwrap();
|
||||
let slab = box_solid(Vec3::ZERO, Vec3::new(140.0, 90.0, top)).unwrap();
|
||||
let basin = boolean(&slab, &pocket, BoolOp::Cut).unwrap().unwrap();
|
||||
let pocket_vol = pw * pl * depth + pw * pl * h / 3.0;
|
||||
assert!(rel(measure(&basin).unwrap().volume, 140.0 * 90.0 * top - pocket_vol) < 1e-9);
|
||||
let s = shell(&basin, &[Vec3::new(5.0, 5.0, top)], t).unwrap();
|
||||
// The cavity's columns: from t up to the top, or up to the pocket floor moved down by t
|
||||
// inside the pocket's walls moved out by t.
|
||||
let floor = |x: f64, y: f64| facets.iter().map(|f| (f.d + t - f.n.x * x - f.n.y * y) / f.n.z).fold(f64::NEG_INFINITY, f64::max);
|
||||
let (nx, ny) = (1340, 840);
|
||||
let (dx, dy) = ((140.0 - 2.0 * t) / nx as f64, (90.0 - 2.0 * t) / ny as f64);
|
||||
let mut cavity = 0.0;
|
||||
for i in 0..nx {
|
||||
for j in 0..ny {
|
||||
let (x, y) = (t + (i as f64 + 0.5) * dx, t + (j as f64 + 0.5) * dy);
|
||||
let inside = x > px - t && x < px + pw + t && y > py - t && y < py + pl + t;
|
||||
let roof = if inside { floor(x, y) } else { top };
|
||||
cavity += (roof - t) * dx * dy;
|
||||
}
|
||||
}
|
||||
let want = 140.0 * 90.0 * top - pocket_vol - cavity;
|
||||
let m = measure(&s).unwrap();
|
||||
assert!(rel(m.volume, want) < 1e-4, "{} vs {want}", m.volume);
|
||||
}
|
||||
@@ -58,7 +58,10 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
{center: [20,15], radius: 5}; sketch.dimension {entities: [curve id], value: \"width\"}; sketch.finish {}; solid.extrude \
|
||||
{distance: 20, operation: \"cut\"}; solid.fillet {edges: [[0,0,10]], radius: 3}; solid.box {length, \
|
||||
width, height}. Lengths are mm; expressions may use parameters and units (\"width / 2\", \"30 deg\"). Returns \
|
||||
the command's result; failures leave the design unchanged.",
|
||||
the command's result; failures leave the design unchanged. Sketch commands that add geometry return \
|
||||
created: {points, curves, constraints, dimensions}: the ids of what they added, stable and not reused \
|
||||
after a delete (undo rewinds the counters with the design), usable right away \
|
||||
in sketch.dimension, sketch.constraint.* and sketch.delete.",
|
||||
obj(
|
||||
json!({
|
||||
"command": string("Command id (see list_commands)"),
|
||||
@@ -148,7 +151,8 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
"Look at the design",
|
||||
"Render the design and return it as a PNG image. Headless: a CPU render of the model, fitted to the view. \
|
||||
Connected to the app: a screenshot of the window (source \"window\", the default) or a render of the model \
|
||||
with the app's camera (source \"model\"). `view` first turns the camera to a standard view.",
|
||||
with the app's camera (source \"model\"). Both show an active section (inspect.section) with its cut face \
|
||||
capped. `view` first turns the camera to a standard view.",
|
||||
obj(
|
||||
json!({
|
||||
"view": {"type": "string", "enum": VIEWS, "description": "Standard view (default: keep the current one; headless starts at iso)"},
|
||||
|
||||
@@ -8,4 +8,4 @@ mod camera;
|
||||
mod raster;
|
||||
|
||||
pub use camera::{Camera, CameraAnim, Mat4, Quat, StandardView, UpAxis, ease_in_out};
|
||||
pub use raster::{Canvas, Rgb, Scene, SceneLine, SceneMesh, png, render, render_png, render_transparent};
|
||||
pub use raster::{Canvas, Rgb, SECTION_CAP, Scene, SceneLine, SceneMesh, png, render, render_png, render_transparent};
|
||||
+91
-14
@@ -43,8 +43,13 @@ pub struct Scene {
|
||||
/// A section cut: (normal, d); what lies where normal·p > d is cut away, and the inside
|
||||
/// shows as a hatched cap.
|
||||
pub clip: Option<(Vec3, f64)>,
|
||||
/// The colour the section cap is tinted toward (default: [`SECTION_CAP`]).
|
||||
pub cap: Option<Rgb>,
|
||||
}
|
||||
|
||||
/// The section cap's default tint, as in the viewport.
|
||||
pub const SECTION_CAP: Rgb = Rgb(214, 128, 72);
|
||||
|
||||
/// RGBA pixels plus depth.
|
||||
pub struct Canvas {
|
||||
pub w: usize,
|
||||
@@ -105,16 +110,28 @@ pub fn render(scene: &Scene, cam: &Camera, w: usize, h: usize) -> Canvas {
|
||||
Some([(q[0] / q[3] + 1.0) * 0.5 * sw as f64, (1.0 - q[1] / q[3]) * 0.5 * sh as f64, q[2] / q[3]])
|
||||
};
|
||||
let view_dir = cam.back();
|
||||
// Where the section cap is the visible surface: the depth of the section plane on that
|
||||
// pixel's view ray. Written into the depth buffer once the solids are drawn, so the cut face
|
||||
// hides edges inside the body (holes, far corners) instead of showing them like a pane of
|
||||
// glass, while holes through the cut stay open (as the viewport does with its stencil).
|
||||
let cap_z = scene.clip.and_then(|(n, d)| cap_plane(cam, n, d, scene.radius.max(1.0), &project));
|
||||
let mut cap_depth = if cap_z.is_some() { vec![f32::INFINITY; sw * sh] } else { Vec::new() };
|
||||
let cap_tint = scene.cap.unwrap_or(SECTION_CAP).f();
|
||||
for sm in &scene.meshes {
|
||||
let m = &sm.mesh;
|
||||
let body = sm.color.f();
|
||||
let pts: Vec<Option<[f64; 3]>> = m.positions.iter().map(|p| project(*p)).collect();
|
||||
// 1 / clip w per vertex, for perspective-correct interpolation of world positions (so
|
||||
// the section cuts along the plane, not a curve, in perspective views).
|
||||
let inv_w: Vec<f64> = m.positions.iter().map(|p| 1.0 / vp.apply(*p)[3].max(1e-9)).collect();
|
||||
for (ti, t) in m.triangles.iter().enumerate() {
|
||||
let face = m.tri_face.get(ti).copied();
|
||||
let base = sm.face_colors.iter().find(|(f, _)| Some(*f) == face).map_or(body, |(_, c)| c.f());
|
||||
let wp = t.map(|k| m.positions.get(k as usize).copied().unwrap_or_default());
|
||||
// Seen from inside (only where a section cuts the body open).
|
||||
let inside = (wp[1] - wp[0]).cross(wp[2] - wp[0]).dot(view_dir) < 0.0;
|
||||
let to_eye = if cam.fov > 0.0 { cam.eye() - wp[0] } else { view_dir };
|
||||
let inside = (wp[1] - wp[0]).cross(wp[2] - wp[0]).dot(to_eye) < 0.0;
|
||||
let iw = t.map(|k| inv_w.get(k as usize).copied().unwrap_or(1.0));
|
||||
let (Some(Some(a)), Some(Some(b)), Some(Some(cc))) = (pts.get(t[0] as usize), pts.get(t[1] as usize), pts.get(t[2] as usize)) else {
|
||||
continue;
|
||||
};
|
||||
@@ -140,8 +157,11 @@ pub fn render(scene: &Scene, cam: &Camera, w: usize, h: usize) -> Canvas {
|
||||
if w0 < -1e-9 || w1 < -1e-9 || w2 < -1e-9 {
|
||||
continue;
|
||||
}
|
||||
let (q0, q1, q2) = (w0 * iw[0], w1 * iw[1], w2 * iw[2]);
|
||||
let qs = q0 + q1 + q2;
|
||||
let (p0, p1, p2) = if qs > 1e-12 { (q0 / qs, q1 / qs, q2 / qs) } else { (w0, w1, w2) };
|
||||
if let Some((cn, cd)) = scene.clip
|
||||
&& cn.dot(wp[0] * w0 + wp[1] * w1 + wp[2] * w2) > cd
|
||||
&& cn.dot(wp[0] * p0 + wp[1] * p1 + wp[2] * p2) > cd
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -151,11 +171,20 @@ pub fn render(scene: &Scene, cam: &Camera, w: usize, h: usize) -> Canvas {
|
||||
if let Some(d) = c.depth.get_mut(i) {
|
||||
*d = z;
|
||||
}
|
||||
let n = (ns[0] * w0 + ns[1] * w1 + ns[2] * w2).normalized().unwrap_or(Vec3::Z);
|
||||
if scene.clip.is_some() && inside {
|
||||
// The cut: the body's colour, hatched at 45 degrees.
|
||||
let k = if ((x + y) / SS) % 9 < 2 { 0.5 } else { 0.8 };
|
||||
c.put(x, y, base.map(|v| v * k), 1.0);
|
||||
let n = (ns[0] * p0 + ns[1] * p1 + ns[2] * p2).normalized().unwrap_or(Vec3::Z);
|
||||
let cut = scene.clip.is_some() && inside;
|
||||
if let Some(d) = cap_depth.get_mut(i) {
|
||||
*d = match cap_z {
|
||||
Some([a, b, k]) if cut => ((a * px + b * py + k) as f32).min(z),
|
||||
_ => f32::INFINITY,
|
||||
};
|
||||
}
|
||||
if cut {
|
||||
// The cut: the body's colour tinted toward the cap colour, hatched
|
||||
// at 45 degrees (as the viewport draws it).
|
||||
let tinted: [f64; 3] = std::array::from_fn(|k| base[k] + (cap_tint[k] - base[k]) * 0.35);
|
||||
let k = if ((x + y) / SS) % 9 < 2 { 0.6 } else { 1.0 };
|
||||
c.put(x, y, tinted.map(|v| v * k), 1.0);
|
||||
continue;
|
||||
}
|
||||
let mut col = shade(n, view_dir, base);
|
||||
@@ -172,21 +201,24 @@ pub fn render(scene: &Scene, cam: &Camera, w: usize, h: usize) -> Canvas {
|
||||
}
|
||||
}
|
||||
}
|
||||
for (d, cz) in c.depth.iter_mut().zip(&cap_depth) {
|
||||
if cz.is_finite() {
|
||||
*d = *cz;
|
||||
}
|
||||
}
|
||||
for l in &scene.lines {
|
||||
let col = l.color.f();
|
||||
let half = (l.width * SS as f64 * 0.5).max(0.5);
|
||||
for seg in l.points.windows(2) {
|
||||
let (Some(p), Some(q)) = (seg.first(), seg.get(1)) else { continue };
|
||||
let (Some(a), Some(b)) = (project(*p), project(*q)) else { continue };
|
||||
let (Some(&p), Some(&q)) = (seg.first(), seg.get(1)) else { continue };
|
||||
// The section cuts the segment in 3D (screen space is not linear in depth in
|
||||
// perspective views).
|
||||
let Some((p, q)) = clip_segment(p, q, scene.clip) else { continue };
|
||||
let (Some(a), Some(b)) = (project(p), project(q)) else { continue };
|
||||
let len = ((b[0] - a[0]).powi(2) + (b[1] - a[1]).powi(2)).sqrt();
|
||||
let steps = (len.ceil() as usize).clamp(1, 20_000);
|
||||
for k in 0..=steps {
|
||||
let t = k as f64 / steps as f64;
|
||||
if let Some((cn, cd)) = scene.clip
|
||||
&& cn.dot(*p + (*q - *p) * t) > cd + 1e-6
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let (x, y, z) = (a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t);
|
||||
let r = half.ceil() as i64;
|
||||
for dy in -r..=r {
|
||||
@@ -234,6 +266,39 @@ pub fn render(scene: &Scene, cam: &Camera, w: usize, h: usize) -> Canvas {
|
||||
out
|
||||
}
|
||||
|
||||
/// The part of segment `p`–`q` the section keeps (where normal·x <= d).
|
||||
fn clip_segment(p: Vec3, q: Vec3, clip: Option<(Vec3, f64)>) -> Option<(Vec3, Vec3)> {
|
||||
let Some((n, d)) = clip else { return Some((p, q)) };
|
||||
let (dp, dq) = (n.dot(p) - d - 1e-6, n.dot(q) - d - 1e-6);
|
||||
match (dp > 0.0, dq > 0.0) {
|
||||
(true, true) => None,
|
||||
(false, false) => Some((p, q)),
|
||||
(true, false) => Some((p + (q - p) * (dp / (dp - dq)), q)),
|
||||
(false, true) => Some((p, p + (q - p) * (dp / (dp - dq)))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Screen depth of the section plane `n·p = d` as `z = a·x + b·y + k` in canvas pixels (a plane
|
||||
/// stays a plane under projection), set a hair behind the plane so the cut's own outline stays
|
||||
/// drawn. None when the plane is seen edge-on or lies behind the eye.
|
||||
fn cap_plane(cam: &Camera, n: Vec3, d: f64, radius: f64, project: &dyn Fn(Vec3) -> Option<[f64; 3]>) -> Option<[f64; 3]> {
|
||||
let margin = 1e-3 * (1.0 + radius);
|
||||
let o = cam.target - n * (n.dot(cam.target) - d - margin);
|
||||
let (u, v) = (n.any_perp(), n.cross(n.any_perp()));
|
||||
let s = radius * 0.1;
|
||||
let (p0, p1, p2) = (project(o)?, project(o + u * s)?, project(o + v * s)?);
|
||||
let (ax, ay, az) = (p1[0] - p0[0], p1[1] - p0[1], p1[2] - p0[2]);
|
||||
let (bx, by, bz) = (p2[0] - p0[0], p2[1] - p0[1], p2[2] - p0[2]);
|
||||
let det = ax * by - ay * bx;
|
||||
if det.abs() < 1e-9 {
|
||||
return None;
|
||||
}
|
||||
let a = (az * by - ay * bz) / det;
|
||||
let b = (ax * bz - az * bx) / det;
|
||||
let k = p0[2] - a * p0[0] - b * p0[1];
|
||||
[a, b, k].iter().all(|x| x.is_finite()).then_some([a, b, k])
|
||||
}
|
||||
|
||||
/// Render to PNG bytes.
|
||||
pub fn render_png(scene: &Scene, cam: &Camera, w: usize, h: usize) -> Option<Vec<u8>> {
|
||||
let c = render(scene, cam, w, h);
|
||||
@@ -274,6 +339,17 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::StandardView;
|
||||
|
||||
/// Lines are cut by the section in 3D: the kept piece ends on the plane.
|
||||
#[test]
|
||||
fn section_clips_lines_at_the_plane() {
|
||||
let clip = Some((Vec3::X, 2.0));
|
||||
let (p, q) = clip_segment(Vec3::ZERO, Vec3::new(10.0, 4.0, 0.0), clip).unwrap();
|
||||
assert_eq!(p, Vec3::ZERO);
|
||||
assert!((q.x - 2.0).abs() < 1e-5 && (q.y - 0.8).abs() < 1e-5, "{q:?}");
|
||||
assert!(clip_segment(Vec3::new(3.0, 0.0, 0.0), Vec3::new(5.0, 0.0, 0.0), clip).is_none());
|
||||
assert!(clip_segment(Vec3::new(3.0, 0.0, 0.0), Vec3::new(5.0, 0.0, 0.0), None).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_a_triangle() {
|
||||
let mesh = Mesh {
|
||||
@@ -291,6 +367,7 @@ mod tests {
|
||||
background: None,
|
||||
radius: 20.0,
|
||||
clip: None,
|
||||
cap: None,
|
||||
};
|
||||
let mut cam = Camera { distance: 50.0, ..Default::default() };
|
||||
cam.set_view(StandardView::Front);
|
||||
|
||||
@@ -361,6 +361,10 @@ impl ConstraintKind {
|
||||
LinearDiameter { .. } => "LinearDiameterDimension",
|
||||
}
|
||||
}
|
||||
/// Does the constraint refer to point `p` (by index)?
|
||||
pub fn refers_to_point(&self, p: usize) -> bool {
|
||||
self.indices().0.contains(&p)
|
||||
}
|
||||
fn indices(&self) -> (Vec<usize>, Vec<usize>) {
|
||||
use ConstraintKind::*;
|
||||
match *self {
|
||||
@@ -892,6 +896,48 @@ impl Sketch {
|
||||
out
|
||||
}
|
||||
|
||||
/// Virtual sharp corners (what a fillet or chamfer leaves where dimensions measured to the
|
||||
/// corner): points used by no curve, held on two or more lines or arcs (their extensions).
|
||||
/// Each with the extension segments from the corner to the nearest end of those curves, for
|
||||
/// drawing.
|
||||
pub fn virtual_corners(&self) -> Vec<(usize, Vec<(Vec2, Vec2)>)> {
|
||||
let mut out = Vec::new();
|
||||
for (i, p) in self.points.iter().enumerate().skip(1) {
|
||||
if self.point_used_by_curve(i) {
|
||||
continue;
|
||||
}
|
||||
let on: Vec<usize> = self
|
||||
.constraints
|
||||
.iter()
|
||||
.filter_map(|c| match c.kind {
|
||||
ConstraintKind::PointOnCurve { p, c } if p == i => Some(c),
|
||||
_ => None,
|
||||
})
|
||||
.filter(|c| matches!(self.curves.get(*c).map(|c| &c.kind), Some(CurveKind::Line { .. } | CurveKind::Arc { .. })))
|
||||
.collect();
|
||||
if on.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
let mut ext = Vec::new();
|
||||
for c in on {
|
||||
let Some((a, b)) = self.curves.get(c).and_then(|c| c.kind.ends()) else { continue };
|
||||
let (Some(pa), Some(pb)) = (self.point(a), self.point(b)) else { continue };
|
||||
let near = if p.pos.dist(pa) <= p.pos.dist(pb) { pa } else { pb };
|
||||
// A line only needs one when the corner is beyond its ends.
|
||||
let inside = matches!(self.curves.get(c).map(|c| &c.kind), Some(CurveKind::Line { .. })) && {
|
||||
let d = pb - pa;
|
||||
let t = (p.pos - pa).dot(d) / d.dot(d).max(1e-300);
|
||||
(0.0..=1.0).contains(&t)
|
||||
};
|
||||
if !inside && p.pos.dist(near) > 1e-9 {
|
||||
ext.push((p.pos, near));
|
||||
}
|
||||
}
|
||||
out.push((i, ext));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Is curve `c` held in place: fixed or projected (linked).
|
||||
pub fn curve_locked(&self, c: usize) -> bool {
|
||||
self.curves.get(c).is_some_and(|c| c.fixed || c.link.is_some() || self.freeform_offsets.iter().any(|offset| offset.output == c.id))
|
||||
|
||||
@@ -17,6 +17,7 @@ serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
web-time = { workspace = true }
|
||||
image = { workspace = true, features = ["jpeg"] }
|
||||
ttf-parser = { workspace = true }
|
||||
|
||||
[build-dependencies]
|
||||
serde_json = { workspace = true }
|
||||
@@ -367,7 +367,8 @@ pub(crate) fn draw_row(ui: &mut egui::Ui, id: egui::Id, row: &Row) -> RowResp {
|
||||
x += 21.0;
|
||||
}
|
||||
let col = row.color.unwrap_or(if row.dim { t.text_dim } else { t.text });
|
||||
let galley = ui.painter().layout_no_wrap(row.label.to_string(), FontId::proportional(12.5), col);
|
||||
let label_display = crate::i18n::tr_dyn(row.label);
|
||||
let galley = ui.painter().layout_no_wrap(label_display, FontId::proportional(12.5), col);
|
||||
let lw = galley.size().x;
|
||||
ui.painter().galley(pos2(x, cy - galley.size().y / 2.0), galley, col);
|
||||
let mut bx = (x + lw + 6.0).min(r.right() - 16.0);
|
||||
@@ -378,9 +379,9 @@ pub(crate) fn draw_row(ui: &mut egui::Ui, id: egui::Id, row: &Row) -> RowResp {
|
||||
}
|
||||
crate::scenario::publish_handle(&format!("row:{}", row.label), r.center());
|
||||
let tip = match hover_on {
|
||||
1 => Some(if row.fold == Some(true) { "Collapse" } else { "Expand" }),
|
||||
2 => Some(if row.eye == Some(true) { "Hide" } else { "Show" }),
|
||||
3 => Some(if row.radio == Some(true) { "Active component" } else { "Activate" }),
|
||||
1 => Some(if row.fold == Some(true) { crate::i18n::tr("ui.collapse") } else { crate::i18n::tr("ui.expand") }),
|
||||
2 => Some(if row.eye == Some(true) { crate::i18n::tr("ui.hide") } else { crate::i18n::tr("ui.show") }),
|
||||
3 => Some(if row.radio == Some(true) { crate::i18n::tr("ui.active_component") } else { crate::i18n::tr("ui.activate") }),
|
||||
_ => None,
|
||||
};
|
||||
let resp = match tip {
|
||||
|
||||
@@ -1385,7 +1385,8 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
painter.circle_filled(c, 3.0, t.text_dim);
|
||||
}
|
||||
for (k, item) in radial.iter().enumerate() {
|
||||
let galley = painter.layout_no_wrap(item.label.clone(), font.clone(), if item.enabled { t.text } else { t.text_dim });
|
||||
let display_label = crate::i18n::tr_label(&item.label);
|
||||
let galley = painter.layout_no_wrap(display_label.to_string(), font.clone(), if item.enabled { t.text } else { t.text_dim });
|
||||
let size = vec2(galley.size().x + 40.0, 28.0);
|
||||
let rr = slot_rect(c, k, size);
|
||||
let resp = ui.interact(rr, ui.id().with(("radial", k)), Sense::click());
|
||||
@@ -1407,7 +1408,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
picked = Some(item.clone());
|
||||
}
|
||||
if item.enabled {
|
||||
resp.on_hover_text(item.shortcut.clone());
|
||||
resp.on_hover_text(crate::i18n::tr_label(&item.shortcut).to_string());
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1501,7 +1502,7 @@ pub fn list_row_lit(ui: &mut egui::Ui, item: &Item, lit: bool) -> Option<Item> {
|
||||
return None;
|
||||
}
|
||||
if item.is_heading() {
|
||||
let text = ui.painter().layout(item.label.to_uppercase(), FontId::proportional(10.5), t.text_dim, w - 16.0);
|
||||
let text = ui.painter().layout(crate::i18n::tr_label(&item.label).to_uppercase(), FontId::proportional(10.5), t.text_dim, w - 16.0);
|
||||
let (r, _) = ui.allocate_exact_size(vec2(w, (text.size().y + 8.0).max(20.0)), Sense::hover());
|
||||
ui.painter().galley(pos2(r.left() + 8.0, r.top() + 4.0), text, t.text_dim);
|
||||
return None;
|
||||
@@ -1522,9 +1523,11 @@ pub fn list_row_lit(ui: &mut egui::Ui, item: &Item, lit: bool) -> Option<Item> {
|
||||
);
|
||||
}
|
||||
let text = if item.enabled { t.text } else { t.text_dim.gamma_multiply(0.75) };
|
||||
ui.painter().text(pos2(r.left() + 32.0, r.center().y), Align2::LEFT_CENTER, &item.label, FontId::proportional(13.0), text);
|
||||
let display_label = crate::i18n::tr_label(&item.label);
|
||||
ui.painter().text(pos2(r.left() + 32.0, r.center().y), Align2::LEFT_CENTER, display_label, FontId::proportional(13.0), text);
|
||||
if !item.shortcut.is_empty() {
|
||||
ui.painter().text(pos2(r.right() - 10.0, r.center().y), Align2::RIGHT_CENTER, &item.shortcut, FontId::proportional(11.5), t.text_dim);
|
||||
let display_shortcut = crate::i18n::tr_label(&item.shortcut);
|
||||
ui.painter().text(pos2(r.right() - 10.0, r.center().y), Align2::RIGHT_CENTER, display_shortcut, FontId::proportional(11.5), t.text_dim);
|
||||
}
|
||||
(item.enabled && resp.clicked()).then(|| item.clone())
|
||||
}
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
//! - `ui.click {x, y, button?, shift?}`, `ui.move {x, y}`, `ui.scroll {x, y, delta}`: real
|
||||
//! pointer input in screen points; `ui.key {key, cmd?, shift?}`, `ui.text {text}`
|
||||
//! - `ui.screenshot {path?}`: PNG of the window; `ui.render {path, width?, height?}`: headless
|
||||
//! CPU render of the model with the current camera (no window needed)
|
||||
//! CPU render of the model with the current camera and the section the viewport shows (no
|
||||
//! window needed)
|
||||
//! - `ui.menu {x?, y?, target?, close?}`: open a context menu at a point (the viewport's marking
|
||||
//! menu, or a browser menu with `target: {type: body|sketch|component, …}`); without a point,
|
||||
//! the open menu and its items. `ui.menuPick {item}` runs an item by id or label;
|
||||
@@ -523,7 +524,9 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) ->
|
||||
"ui.render" => {
|
||||
let w = f("width").unwrap_or(1280.0).clamp(16.0, 8192.0) as usize;
|
||||
let h = f("height").unwrap_or(800.0).clamp(16.0, 8192.0) as usize;
|
||||
let scene = solvecraft_engine::view::scene(&app.session, &app.cam);
|
||||
// Cut by the section the viewport shows (an open Section Analysis dialog's preview,
|
||||
// a sketch's Slice), not only the committed one.
|
||||
let scene = solvecraft_engine::view::scene_with_section(&app.session, &app.cam, crate::viewport::section_plane(app));
|
||||
let Some(png) = render_png(&scene, &app.cam, w, h) else { return err("render failed") };
|
||||
match s("path") {
|
||||
Some(path) => match std::fs::write(path, &png) {
|
||||
@@ -563,3 +566,45 @@ pub fn save_screenshot(image: &egui::ColorImage, path: Option<&str>) -> Value {
|
||||
Err(e) => json!({"ok": false, "error": e.to_string()}),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
use solvecraft_engine::{Sel, Session};
|
||||
|
||||
use super::*;
|
||||
use crate::Services;
|
||||
|
||||
fn render(app: &mut SolveApp, path: &std::path::Path) -> Vec<u8> {
|
||||
let (req, _rx) = ControlRequest::new("ui.render", json!({"path": path.to_string_lossy(), "width": 200, "height": 140}));
|
||||
match handle(app, &egui::Context::default(), &req) {
|
||||
Outcome::Done(v) => assert_eq!(v["ok"], true, "{v}"),
|
||||
Outcome::Screenshot { .. } => panic!("ui.render took a screenshot"),
|
||||
}
|
||||
std::fs::read(path).unwrap()
|
||||
}
|
||||
|
||||
/// A model render shows the section the window shows: the open Section Analysis dialog's
|
||||
/// preview too, not only a committed `inspect.section`.
|
||||
#[test]
|
||||
fn model_render_shows_the_section_dialog_preview() {
|
||||
let mut s = Session::default();
|
||||
s.execute("solid.box", &json!({"length": 40, "width": 30, "height": 20})).unwrap();
|
||||
let mut app = SolveApp::new(s, Services::default());
|
||||
let dir = std::env::temp_dir().join(format!("solvecraft-render-section-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let whole = render(&mut app, &dir.join("whole.png"));
|
||||
app.session.selection = vec![Sel::Plane { name: "XZ".into() }];
|
||||
app.start("inspect.section");
|
||||
let Some(crate::dialogs::Kind::Section { offset, .. }) = app.dialog.as_mut().map(|d| &mut d.kind) else { panic!("no section dialog") };
|
||||
*offset = "15 mm".into();
|
||||
assert!(app.session.section.is_none() && crate::viewport::section_plane(&app).is_some());
|
||||
let preview = render(&mut app, &dir.join("preview.png"));
|
||||
assert!(whole != preview, "the model render ignores the section the window shows");
|
||||
// Committed, it renders the same.
|
||||
app.run("inspect.section", json!({"plane": "XZ", "offset": 15})).unwrap();
|
||||
app.dialog = None;
|
||||
assert!(render(&mut app, &dir.join("committed.png")) == preview, "the committed section renders differently");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -775,9 +775,10 @@ pub(crate) fn field(ui: &mut egui::Ui, s: &mut String) -> bool {
|
||||
|
||||
pub(crate) fn combo(ui: &mut egui::Ui, id: &str, labels: &[&str], sel: &mut usize) {
|
||||
let row = ROW.with(|r| r.borrow().clone());
|
||||
let r = egui::ComboBox::from_id_salt(id).selected_text(labels.get(*sel).copied().unwrap_or("")).width(FIELD_W).show_ui(ui, |ui| {
|
||||
let selected = labels.get(*sel).copied().map(tr).unwrap_or("");
|
||||
let r = egui::ComboBox::from_id_salt(id).selected_text(selected).width(FIELD_W).show_ui(ui, |ui| {
|
||||
for (i, l) in labels.iter().enumerate() {
|
||||
let item = ui.selectable_value(sel, i, *l);
|
||||
let item = ui.selectable_value(sel, i, tr(l));
|
||||
crate::scenario::publish_handle(&format!("dialog:{row}:{l}"), item.rect.center());
|
||||
}
|
||||
});
|
||||
@@ -789,6 +790,10 @@ thread_local! {
|
||||
static ROW: std::cell::RefCell<String> = const { std::cell::RefCell::new(String::new()) };
|
||||
}
|
||||
|
||||
fn tr(text: &str) -> &str {
|
||||
crate::i18n::tr_label(text)
|
||||
}
|
||||
|
||||
/// A dialog row's widget, for scenarios and demos: the handle `dialog:<row label>`.
|
||||
fn publish_row(r: &egui::Response) {
|
||||
ROW.with(|row| crate::scenario::publish_handle(&format!("dialog:{}", row.borrow()), r.rect.center()));
|
||||
@@ -878,11 +883,12 @@ const LABEL_MAX_W: f32 = 150.0;
|
||||
/// A row label in the dialog's label column (the grid sizes the column to its widest label).
|
||||
pub(crate) fn row_label(ui: &mut egui::Ui, text: &str) {
|
||||
ROW.with(|r| *r.borrow_mut() = text.to_string());
|
||||
let full = egui::WidgetText::from(text).into_galley(ui, Some(egui::TextWrapMode::Extend), f32::INFINITY, egui::TextStyle::Body).size().x;
|
||||
let display_text = tr(text);
|
||||
let full = egui::WidgetText::from(display_text).into_galley(ui, Some(egui::TextWrapMode::Extend), f32::INFINITY, egui::TextStyle::Body).size().x;
|
||||
let w = full.clamp(LABEL_W, LABEL_MAX_W);
|
||||
ui.allocate_ui_with_layout(vec2(w, 22.0), egui::Layout::left_to_right(egui::Align::Center), |ui| {
|
||||
ui.set_width(w);
|
||||
ui.add(egui::Label::new(text).wrap());
|
||||
ui.add(egui::Label::new(display_text).wrap());
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1057,9 +1063,14 @@ fn input_rows(d: &mut Dialog, ui: &mut egui::Ui) {
|
||||
let custom_axis = i == 1 && inp.items.is_empty() && matches!(d.kind, Kind::Revolve { axis: Some(_), .. });
|
||||
let filled = !inp.items.is_empty() || custom_axis;
|
||||
let text = if filled {
|
||||
RichText::new(if custom_axis { "↖ Custom Axis".into() } else { format!("↖ {} selected", inp.items.len()) }).color(Color32::WHITE)
|
||||
RichText::new(if custom_axis {
|
||||
crate::i18n::tr("dialog.custom_axis").into()
|
||||
} else {
|
||||
crate::i18n::format("dialog.selected_count", &[("count", &inp.items.len().to_string())])
|
||||
})
|
||||
.color(Color32::WHITE)
|
||||
} else {
|
||||
RichText::new("↖ Select").color(t.text)
|
||||
RichText::new(crate::i18n::tr("dialog.select")).color(t.text)
|
||||
};
|
||||
let stroke = if active { Stroke::new(1.5, t.accent) } else { Stroke::new(1.0, t.dialog_border.gamma_multiply(1.6)) };
|
||||
// Not in the Tab order: Tab moves between values.
|
||||
@@ -1255,6 +1266,7 @@ fn auto_operation(app: &SolveApp, d: &mut Dialog) {
|
||||
/// the view cube, sized to its content.
|
||||
pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
let Some(mut d) = app.dialog.take() else { return };
|
||||
let _language = crate::i18n::scope(&app.preferences.language);
|
||||
auto_operation(app, &mut d);
|
||||
update_measure(app, &mut d);
|
||||
update_fill_cells(app, &mut d);
|
||||
@@ -1267,7 +1279,8 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
let mut cancel = false;
|
||||
let mut enter = false;
|
||||
let wide = d.is_wide();
|
||||
let heading = if d.editing.is_some() { format!("EDIT {}", title(&d.kind)) } else { title(&d.kind).to_string() };
|
||||
let raw_title = title(&d.kind);
|
||||
let heading = if d.editing.is_some() { crate::i18n::format("dialog.edit_title", &[("title", tr(raw_title))]) } else { tr(raw_title).to_string() };
|
||||
let frame = egui::Frame::window(&ctx.global_style())
|
||||
.fill(t.dialog_bg)
|
||||
.stroke(Stroke::new(1.0, t.dialog_border))
|
||||
@@ -1917,21 +1930,29 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
egui::Spinner::new().size(14.0).paint_at(ui, sr);
|
||||
sresp.on_hover_text("Updating the preview");
|
||||
}
|
||||
let buttons = if has_ok { 64.0 * 2.0 + ui.spacing().item_spacing.x } else { 64.0 };
|
||||
let raw_label = if matches!(d.kind, Kind::ConfirmDelete { .. }) { "Delete" } else { "OK" };
|
||||
let label = tr(raw_label);
|
||||
let raw_close = if matches!(d.kind, Kind::Params { .. } | Kind::Measure { .. }) { "Close" } else { "Cancel" };
|
||||
let close = tr(raw_close);
|
||||
let button_width = |text: &str| {
|
||||
let galley = egui::WidgetText::from(text).into_galley(ui, Some(egui::TextWrapMode::Extend), f32::INFINITY, egui::TextStyle::Button);
|
||||
(galley.size().x + 2.0 * ui.spacing().button_padding.x).max(64.0)
|
||||
};
|
||||
let (ok_width, close_width) = (button_width(label), button_width(close));
|
||||
let buttons = close_width + if has_ok { ok_width + ui.spacing().item_spacing.x } else { 0.0 };
|
||||
crate::frame::right_aligned(ui, total, buttons, |ui| {
|
||||
if has_ok {
|
||||
let label = if matches!(d.kind, Kind::ConfirmDelete { .. }) { "Delete" } else { "OK" };
|
||||
let b = egui::Button::new(RichText::new(label).color(if valid { Color32::WHITE } else { t.text_dim }))
|
||||
.fill(if valid { t.accent } else { Color32::TRANSPARENT })
|
||||
.min_size(vec2(64.0, 24.0));
|
||||
.wrap_mode(egui::TextWrapMode::Extend)
|
||||
.min_size(vec2(ok_width, 24.0));
|
||||
let r = ui.add_enabled(valid, b);
|
||||
crate::scenario::publish_handle(&format!("dialog:{label}"), r.rect.center());
|
||||
crate::scenario::publish_handle(&format!("dialog:{raw_label}"), r.rect.center());
|
||||
if r.clicked() {
|
||||
ok = true;
|
||||
}
|
||||
}
|
||||
let close = if matches!(d.kind, Kind::Params { .. } | Kind::Measure { .. }) { "Close" } else { "Cancel" };
|
||||
if ui.add(egui::Button::new(close).min_size(vec2(64.0, 24.0))).clicked() {
|
||||
if ui.add(egui::Button::new(close).wrap_mode(egui::TextWrapMode::Extend).min_size(vec2(close_width, 24.0))).clicked() {
|
||||
cancel = true;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -311,7 +311,14 @@ pub fn tabs(app: &mut SolveApp, ui: &mut egui::Ui, r: Rect, x0: f32, x1: f32) {
|
||||
t.app_bar
|
||||
},
|
||||
);
|
||||
ui.painter().text(home.center(), Align2::CENTER_CENTER, "Home", FontId::proportional(13.0), if app.home.open { t.text } else { t.app_bar_text });
|
||||
let home_text = app.tr("ui.home");
|
||||
ui.painter().text(
|
||||
home.center(),
|
||||
Align2::CENTER_CENTER,
|
||||
home_text,
|
||||
FontId::proportional(13.0),
|
||||
if app.home.open { t.text } else { t.app_bar_text },
|
||||
);
|
||||
crate::scenario::publish_handle("tab:home", home.center());
|
||||
if response.clicked() {
|
||||
app.home.show(app.session.revision);
|
||||
@@ -346,7 +353,8 @@ pub fn tabs(app: &mut SolveApp, ui: &mut egui::Ui, r: Rect, x0: f32, x1: f32) {
|
||||
};
|
||||
ui.painter().rect_filled(tr, egui::CornerRadius { nw: 5, ne: 5, sw: 0, se: 0 }, fill);
|
||||
let ink = if active { t.text } else { t.app_bar_text };
|
||||
let label = format!("{name}{}", if *dirty { " •" } else { "" });
|
||||
let name_display = if name == "Untitled" { app.tr("ui.untitled") } else { name };
|
||||
let label = format!("{name_display}{}", if *dirty { " •" } else { "" });
|
||||
let g = ui.painter().layout_no_wrap(label, FontId::proportional(13.0), ink);
|
||||
let text_w = (tr.width() - 30.0).max(10.0);
|
||||
let clip = Rect::from_min_size(tr.min, vec2(text_w + 10.0, tr.height()));
|
||||
@@ -448,26 +456,29 @@ pub enum Answer {
|
||||
|
||||
/// The "save changes?" prompt for a tab being closed, or for each unsaved design when quitting.
|
||||
pub fn prompt(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
let _language = crate::i18n::scope(&app.preferences.language);
|
||||
quit_step(app);
|
||||
let Some(i) = app.docs.closing else { return };
|
||||
let name = tabs_info(app).get(i).map(|x| x.0.clone()).unwrap_or_default();
|
||||
let name_display = if name == "Untitled" { crate::i18n::tr("ui.untitled") } else { &name };
|
||||
let mut answer: Option<Answer> = None;
|
||||
crate::frame::window(ctx, format!("Save changes to {name}?"), crate::frame::Width::Normal)
|
||||
let title = crate::i18n::format("document.save_changes", &[("name", name_display)]);
|
||||
crate::frame::window(ctx, title, crate::frame::Width::Normal)
|
||||
.id(egui::Id::new("sc_close_prompt"))
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.anchor(Align2::CENTER_CENTER, vec2(0.0, 0.0))
|
||||
.show(ctx, |ui| {
|
||||
ui.label(format!("{name} has unsaved changes. If you don't save, they are lost."));
|
||||
ui.label(crate::i18n::format("document.unsaved_changes", &[("name", name_display)]));
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("Save").clicked() {
|
||||
if ui.button(crate::i18n::tr("ui.save")).clicked() {
|
||||
answer = Some(Answer::Save);
|
||||
}
|
||||
if ui.button("Don't Save").clicked() {
|
||||
if ui.button(crate::i18n::tr("ui.don_t_save")).clicked() {
|
||||
answer = Some(Answer::DontSave);
|
||||
}
|
||||
if ui.button("Cancel").clicked() {
|
||||
if ui.button(crate::i18n::tr("ui.cancel")).clicked() {
|
||||
answer = Some(Answer::Cancel);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
//! System font loading and fallback setup for multi-language (CJK/Japanese) support.
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Candidate font paths to look for on different operating systems.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const FONT_CANDIDATES: &[&str] = &[
|
||||
// Windows
|
||||
"C:\\Windows\\Fonts\\meiryo.ttc",
|
||||
"C:\\Windows\\Fonts\\YuGothM.ttc",
|
||||
"C:\\Windows\\Fonts\\YuGothR.ttc",
|
||||
"C:\\Windows\\Fonts\\msgothic.ttc",
|
||||
"C:\\Windows\\Fonts\\BIZ-UDGothicR.ttc",
|
||||
// macOS
|
||||
"/System/Library/Fonts/ヒラギノ角ゴシック W3.ttc",
|
||||
"/System/Library/Fonts/Hiragino Sans GB.ttc",
|
||||
"/System/Library/Fonts/PingFang.ttc",
|
||||
"/Library/Fonts/Arial Unicode.ttf",
|
||||
// Linux
|
||||
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
|
||||
"/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
|
||||
"/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc",
|
||||
"/usr/share/fonts/truetype/droid/DroidSansFallbackFull.ttf",
|
||||
];
|
||||
|
||||
/// Configure font definitions with system CJK fallback if available.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn setup_fonts(ctx: &egui::Context) {
|
||||
let mut font_bytes: Option<Vec<u8>> = None;
|
||||
for path in FONT_CANDIDATES {
|
||||
if let Ok(bytes) = std::fs::read(path)
|
||||
&& ttf_parser::Face::parse(&bytes, 0).is_ok()
|
||||
{
|
||||
font_bytes = Some(bytes);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let Some(bytes) = font_bytes else {
|
||||
return;
|
||||
};
|
||||
|
||||
let mut fonts = egui::FontDefinitions::default();
|
||||
fonts.font_data.insert("cjk_fallback".to_owned(), Arc::new(egui::FontData::from_owned(bytes)));
|
||||
|
||||
fonts.families.entry(egui::FontFamily::Proportional).or_default().push("cjk_fallback".to_owned());
|
||||
|
||||
fonts.families.entry(egui::FontFamily::Monospace).or_default().push("cjk_fallback".to_owned());
|
||||
|
||||
ctx.set_fonts(fonts);
|
||||
}
|
||||
|
||||
/// Browsers cannot read system font files. A web host can supply licensed fonts through egui.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn setup_fonts(_ctx: &egui::Context) {}
|
||||
@@ -0,0 +1,266 @@
|
||||
//! Embedded offline message catalogs. UI messages use stable IDs; engine display labels
|
||||
//! pass through an adapter without changing engine or scenario identifiers.
|
||||
|
||||
use std::cell::Cell;
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
type Messages = BTreeMap<String, String>;
|
||||
|
||||
/// Register locales here after adding their catalogs. Missing translations use English.
|
||||
pub struct Catalog {
|
||||
pub id: &'static str,
|
||||
pub name: &'static str,
|
||||
source: &'static str,
|
||||
messages: OnceLock<Messages>,
|
||||
}
|
||||
|
||||
impl Catalog {
|
||||
const fn new(id: &'static str, name: &'static str, source: &'static str) -> Self {
|
||||
Self { id, name, source, messages: OnceLock::new() }
|
||||
}
|
||||
|
||||
fn messages(&'static self) -> &'static Messages {
|
||||
self.messages.get_or_init(|| match serde_json::from_str(self.source) {
|
||||
Ok(messages) => messages,
|
||||
Err(error) => {
|
||||
log::error!("invalid embedded {} message catalog: {error}", self.id);
|
||||
Messages::new()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
static ENGLISH: Catalog = Catalog::new("en", "English", include_str!("locales/en.json"));
|
||||
static JAPANESE: Catalog = Catalog::new("ja", "日本語", include_str!("locales/ja.json"));
|
||||
pub const LOCALES: &[&Catalog] = &[&ENGLISH, &JAPANESE];
|
||||
|
||||
pub fn is_supported(id: &str) -> bool {
|
||||
LOCALES.iter().any(|catalog| catalog.id == id)
|
||||
}
|
||||
|
||||
/// Immutable locale reference. Catalogs parse once; lookups are logarithmic.
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct Locale(&'static Catalog);
|
||||
|
||||
impl Locale {
|
||||
pub fn for_language(id: &str) -> Self {
|
||||
Self(LOCALES.iter().copied().find(|catalog| catalog.id == id).unwrap_or(&ENGLISH))
|
||||
}
|
||||
|
||||
pub fn name(self) -> &'static str {
|
||||
self.0.name
|
||||
}
|
||||
|
||||
/// Stable message ID → translation → English → ID.
|
||||
pub fn text(self, id: &str) -> &str {
|
||||
self.0.messages().get(id).or_else(|| ENGLISH.messages().get(id)).map_or(id, String::as_str)
|
||||
}
|
||||
|
||||
/// Translate source labels from engine/UI tables, preserving unknown labels.
|
||||
/// Also accepts stable IDs while table helpers migrate away from source text.
|
||||
pub fn label(self, label: &str) -> &str {
|
||||
static LABEL_IDS: OnceLock<BTreeMap<&'static str, &'static str>> = OnceLock::new();
|
||||
let ids = LABEL_IDS.get_or_init(|| {
|
||||
ENGLISH.messages().iter().filter(|(_, text)| !text.contains('{')).map(|(id, text)| (text.as_str(), id.as_str())).collect()
|
||||
});
|
||||
ids.get(label).map_or_else(|| self.text(label), |id| self.text(id))
|
||||
}
|
||||
|
||||
pub fn format(self, id: &str, args: &[(&str, &str)]) -> String {
|
||||
interpolate(self.text(id), args)
|
||||
}
|
||||
|
||||
/// Translate generated browser names and labels, preserving custom names otherwise.
|
||||
pub fn dynamic(self, text: &str) -> String {
|
||||
for (prefix, id) in [("Sketch", "browser.sketch_name"), ("Body", "browser.body_name"), ("Component", "browser.component_name")] {
|
||||
if let Some(number) = text.strip_prefix(prefix)
|
||||
&& !number.is_empty()
|
||||
&& number.chars().all(|c| c.is_ascii_digit())
|
||||
{
|
||||
return self.format(id, &[("number", number)]);
|
||||
}
|
||||
}
|
||||
for (prefix, id, arg) in [("Units: ", "browser.units", "units"), ("Precision: ", "browser.precision", "precision")] {
|
||||
if let Some(value) = text.strip_prefix(prefix) {
|
||||
return self.format(id, &[(arg, value)]);
|
||||
}
|
||||
}
|
||||
if let Some(count) = text.strip_prefix("Parameters (").and_then(|s| s.strip_suffix(')')) {
|
||||
return self.format("browser.parameters", &[("count", count)]);
|
||||
}
|
||||
if let Some(count) = text.strip_suffix(" degrees of freedom")
|
||||
&& !count.is_empty()
|
||||
&& count.chars().all(|c| c.is_ascii_digit())
|
||||
{
|
||||
return self.format("sketch.degrees_of_freedom", &[("count", count)]);
|
||||
}
|
||||
self.label(text).to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// One-pass substitution: argument values are literal, even when they contain braces.
|
||||
/// Missing arguments remain visible rather than crashing or silently removing text.
|
||||
fn interpolate(template: &str, args: &[(&str, &str)]) -> String {
|
||||
let mut output = String::with_capacity(template.len());
|
||||
let mut remaining = template;
|
||||
while let Some((before, after)) = remaining.split_once('{') {
|
||||
output.push_str(before);
|
||||
let Some((name, rest)) = after.split_once('}') else {
|
||||
output.push('{');
|
||||
output.push_str(after);
|
||||
return output;
|
||||
};
|
||||
if let Some((_, value)) = args.iter().find(|(key, _)| *key == name) {
|
||||
output.push_str(value);
|
||||
} else {
|
||||
output.push('{');
|
||||
output.push_str(name);
|
||||
output.push('}');
|
||||
}
|
||||
remaining = rest;
|
||||
}
|
||||
output.push_str(remaining);
|
||||
output
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
// Helpers without an app reference share a render scope. Nested renders restore
|
||||
// their caller's locale; no owned language strings or persistent global setting.
|
||||
static CURRENT: Cell<Locale> = const { Cell::new(Locale(&ENGLISH)) };
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub struct LanguageScope {
|
||||
previous: Locale,
|
||||
// Drop on the thread where the scope was entered.
|
||||
_thread: std::marker::PhantomData<std::rc::Rc<()>>,
|
||||
}
|
||||
|
||||
pub fn scope(id: &str) -> LanguageScope {
|
||||
let previous = CURRENT.with(|current| current.replace(Locale::for_language(id)));
|
||||
LanguageScope { previous, _thread: std::marker::PhantomData }
|
||||
}
|
||||
|
||||
impl Drop for LanguageScope {
|
||||
fn drop(&mut self) {
|
||||
CURRENT.with(|current| current.set(self.previous));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tr(id: &str) -> &str {
|
||||
CURRENT.with(|current| current.get().text(id))
|
||||
}
|
||||
pub fn tr_label(label: &str) -> &str {
|
||||
CURRENT.with(|current| current.get().label(label))
|
||||
}
|
||||
pub fn format(id: &str, args: &[(&str, &str)]) -> String {
|
||||
CURRENT.with(|current| current.get().format(id, args))
|
||||
}
|
||||
pub fn tr_dyn(text: &str) -> String {
|
||||
CURRENT.with(|current| current.get().dynamic(text))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
fn placeholders(text: &str) -> BTreeSet<&str> {
|
||||
let mut result = BTreeSet::new();
|
||||
let mut rest = text;
|
||||
while let Some((_, after)) = rest.split_once('{') {
|
||||
let (name, remaining) = after.split_once('}').expect("unclosed placeholder");
|
||||
assert!(!name.is_empty() && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_'));
|
||||
result.insert(name);
|
||||
rest = remaining;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalogs_have_valid_ids_and_matching_placeholders() {
|
||||
let english: Messages = serde_json::from_str(ENGLISH.source).unwrap();
|
||||
let mut ids = BTreeSet::new();
|
||||
for catalog in LOCALES {
|
||||
assert!(ids.insert(catalog.id), "duplicate locale");
|
||||
let messages: Messages = serde_json::from_str(catalog.source).unwrap();
|
||||
assert!(!messages.is_empty());
|
||||
for (id, text) in messages {
|
||||
let original = english.get(&id).expect("unknown message ID");
|
||||
assert_eq!(placeholders(&text), placeholders(original), "{}: {id}", catalog.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_ui_message_ids_exist_in_english_catalog() {
|
||||
// Catch a misspelled key before it is shown verbatim in the UI. Engine-table labels
|
||||
// deliberately use the separate label adapter and fall back to their source text.
|
||||
for source in [
|
||||
include_str!("toolbar.rs"),
|
||||
include_str!("prefs.rs"),
|
||||
include_str!("documents.rs"),
|
||||
include_str!("dialogs.rs"),
|
||||
include_str!("browser.rs"),
|
||||
include_str!("sketch_palette.rs"),
|
||||
] {
|
||||
for call in [".tr(\"", "i18n::tr(\"", "i18n::format(\""] {
|
||||
for tail in source.split(call).skip(1) {
|
||||
let id = tail.split('"').next().unwrap();
|
||||
assert!(ENGLISH.messages().contains_key(id), "missing English message: {id}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keys_and_engine_labels_translate_with_english_fallback() {
|
||||
let ja = Locale::for_language("ja");
|
||||
assert_eq!(ja.text("ui.preferences"), "環境設定");
|
||||
assert_eq!(ja.label("Extrude"), "押し出し");
|
||||
assert_eq!(ja.label("CREATE SKETCH"), "スケッチを作成");
|
||||
assert_eq!(ja.label("NonExistentKey"), "NonExistentKey");
|
||||
assert_eq!(Locale::for_language("unknown").text("ui.preferences"), "Preferences");
|
||||
static PARTIAL: Catalog = Catalog::new("test", "Test", r#"{"ui.preferences":"Prefs"}"#);
|
||||
assert_eq!(Locale(&PARTIAL).text("ui.preferences"), "Prefs");
|
||||
assert_eq!(Locale(&PARTIAL).text("ui.extrude"), "Extrude");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn templates_allow_reordering_and_literal_braces_in_values() {
|
||||
assert_eq!(interpolate("{second} then {first}", &[("first", "{second}"), ("second", "B")]), "B then {second}");
|
||||
assert_eq!(interpolate("{missing} / {", &[]), "{missing} / {");
|
||||
assert_eq!(Locale::for_language("ja").format("document.save_changes", &[("name", "My {design}")]), "My {design} への変更を保存しますか?");
|
||||
assert_eq!(Locale::for_language("en").format("dialog.selected_count", &[("count", "2")]), "↖ 2 selected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn browser_patterns_preserve_numbers_and_custom_names() {
|
||||
let ja = Locale::for_language("ja");
|
||||
assert_eq!(ja.dynamic("Sketch1"), "スケッチ1");
|
||||
assert_eq!(ja.dynamic("Body2"), "ボディ2");
|
||||
assert_eq!(ja.dynamic("Component12"), "コンポーネント12");
|
||||
assert_eq!(ja.dynamic("Units: mm"), "単位: mm");
|
||||
assert_eq!(ja.dynamic("Precision: 3"), "精度: 3");
|
||||
assert_eq!(ja.dynamic("Parameters (5)"), "パラメータ (5)");
|
||||
assert_eq!(ja.dynamic("4 degrees of freedom"), "自由度 4");
|
||||
assert_eq!(ja.dynamic("Sketch of bracket"), "Sketch of bracket");
|
||||
assert_eq!(Locale::for_language("en").dynamic("Sketch1"), "Sketch1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_scopes_restore_the_previous_language() {
|
||||
let _english = scope("en");
|
||||
{
|
||||
let _japanese = scope("ja");
|
||||
assert_eq!(tr("ui.preferences"), "環境設定");
|
||||
{
|
||||
let _nested = scope("en");
|
||||
assert_eq!(tr("ui.preferences"), "Preferences");
|
||||
}
|
||||
assert_eq!(tr("ui.preferences"), "環境設定");
|
||||
}
|
||||
assert_eq!(tr("ui.preferences"), "Preferences");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
//! Localization must not change scenario handles or leave the next app in another locale.
|
||||
|
||||
use crate::scenario::{self, Harness};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn japanese_dialog_keeps_english_scenario_handles() {
|
||||
let mut h = Harness::new();
|
||||
h.app.preferences.language = "ja".into();
|
||||
h.app.home.open = false;
|
||||
h.call("ui.start", json!({"command":"sketch.create"}));
|
||||
h.frames(3);
|
||||
assert!(h.widget("平面").is_some(), "the input label must be translated");
|
||||
assert!(h.widget("キャンセル").is_some(), "the close button must be translated");
|
||||
assert!(!scenario::handles("dialog:Plane").is_empty(), "scenario handles must retain source labels");
|
||||
assert_eq!(crate::i18n::tr("ui.preferences"), "Preferences", "the render scope must restore English");
|
||||
|
||||
h.app.preferences.language = "en".into();
|
||||
h.frames(3);
|
||||
assert!(h.widget("Plane").is_some());
|
||||
assert!(h.widget("Cancel").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn language_preference_cancel_restores_previous_locale() {
|
||||
let mut h = Harness::new();
|
||||
h.app.home.open = false;
|
||||
h.call("ui.prefs", json!({"open":"General"}));
|
||||
h.call("ui.prefs", json!({"language":"ja"}));
|
||||
assert!(h.widget("言語").is_some());
|
||||
assert_eq!(h.app.preferences.language, "ja");
|
||||
h.call("ui.prefs", json!({"button":"cancel"}));
|
||||
assert_eq!(h.app.preferences.language, "en");
|
||||
assert_eq!(h.app.tr("ui.preferences"), "Preferences");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn translated_dialog_buttons_do_not_wrap() {
|
||||
fn cancel_rows(shape: &egui::Shape) -> Option<usize> {
|
||||
match shape {
|
||||
egui::Shape::Text(text) if text.galley.job.text == "キャンセル" => Some(text.galley.rows.len()),
|
||||
egui::Shape::Vec(shapes) => shapes.iter().find_map(cancel_rows),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
let mut h = Harness::new();
|
||||
crate::fonts::setup_fonts(&h.ctx);
|
||||
h.app.preferences.language = "ja".into();
|
||||
h.app.home.open = false;
|
||||
h.call("ui.start", json!({"command":"solid.extrude"}));
|
||||
let mut out = h.ctx.run_ui(
|
||||
egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 1000.0))), ..Default::default() },
|
||||
|ui| h.app.ui(ui),
|
||||
);
|
||||
assert_eq!(out.shapes.iter().find_map(|shape| cancel_rows(&shape.shape)), Some(1));
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
@@ -27,11 +27,15 @@ pub mod dialogs_surface;
|
||||
pub mod dim_view;
|
||||
pub mod documents;
|
||||
pub mod drag_snap;
|
||||
pub mod fonts;
|
||||
pub mod frame;
|
||||
pub mod gizmo;
|
||||
pub mod gpu;
|
||||
pub mod help;
|
||||
pub mod home;
|
||||
pub mod i18n;
|
||||
#[cfg(test)]
|
||||
mod i18n_ui_tests;
|
||||
pub mod icons;
|
||||
pub mod inference;
|
||||
pub mod keymap;
|
||||
@@ -299,6 +303,16 @@ impl SolveApp {
|
||||
}
|
||||
}
|
||||
|
||||
/// Translate text using the active language preference.
|
||||
pub fn tr<'a>(&self, text: &'a str) -> &'a str {
|
||||
i18n::Locale::for_language(&self.preferences.language).text(text)
|
||||
}
|
||||
|
||||
/// Translate a display label supplied by the engine, without changing its identifier.
|
||||
pub fn tr_label<'a>(&self, text: &'a str) -> &'a str {
|
||||
i18n::Locale::for_language(&self.preferences.language).label(text)
|
||||
}
|
||||
|
||||
/// Draw the viewport on the GPU (eframe's wgpu render state, with the app's depth and
|
||||
/// stencil buffer bits and MSAA sample count). Without it the viewport renders on the CPU.
|
||||
pub fn set_wgpu(&mut self, rs: &egui_wgpu::RenderState, depth_bits: u8, stencil_bits: u8, samples: u32) {
|
||||
@@ -517,6 +531,27 @@ impl SolveApp {
|
||||
self.last_command = Some((spec.id.to_string(), spec.label.to_string()));
|
||||
}
|
||||
if let Some(mut t) = tools::Tool::for_command(id) {
|
||||
// Curve-pair constraints can consume an exact preselection. Leave ambiguous
|
||||
// selections to the ordinary pick tool; never silently constrain a subset.
|
||||
if matches!(
|
||||
id,
|
||||
"sketch.constraint.parallel"
|
||||
| "sketch.constraint.perpendicular"
|
||||
| "sketch.constraint.equal"
|
||||
| "sketch.constraint.tangent"
|
||||
| "sketch.constraint.concentric"
|
||||
| "sketch.constraint.collinear"
|
||||
) && let [solvecraft_engine::Sel::SketchCurve { id: a }, solvecraft_engine::Sel::SketchCurve { id: b }] =
|
||||
self.session.selection.as_slice()
|
||||
&& a != b
|
||||
{
|
||||
let params = json!({ "a": a, "b": b });
|
||||
if self.run(id, params).is_ok() {
|
||||
let _ = self.session.execute("select.clear", &json!({}));
|
||||
self.set_status("Constraint applied", false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if id == "sketch.dimension" {
|
||||
t.picks = self
|
||||
.session
|
||||
@@ -838,6 +873,7 @@ impl SolveApp {
|
||||
/// Lay out the whole window.
|
||||
pub fn ui(&mut self, ui: &mut egui::Ui) {
|
||||
let t0 = now_ms();
|
||||
let _language = crate::i18n::scope(&self.preferences.language);
|
||||
scenario::clear_handles();
|
||||
toolbar::app_bar(self, ui);
|
||||
file_load::show(self, ui.ctx());
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
{
|
||||
"browser.body_name": "Body{number}",
|
||||
"browser.component_name": "Component{number}",
|
||||
"browser.parameters": "Parameters ({count})",
|
||||
"browser.precision": "Precision: {precision}",
|
||||
"browser.sketch_name": "Sketch{number}",
|
||||
"browser.units": "Units: {units}",
|
||||
"dialog.custom_axis": "↖ Custom Axis",
|
||||
"dialog.edit_title": "EDIT {title}",
|
||||
"dialog.select": "↖ Select",
|
||||
"dialog.selected_count": "↖ {count} selected",
|
||||
"document.save_changes": "Save changes to {name}?",
|
||||
"document.unsaved_changes": "{name} has unsaved changes. If you don't save, they are lost.",
|
||||
"sketch.degrees_of_freedom": "{count} degrees of freedom",
|
||||
"sketch.draw_curves": "Draw {linetype} curves",
|
||||
"sketch.make_selection": "Make the selection {linetype}",
|
||||
"ui.1_degree_of_freedom": "1 degree of freedom",
|
||||
"ui.3d_sketch": "3D Sketch",
|
||||
"ui.about": "About",
|
||||
"ui.activate": "Activate",
|
||||
"ui.active_component": "Active component",
|
||||
"ui.agent_cursor": "Agent cursor",
|
||||
"ui.agent_cursor_speed": "Agent cursor speed",
|
||||
"ui.all": "All",
|
||||
"ui.angle": "Angle",
|
||||
"ui.angle_decimals": "Angle decimals",
|
||||
"ui.animate_view_changes": "Animate view changes",
|
||||
"ui.animation": "Animation",
|
||||
"ui.animation_speed": "Animation speed",
|
||||
"ui.apply": "Apply",
|
||||
"ui.arc": "Arc",
|
||||
"ui.assemble": "Assemble",
|
||||
"ui.autosave_every": "Autosave every",
|
||||
"ui.axes": "Axes",
|
||||
"ui.axis": "Axis",
|
||||
"ui.bodies": "Bodies",
|
||||
"ui.box": "Box",
|
||||
"ui.break": "Break",
|
||||
"ui.cancel": "Cancel",
|
||||
"ui.canvases": "Canvases",
|
||||
"ui.centerline": "Centerline",
|
||||
"ui.chamfer": "Chamfer",
|
||||
"ui.change_workspace": "Change workspace",
|
||||
"ui.chord_length": "Chord Length",
|
||||
"ui.circle": "Circle",
|
||||
"ui.circular_pattern": "Circular Pattern",
|
||||
"ui.clear_recent": "Clear Recent",
|
||||
"ui.clear_the_selection": "Clear the selection",
|
||||
"ui.close": "Close",
|
||||
"ui.coil": "Coil",
|
||||
"ui.coincident": "Coincident",
|
||||
"ui.collapse": "Collapse",
|
||||
"ui.collinear": "Collinear",
|
||||
"ui.combine": "Combine",
|
||||
"ui.coming_later": "coming later",
|
||||
"ui.concentric": "Concentric",
|
||||
"ui.conic_curve": "Conic Curve",
|
||||
"ui.constant": "Constant",
|
||||
"ui.constraints_conflict": "Constraints conflict",
|
||||
"ui.construct": "Construct",
|
||||
"ui.construction": "Construction",
|
||||
"ui.copy": "Copy",
|
||||
"ui.counterbore": "Counterbore",
|
||||
"ui.countersink": "Countersink",
|
||||
"ui.create": "Create",
|
||||
"ui.create_sketch": "Create Sketch",
|
||||
"ui.current": "current",
|
||||
"ui.curvature": "Curvature",
|
||||
"ui.cut": "Cut",
|
||||
"ui.cylinder": "Cylinder",
|
||||
"ui.dark": "Dark",
|
||||
"ui.default_modelling_orientation": "Default modelling orientation",
|
||||
"ui.degrees_of_freedom": "degrees of freedom",
|
||||
"ui.delete": "Delete",
|
||||
"ui.depth": "Depth",
|
||||
"ui.design": "Design",
|
||||
"ui.diameter": "Diameter",
|
||||
"ui.dimension": "Dimension",
|
||||
"ui.direction": "Direction",
|
||||
"ui.distance": "Distance",
|
||||
"ui.distance_1": "Distance 1",
|
||||
"ui.distance_2": "Distance 2",
|
||||
"ui.document_settings": "Document Settings",
|
||||
"ui.don_t_save": "Don't Save",
|
||||
"ui.draft": "Draft",
|
||||
"ui.drawing": "Drawing",
|
||||
"ui.drill_point": "Drill Point",
|
||||
"ui.edit": "Edit",
|
||||
"ui.ellipse": "Ellipse",
|
||||
"ui.end_radius": "End Radius",
|
||||
"ui.equal": "Equal",
|
||||
"ui.expand": "Expand",
|
||||
"ui.export_ellipsis": "Export...",
|
||||
"ui.extend": "Extend",
|
||||
"ui.extent_type": "Extent Type",
|
||||
"ui.extrude": "Extrude",
|
||||
"ui.features": "Features",
|
||||
"ui.file": "File",
|
||||
"ui.fillet": "Fillet",
|
||||
"ui.finish_sketch": "Finish Sketch",
|
||||
"ui.fix_unfix": "Fix/Unfix",
|
||||
"ui.flat": "Flat",
|
||||
"ui.flip": "Flip",
|
||||
"ui.front": "Front",
|
||||
"ui.fully_constrained": "Fully constrained",
|
||||
"ui.general": "General",
|
||||
"ui.generative_design": "Generative Design",
|
||||
"ui.graphics": "Graphics",
|
||||
"ui.heading_assemble": "ASSEMBLE",
|
||||
"ui.heading_box": "BOX",
|
||||
"ui.heading_chamfer": "CHAMFER",
|
||||
"ui.heading_circular_pattern": "CIRCULAR PATTERN",
|
||||
"ui.heading_coil": "COIL",
|
||||
"ui.heading_combine": "COMBINE",
|
||||
"ui.heading_construct": "CONSTRUCT",
|
||||
"ui.heading_create": "CREATE",
|
||||
"ui.heading_create_sketch": "CREATE SKETCH",
|
||||
"ui.heading_cylinder": "CYLINDER",
|
||||
"ui.heading_design": "DESIGN",
|
||||
"ui.heading_draft": "DRAFT",
|
||||
"ui.heading_edit": "EDIT",
|
||||
"ui.heading_extrude": "EXTRUDE",
|
||||
"ui.heading_fillet": "FILLET",
|
||||
"ui.heading_finish_sketch": "FINISH SKETCH",
|
||||
"ui.heading_front": "FRONT",
|
||||
"ui.heading_hole": "HOLE",
|
||||
"ui.heading_home": "HOME",
|
||||
"ui.heading_insert": "INSERT",
|
||||
"ui.heading_inspect": "INSPECT",
|
||||
"ui.heading_loft": "LOFT",
|
||||
"ui.heading_measure": "MEASURE",
|
||||
"ui.heading_mesh": "MESH",
|
||||
"ui.heading_mirror": "MIRROR",
|
||||
"ui.heading_modify": "MODIFY",
|
||||
"ui.heading_move": "MOVE",
|
||||
"ui.heading_offset_plane": "OFFSET PLANE",
|
||||
"ui.heading_ok": "OK",
|
||||
"ui.heading_parameters": "PARAMETERS",
|
||||
"ui.heading_pattern_on_path": "PATTERN ON PATH",
|
||||
"ui.heading_pipe": "PIPE",
|
||||
"ui.heading_plane_at_angle": "PLANE AT ANGLE",
|
||||
"ui.heading_plastic": "PLASTIC",
|
||||
"ui.heading_rectangular_pattern": "RECTANGULAR PATTERN",
|
||||
"ui.heading_revolve": "REVOLVE",
|
||||
"ui.heading_right": "RIGHT",
|
||||
"ui.heading_scale": "SCALE",
|
||||
"ui.heading_section_analysis": "SECTION ANALYSIS",
|
||||
"ui.heading_select": "SELECT",
|
||||
"ui.heading_sheet_metal": "SHEET METAL",
|
||||
"ui.heading_shell": "SHELL",
|
||||
"ui.heading_sketch": "SKETCH",
|
||||
"ui.heading_sketch_palette": "SKETCH PALETTE",
|
||||
"ui.heading_solid": "SOLID",
|
||||
"ui.heading_sphere": "SPHERE",
|
||||
"ui.heading_split_body": "SPLIT BODY",
|
||||
"ui.heading_surface": "SURFACE",
|
||||
"ui.heading_sweep": "SWEEP",
|
||||
"ui.heading_thread": "THREAD",
|
||||
"ui.heading_top": "TOP",
|
||||
"ui.heading_torus": "TORUS",
|
||||
"ui.help": "Help",
|
||||
"ui.hide": "Hide",
|
||||
"ui.hole": "Hole",
|
||||
"ui.hole_tap_type": "Hole Tap Type",
|
||||
"ui.hole_type": "Hole Type",
|
||||
"ui.home": "Home",
|
||||
"ui.horizontal_vertical": "Horizontal/Vertical",
|
||||
"ui.insert": "Insert",
|
||||
"ui.inspect": "Inspect",
|
||||
"ui.intersect": "Intersect",
|
||||
"ui.join": "Join",
|
||||
"ui.joints": "Joints",
|
||||
"ui.keyboard_shortcuts": "Keyboard Shortcuts",
|
||||
"ui.language": "Language",
|
||||
"ui.length_decimals": "Length decimals",
|
||||
"ui.light": "Light",
|
||||
"ui.line": "Line",
|
||||
"ui.linetype": "Linetype",
|
||||
"ui.loft": "Loft",
|
||||
"ui.look_at": "Look At",
|
||||
"ui.manufacture": "Manufacture",
|
||||
"ui.measure": "Measure",
|
||||
"ui.midpoint": "Midpoint",
|
||||
"ui.mirror": "Mirror",
|
||||
"ui.modify": "Modify",
|
||||
"ui.move": "Move",
|
||||
"ui.multiple": "Multiple",
|
||||
"ui.named_views": "Named Views",
|
||||
"ui.navigation": "Navigation",
|
||||
"ui.new_body": "New Body",
|
||||
"ui.new_design": "New Design",
|
||||
"ui.normal": "Normal",
|
||||
"ui.object_type": "Object Type",
|
||||
"ui.objects": "Objects",
|
||||
"ui.offer_to_stand_up_y_up_files": "Offer to stand up Y-up files",
|
||||
"ui.offset": "Offset",
|
||||
"ui.offset_plane": "Offset Plane",
|
||||
"ui.one_side": "One Side",
|
||||
"ui.open": "Open",
|
||||
"ui.open_ellipsis": "Open...",
|
||||
"ui.open_recent": "Open Recent",
|
||||
"ui.operation": "Operation",
|
||||
"ui.options": "Options",
|
||||
"ui.orbit_around": "Orbit around",
|
||||
"ui.origin": "Origin",
|
||||
"ui.pan_zoom_orbit": "Pan, zoom, orbit",
|
||||
"ui.parallel": "Parallel",
|
||||
"ui.parameters": "Parameters",
|
||||
"ui.paste": "Paste",
|
||||
"ui.path": "Path",
|
||||
"ui.pattern_on_path": "Pattern on Path",
|
||||
"ui.perpendicular": "Perpendicular",
|
||||
"ui.pipe": "Pipe",
|
||||
"ui.placement": "Placement",
|
||||
"ui.plane": "Plane",
|
||||
"ui.plane_at_angle": "Plane at Angle",
|
||||
"ui.point": "Point",
|
||||
"ui.point_under_the_cursor": "Point under the cursor",
|
||||
"ui.points": "Points",
|
||||
"ui.polygon": "Polygon",
|
||||
"ui.preferences": "Preferences",
|
||||
"ui.profile": "Profile",
|
||||
"ui.profile_plane": "Profile Plane",
|
||||
"ui.profiles": "Profiles",
|
||||
"ui.project": "Project",
|
||||
"ui.quit": "Quit",
|
||||
"ui.radius": "Radius",
|
||||
"ui.radius_type": "Radius Type",
|
||||
"ui.rectangle": "Rectangle",
|
||||
"ui.rectangular_pattern": "Rectangular Pattern",
|
||||
"ui.redo": "Redo",
|
||||
"ui.render": "Render",
|
||||
"ui.reset_the_settings_on_this_page": "Reset the settings on this page",
|
||||
"ui.restore_defaults": "Restore Defaults",
|
||||
"ui.reverse_zoom_direction": "Reverse zoom direction",
|
||||
"ui.revolve": "Revolve",
|
||||
"ui.right": "Right",
|
||||
"ui.save": "Save",
|
||||
"ui.save_as": "Save As",
|
||||
"ui.save_as_ellipsis": "Save As...",
|
||||
"ui.scale": "Scale",
|
||||
"ui.section_analysis": "Section Analysis",
|
||||
"ui.select": "Select",
|
||||
"ui.selection": "Selection",
|
||||
"ui.shell": "Shell",
|
||||
"ui.show": "Show",
|
||||
"ui.show_constraints": "Show Constraints",
|
||||
"ui.show_dimensions": "Show Dimensions",
|
||||
"ui.show_points": "Show Points",
|
||||
"ui.show_profile": "Show Profile",
|
||||
"ui.show_projected_geometries": "Show Projected Geometries",
|
||||
"ui.simple": "Simple",
|
||||
"ui.simulation": "Simulation",
|
||||
"ui.single": "Single",
|
||||
"ui.sketch": "Sketch",
|
||||
"ui.sketch_dimension": "Sketch Dimension",
|
||||
"ui.sketch_grid": "Sketch Grid",
|
||||
"ui.sketch_scale": "Sketch Scale",
|
||||
"ui.sketches": "Sketches",
|
||||
"ui.slice": "Slice",
|
||||
"ui.slot": "Slot",
|
||||
"ui.smooth": "Smooth",
|
||||
"ui.snap": "Snap",
|
||||
"ui.sphere": "Sphere",
|
||||
"ui.spline": "Spline",
|
||||
"ui.split_body": "Split Body",
|
||||
"ui.start": "Start",
|
||||
"ui.start_radius": "Start Radius",
|
||||
"ui.sweep": "Sweep",
|
||||
"ui.symmetric": "Symmetric",
|
||||
"ui.symmetry": "Symmetry",
|
||||
"ui.system": "System",
|
||||
"ui.tangent": "Tangent",
|
||||
"ui.taper_angle": "Taper Angle",
|
||||
"ui.tapped": "Tapped",
|
||||
"ui.text": "Text",
|
||||
"ui.theme": "Theme",
|
||||
"ui.thickness": "Thickness",
|
||||
"ui.thread": "Thread",
|
||||
"ui.tip_angle": "Tip Angle",
|
||||
"ui.to_object": "To Object",
|
||||
"ui.top": "Top",
|
||||
"ui.torus": "Torus",
|
||||
"ui.trim": "Trim",
|
||||
"ui.two_sides": "Two Sides",
|
||||
"ui.ui_scale": "UI scale",
|
||||
"ui.undo": "Undo",
|
||||
"ui.units": "Units",
|
||||
"ui.units_of_new_designs": "Units of new designs",
|
||||
"ui.untitled": "Untitled",
|
||||
"ui.variable": "Variable",
|
||||
"ui.view_centre": "View centre",
|
||||
"ui.visual_style": "Visual style",
|
||||
"ui.workspace": "workspace",
|
||||
"ui.zoom_to_the_cursor": "Zoom to the cursor"
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
{
|
||||
"browser.body_name": "ボディ{number}",
|
||||
"browser.component_name": "コンポーネント{number}",
|
||||
"browser.parameters": "パラメータ ({count})",
|
||||
"browser.precision": "精度: {precision}",
|
||||
"browser.sketch_name": "スケッチ{number}",
|
||||
"browser.units": "単位: {units}",
|
||||
"dialog.custom_axis": "↖ カスタム軸",
|
||||
"dialog.edit_title": "{title}を編集",
|
||||
"dialog.select": "↖ 選択",
|
||||
"dialog.selected_count": "↖ {count} 個選択",
|
||||
"document.save_changes": "{name} への変更を保存しますか?",
|
||||
"document.unsaved_changes": "{name} には保存されていない変更があります。保存しない場合、変更内容は失われます。",
|
||||
"sketch.degrees_of_freedom": "自由度 {count}",
|
||||
"sketch.draw_curves": "{linetype}の曲線を描く",
|
||||
"sketch.make_selection": "選択を{linetype}にする",
|
||||
"ui.1_degree_of_freedom": "自由度 1",
|
||||
"ui.3d_sketch": "3D スケッチ",
|
||||
"ui.about": "SolveCraft について",
|
||||
"ui.activate": "アクティブ化",
|
||||
"ui.active_component": "アクティブなコンポーネント",
|
||||
"ui.agent_cursor": "AIエージェントカーソル",
|
||||
"ui.agent_cursor_speed": "AIエージェント速度",
|
||||
"ui.all": "すべて",
|
||||
"ui.angle": "角度",
|
||||
"ui.angle_decimals": "角度の小数点桁数",
|
||||
"ui.animate_view_changes": "視点変更をアニメーション表示",
|
||||
"ui.animation": "アニメーション",
|
||||
"ui.animation_speed": "アニメーション速度",
|
||||
"ui.apply": "適用",
|
||||
"ui.arc": "円弧",
|
||||
"ui.assemble": "アセンブリ",
|
||||
"ui.autosave_every": "自動保存の間隔",
|
||||
"ui.axes": "軸",
|
||||
"ui.axis": "軸",
|
||||
"ui.bodies": "ボディ",
|
||||
"ui.box": "直方体",
|
||||
"ui.break": "分割",
|
||||
"ui.cancel": "キャンセル",
|
||||
"ui.canvases": "キャンバス",
|
||||
"ui.centerline": "中心線",
|
||||
"ui.chamfer": "面取り",
|
||||
"ui.change_workspace": "ワークスペースの変更",
|
||||
"ui.chord_length": "弦長",
|
||||
"ui.circle": "円",
|
||||
"ui.circular_pattern": "円形パターン",
|
||||
"ui.clear_recent": "最近使った項目をクリア",
|
||||
"ui.clear_the_selection": "選択をクリア",
|
||||
"ui.close": "閉じる",
|
||||
"ui.coil": "コイル",
|
||||
"ui.coincident": "一致",
|
||||
"ui.collapse": "折りたたむ",
|
||||
"ui.collinear": "同一直線",
|
||||
"ui.combine": "結合",
|
||||
"ui.coming_later": "準備中",
|
||||
"ui.concentric": "同心円",
|
||||
"ui.conic_curve": "円錐曲線",
|
||||
"ui.constant": "一定",
|
||||
"ui.constraints_conflict": "拘束が競合しています",
|
||||
"ui.construct": "構築",
|
||||
"ui.construction": "構築",
|
||||
"ui.copy": "コピー",
|
||||
"ui.counterbore": "座ぐり",
|
||||
"ui.countersink": "皿もみ",
|
||||
"ui.create": "作成",
|
||||
"ui.create_sketch": "スケッチを作成",
|
||||
"ui.current": "現在",
|
||||
"ui.curvature": "曲率",
|
||||
"ui.cut": "切り取り",
|
||||
"ui.cylinder": "円柱",
|
||||
"ui.dark": "ダーク",
|
||||
"ui.default_modelling_orientation": "デフォルトのモデル方向",
|
||||
"ui.degrees_of_freedom": "自由度",
|
||||
"ui.delete": "削除",
|
||||
"ui.depth": "深さ",
|
||||
"ui.design": "デザイン",
|
||||
"ui.diameter": "直径",
|
||||
"ui.dimension": "寸法",
|
||||
"ui.direction": "方向",
|
||||
"ui.distance": "距離",
|
||||
"ui.distance_1": "距離 1",
|
||||
"ui.distance_2": "距離 2",
|
||||
"ui.document_settings": "ドキュメントの設定",
|
||||
"ui.don_t_save": "保存しない",
|
||||
"ui.draft": "勾配",
|
||||
"ui.drawing": "図面",
|
||||
"ui.drill_point": "先端",
|
||||
"ui.edit": "編集",
|
||||
"ui.ellipse": "楕円",
|
||||
"ui.end_radius": "終了半径",
|
||||
"ui.equal": "等しい",
|
||||
"ui.expand": "展開",
|
||||
"ui.export_ellipsis": "エクスポート...",
|
||||
"ui.extend": "延長",
|
||||
"ui.extent_type": "範囲タイプ",
|
||||
"ui.extrude": "押し出し",
|
||||
"ui.features": "フィーチャー",
|
||||
"ui.file": "ファイル",
|
||||
"ui.fillet": "フィレット",
|
||||
"ui.finish_sketch": "スケッチを終了",
|
||||
"ui.fix_unfix": "固定/固定解除",
|
||||
"ui.flat": "フラット",
|
||||
"ui.flip": "反転",
|
||||
"ui.front": "正面",
|
||||
"ui.fully_constrained": "完全拘束",
|
||||
"ui.general": "一般",
|
||||
"ui.generative_design": "ジェネレーティブ デザイン",
|
||||
"ui.graphics": "グラフィックス",
|
||||
"ui.heading_assemble": "アセンブリ",
|
||||
"ui.heading_box": "直方体",
|
||||
"ui.heading_chamfer": "面取り",
|
||||
"ui.heading_circular_pattern": "円形パターン",
|
||||
"ui.heading_coil": "コイル",
|
||||
"ui.heading_combine": "結合",
|
||||
"ui.heading_construct": "構築",
|
||||
"ui.heading_create": "作成",
|
||||
"ui.heading_create_sketch": "スケッチを作成",
|
||||
"ui.heading_cylinder": "円柱",
|
||||
"ui.heading_design": "デザイン",
|
||||
"ui.heading_draft": "勾配",
|
||||
"ui.heading_edit": "編集",
|
||||
"ui.heading_extrude": "押し出し",
|
||||
"ui.heading_fillet": "フィレット",
|
||||
"ui.heading_finish_sketch": "スケッチを終了",
|
||||
"ui.heading_front": "正面",
|
||||
"ui.heading_hole": "穴",
|
||||
"ui.heading_home": "ホーム",
|
||||
"ui.heading_insert": "挿入",
|
||||
"ui.heading_inspect": "検査",
|
||||
"ui.heading_loft": "ロフト",
|
||||
"ui.heading_measure": "計測",
|
||||
"ui.heading_mesh": "メッシュ",
|
||||
"ui.heading_mirror": "ミラー",
|
||||
"ui.heading_modify": "修正",
|
||||
"ui.heading_move": "移動",
|
||||
"ui.heading_offset_plane": "オフセット平面",
|
||||
"ui.heading_ok": "OK",
|
||||
"ui.heading_parameters": "パラメータ",
|
||||
"ui.heading_pattern_on_path": "パス上のパターン",
|
||||
"ui.heading_pipe": "パイプ",
|
||||
"ui.heading_plane_at_angle": "傾斜平面",
|
||||
"ui.heading_plastic": "プラスチック",
|
||||
"ui.heading_rectangular_pattern": "矩形パターン",
|
||||
"ui.heading_revolve": "回転",
|
||||
"ui.heading_right": "右側面",
|
||||
"ui.heading_scale": "尺度",
|
||||
"ui.heading_section_analysis": "断面解析",
|
||||
"ui.heading_select": "選択",
|
||||
"ui.heading_sheet_metal": "板金",
|
||||
"ui.heading_shell": "シェル",
|
||||
"ui.heading_sketch": "スケッチ",
|
||||
"ui.heading_sketch_palette": "スケッチ パレット",
|
||||
"ui.heading_solid": "ソリッド",
|
||||
"ui.heading_sphere": "球",
|
||||
"ui.heading_split_body": "ボディを分割",
|
||||
"ui.heading_surface": "サーフェス",
|
||||
"ui.heading_sweep": "スイープ",
|
||||
"ui.heading_thread": "ねじ",
|
||||
"ui.heading_top": "上面",
|
||||
"ui.heading_torus": "トーラス",
|
||||
"ui.help": "ヘルプ",
|
||||
"ui.hide": "非表示",
|
||||
"ui.hole": "穴",
|
||||
"ui.hole_tap_type": "タップタイプ",
|
||||
"ui.hole_type": "穴タイプ",
|
||||
"ui.home": "ホーム",
|
||||
"ui.horizontal_vertical": "水平/垂直",
|
||||
"ui.insert": "挿入",
|
||||
"ui.inspect": "検査",
|
||||
"ui.intersect": "交差",
|
||||
"ui.join": "結合",
|
||||
"ui.joints": "ジョイント",
|
||||
"ui.keyboard_shortcuts": "キーボードショートカット",
|
||||
"ui.language": "言語",
|
||||
"ui.length_decimals": "長さの小数点桁数",
|
||||
"ui.light": "ライト",
|
||||
"ui.line": "線分",
|
||||
"ui.linetype": "線種",
|
||||
"ui.loft": "ロフト",
|
||||
"ui.look_at": "視点を合わせる",
|
||||
"ui.manufacture": "製造",
|
||||
"ui.measure": "計測",
|
||||
"ui.midpoint": "中点",
|
||||
"ui.mirror": "ミラー",
|
||||
"ui.modify": "修正",
|
||||
"ui.move": "移動",
|
||||
"ui.multiple": "複数",
|
||||
"ui.named_views": "名前付きビュー",
|
||||
"ui.navigation": "ナビゲーション",
|
||||
"ui.new_body": "新規ボディ",
|
||||
"ui.new_design": "新規デザイン",
|
||||
"ui.normal": "標準",
|
||||
"ui.object_type": "オブジェクトタイプ",
|
||||
"ui.objects": "オブジェクト",
|
||||
"ui.offer_to_stand_up_y_up_files": "Y-upファイルを立ち上げるか確認",
|
||||
"ui.offset": "オフセット",
|
||||
"ui.offset_plane": "オフセット平面",
|
||||
"ui.one_side": "片側",
|
||||
"ui.open": "開く",
|
||||
"ui.open_ellipsis": "開く...",
|
||||
"ui.open_recent": "最近使った項目を開く",
|
||||
"ui.operation": "操作",
|
||||
"ui.options": "オプション",
|
||||
"ui.orbit_around": "回転の中心",
|
||||
"ui.origin": "原点",
|
||||
"ui.pan_zoom_orbit": "パン、ズーム、オービット",
|
||||
"ui.parallel": "平行",
|
||||
"ui.parameters": "パラメータ",
|
||||
"ui.paste": "貼り付け",
|
||||
"ui.path": "パス",
|
||||
"ui.pattern_on_path": "パス上のパターン",
|
||||
"ui.perpendicular": "直交",
|
||||
"ui.pipe": "パイプ",
|
||||
"ui.placement": "配置",
|
||||
"ui.plane": "平面",
|
||||
"ui.plane_at_angle": "傾斜平面",
|
||||
"ui.point": "点",
|
||||
"ui.point_under_the_cursor": "カーソル下の点",
|
||||
"ui.points": "点",
|
||||
"ui.polygon": "ポリゴン",
|
||||
"ui.preferences": "環境設定",
|
||||
"ui.profile": "プロファイル",
|
||||
"ui.profile_plane": "プロファイル平面",
|
||||
"ui.profiles": "プロファイル",
|
||||
"ui.project": "投影",
|
||||
"ui.quit": "終了",
|
||||
"ui.radius": "半径",
|
||||
"ui.radius_type": "半径タイプ",
|
||||
"ui.rectangle": "長方形",
|
||||
"ui.rectangular_pattern": "矩形パターン",
|
||||
"ui.redo": "やり直す",
|
||||
"ui.render": "レンダリング",
|
||||
"ui.reset_the_settings_on_this_page": "このページの設定をリセット",
|
||||
"ui.restore_defaults": "初期値に戻す",
|
||||
"ui.reverse_zoom_direction": "ズーム方向を反転",
|
||||
"ui.revolve": "回転",
|
||||
"ui.right": "右側面",
|
||||
"ui.save": "保存",
|
||||
"ui.save_as": "名前を付けて保存",
|
||||
"ui.save_as_ellipsis": "名前を付けて保存...",
|
||||
"ui.scale": "尺度",
|
||||
"ui.section_analysis": "断面解析",
|
||||
"ui.select": "選択",
|
||||
"ui.selection": "選択",
|
||||
"ui.shell": "シェル",
|
||||
"ui.show": "表示",
|
||||
"ui.show_constraints": "拘束を表示",
|
||||
"ui.show_dimensions": "寸法を表示",
|
||||
"ui.show_points": "点を表示",
|
||||
"ui.show_profile": "プロファイルを表示",
|
||||
"ui.show_projected_geometries": "投影ジオメトリを表示",
|
||||
"ui.simple": "単純",
|
||||
"ui.simulation": "シミュレーション",
|
||||
"ui.single": "単一",
|
||||
"ui.sketch": "スケッチ",
|
||||
"ui.sketch_dimension": "スケッチ寸法",
|
||||
"ui.sketch_grid": "スケッチ グリッド",
|
||||
"ui.sketch_scale": "スケッチ尺度",
|
||||
"ui.sketches": "スケッチ",
|
||||
"ui.slice": "スライス",
|
||||
"ui.slot": "スロット",
|
||||
"ui.smooth": "スムーズ",
|
||||
"ui.snap": "スナップ",
|
||||
"ui.sphere": "球",
|
||||
"ui.spline": "スプライン",
|
||||
"ui.split_body": "ボディを分割",
|
||||
"ui.start": "開始",
|
||||
"ui.start_radius": "開始半径",
|
||||
"ui.sweep": "スイープ",
|
||||
"ui.symmetric": "対称",
|
||||
"ui.symmetry": "対称",
|
||||
"ui.system": "システム",
|
||||
"ui.tangent": "正接",
|
||||
"ui.taper_angle": "テーパ角度",
|
||||
"ui.tapped": "タップ立て",
|
||||
"ui.text": "テキスト",
|
||||
"ui.theme": "テーマ",
|
||||
"ui.thickness": "厚さ",
|
||||
"ui.thread": "ねじ",
|
||||
"ui.tip_angle": "先端角度",
|
||||
"ui.to_object": "オブジェクトまで",
|
||||
"ui.top": "上面",
|
||||
"ui.torus": "トーラス",
|
||||
"ui.trim": "トリム",
|
||||
"ui.two_sides": "両側",
|
||||
"ui.ui_scale": "UIスケール",
|
||||
"ui.undo": "元に戻す",
|
||||
"ui.units": "単位",
|
||||
"ui.units_of_new_designs": "新規デザインの単位",
|
||||
"ui.untitled": "無題",
|
||||
"ui.variable": "可変",
|
||||
"ui.view_centre": "ビュー中心",
|
||||
"ui.visual_style": "表示スタイル",
|
||||
"ui.workspace": "ワークスペース",
|
||||
"ui.zoom_to_the_cursor": "カーソル位置へズーム"
|
||||
}
|
||||
+59
-17
@@ -46,6 +46,8 @@ pub struct Prefs {
|
||||
pub angle_decimals: u8,
|
||||
/// `dark`, `light` or `system` (follow the operating system).
|
||||
pub theme: String,
|
||||
/// Interface language (a registered locale ID).
|
||||
pub language: String,
|
||||
/// Interface scale (1 = 100 %).
|
||||
pub ui_scale: f32,
|
||||
/// The wheel zooms toward the point under the cursor (else toward the view's centre).
|
||||
@@ -70,6 +72,7 @@ impl Default for Prefs {
|
||||
length_decimals: 2,
|
||||
angle_decimals: 1,
|
||||
theme: "dark".into(),
|
||||
language: "en".into(),
|
||||
ui_scale: 1.0,
|
||||
zoom_to_cursor: true,
|
||||
look_at_anim: true,
|
||||
@@ -157,6 +160,7 @@ fn defaults(app: &mut SolveApp, page: usize) {
|
||||
match page {
|
||||
0 => {
|
||||
p.theme = d.theme;
|
||||
p.language = d.language;
|
||||
p.ui_scale = d.ui_scale;
|
||||
p.default_units = d.default_units;
|
||||
p.length_decimals = d.length_decimals;
|
||||
@@ -376,7 +380,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
let mut open = true;
|
||||
let mut button: Option<&str> = None;
|
||||
let before = app.preferences.clone();
|
||||
crate::frame::window(ctx, "Preferences", crate::frame::Width::Wide)
|
||||
crate::frame::window(ctx, app.tr("ui.preferences"), crate::frame::Width::Wide)
|
||||
.id(egui::Id::new("sc_prefs"))
|
||||
.open(&mut open)
|
||||
.collapsible(false)
|
||||
@@ -389,7 +393,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
ui.vertical(|ui| {
|
||||
ui.set_width(140.0);
|
||||
for (i, s) in PAGES.iter().enumerate() {
|
||||
if ui.selectable_label(app.prefs_window.section == i, *s).clicked() {
|
||||
if ui.selectable_label(app.prefs_window.section == i, app.tr_label(s)).clicked() {
|
||||
app.prefs_window.section = i;
|
||||
app.keymap.read_only = false;
|
||||
}
|
||||
@@ -399,24 +403,24 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
ui.vertical(|ui| {
|
||||
// A fixed width, so the window keeps its size from page to page.
|
||||
ui.set_width(540.0);
|
||||
ui.label(RichText::new(PAGES.get(app.prefs_window.section).copied().unwrap_or_default()).strong().size(14.0));
|
||||
ui.label(RichText::new(app.tr_label(PAGES.get(app.prefs_window.section).copied().unwrap_or_default())).strong().size(14.0));
|
||||
ui.add_space(6.0);
|
||||
page(app, ui, &t);
|
||||
});
|
||||
});
|
||||
ui.separator();
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("Restore Defaults").on_hover_text("Reset the settings on this page").clicked() {
|
||||
if ui.button(app.tr("ui.restore_defaults")).on_hover_text(app.tr("ui.reset_the_settings_on_this_page")).clicked() {
|
||||
button = Some("defaults");
|
||||
}
|
||||
crate::frame::right_aligned(ui, 700.0, 170.0, |ui| {
|
||||
if ui.button("OK").clicked() {
|
||||
if ui.button(app.tr("ui.heading_ok")).clicked() {
|
||||
button = Some("ok");
|
||||
}
|
||||
if ui.button("Cancel").clicked() {
|
||||
if ui.button(app.tr("ui.cancel")).clicked() {
|
||||
button = Some("cancel");
|
||||
}
|
||||
if ui.button("Apply").clicked() {
|
||||
if ui.button(app.tr("ui.apply")).clicked() {
|
||||
button = Some("apply");
|
||||
}
|
||||
});
|
||||
@@ -467,6 +471,7 @@ fn up_axis_changed(app: &mut SolveApp) {
|
||||
|
||||
/// One page of the dialog.
|
||||
fn page(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
let _language = crate::i18n::scope(&app.preferences.language);
|
||||
let note = |ui: &mut egui::Ui, s: &str| {
|
||||
ui.label("");
|
||||
ui.label(RichText::new(s).size(11.0).color(t.text_dim));
|
||||
@@ -478,10 +483,21 @@ fn page(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
}
|
||||
egui::Grid::new(("sc_prefs_grid", app.prefs_window.section)).num_columns(2).spacing([16.0, 8.0]).show(ui, |ui| match app.prefs_window.section {
|
||||
0 => {
|
||||
ui.label("Theme");
|
||||
ui.label(app.tr("ui.language"));
|
||||
egui::ComboBox::from_id_salt("pref_language").selected_text(crate::i18n::Locale::for_language(&app.preferences.language).name()).show_ui(
|
||||
ui,
|
||||
|ui| {
|
||||
for locale in crate::i18n::LOCALES {
|
||||
let (id, label) = (locale.id, locale.name);
|
||||
ui.selectable_value(&mut app.preferences.language, id.to_string(), label);
|
||||
}
|
||||
},
|
||||
);
|
||||
ui.end_row();
|
||||
ui.label(app.tr("ui.theme"));
|
||||
ui.horizontal(|ui| {
|
||||
for (id, label) in THEMES {
|
||||
if ui.radio(app.preferences.theme == id, label).clicked() {
|
||||
if ui.radio(app.preferences.theme == id, app.tr_label(label)).clicked() {
|
||||
app.preferences.theme = id.to_string();
|
||||
if id != "system" {
|
||||
app.ui.dark = id == "dark";
|
||||
@@ -490,33 +506,34 @@ fn page(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
ui.label("UI scale");
|
||||
ui.label(app.tr("ui.ui_scale"));
|
||||
ui.add(egui::Slider::new(&mut app.preferences.ui_scale, 0.75..=2.0).step_by(0.05).custom_formatter(|v, _| format!("{:.0} %", v * 100.0)));
|
||||
ui.end_row();
|
||||
ui.label("Units of new designs");
|
||||
ui.label(app.tr("ui.units_of_new_designs"));
|
||||
egui::ComboBox::from_id_salt("pref_units").selected_text(app.preferences.default_units.clone()).show_ui(ui, |ui| {
|
||||
for u in UNITS {
|
||||
ui.selectable_value(&mut app.preferences.default_units, u.to_string(), u);
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
ui.label("Autosave every");
|
||||
ui.label(app.tr("ui.autosave_every"));
|
||||
if ui.add(egui::DragValue::new(&mut app.autosave_minutes).range(0.0..=120.0).speed(0.5).suffix(" min")).changed() {
|
||||
set_autosave(app);
|
||||
}
|
||||
ui.end_row();
|
||||
note(ui, "0 turns it off");
|
||||
ui.label("Length decimals");
|
||||
ui.label(app.tr("ui.length_decimals"));
|
||||
ui.add(egui::DragValue::new(&mut app.preferences.length_decimals).range(0..=6));
|
||||
ui.end_row();
|
||||
ui.label("Angle decimals");
|
||||
ui.label(app.tr("ui.angle_decimals"));
|
||||
ui.add(egui::DragValue::new(&mut app.preferences.angle_decimals).range(0..=6));
|
||||
ui.end_row();
|
||||
ui.label("Agent cursor");
|
||||
ui.label(app.tr("ui.agent_cursor"));
|
||||
let speed_label = app.tr("ui.agent_cursor_speed");
|
||||
let s = &mut app.ui.agent_cursor;
|
||||
ui.checkbox(&mut s.show, "Show the pointer an AI agent moves (#35)");
|
||||
ui.end_row();
|
||||
ui.label("Agent cursor speed");
|
||||
ui.label(speed_label);
|
||||
ui.add_enabled_ui(s.show, |ui| {
|
||||
crate::agent_cursor::speed_slider(ui, &mut s.speed);
|
||||
});
|
||||
@@ -693,6 +710,9 @@ pub fn set(app: &mut SolveApp, p: &serde_json::Value) -> Result<serde_json::Valu
|
||||
if !THEMES.iter().any(|(id, _)| *id == new.theme) || !["z", "y"].contains(&new.up_axis.as_str()) {
|
||||
return Err("`theme` must be dark, light or system and `up_axis` z or y".into());
|
||||
}
|
||||
if !crate::i18n::is_supported(&new.language) {
|
||||
return Err(format!("`language` must be one of: {}", crate::i18n::LOCALES.iter().map(|locale| locale.id).collect::<Vec<_>>().join(", ")));
|
||||
}
|
||||
if !(0.5..=2.5).contains(&new.ui_scale) || !(0.25..=4.0).contains(&new.look_at_speed) {
|
||||
return Err("`ui_scale` must be 0.5…2.5 and `look_at_speed` 0.25…4".into());
|
||||
}
|
||||
@@ -778,11 +798,33 @@ mod tests {
|
||||
// File and Help › Keyboard Shortcuts open the same dialog at its page.
|
||||
app.keymap.open = true;
|
||||
let ctx = egui::Context::default();
|
||||
let _ = ctx.run_ui(Default::default(), |ui| show(&mut app, ui.ctx()));
|
||||
let mut out = ctx.run_ui(Default::default(), |ui| show(&mut app, ui.ctx()));
|
||||
out.textures_delta.clear();
|
||||
assert!(app.prefs_window.open && app.prefs_window.section == PAGE_SHORTCUTS && !app.keymap.open);
|
||||
assert!(control(&mut app, &json!({"open": "Plugins"})).is_err());
|
||||
assert!(set(&mut app, &json!({"up_axis": "x"})).is_err());
|
||||
assert!(set(&mut app, &json!({"theme": "sepia"})).is_err());
|
||||
assert!(set(&mut app, &json!({"language": "fr"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn language_preference_switches_and_persists() {
|
||||
let mut app = SolveApp::new(solvecraft_engine::Session::default(), Default::default());
|
||||
assert_eq!(app.preferences.language, "en");
|
||||
assert_eq!(app.tr("ui.preferences"), "Preferences");
|
||||
assert_eq!(app.tr("ui.extrude"), "Extrude");
|
||||
|
||||
// Switch language to Japanese
|
||||
set(&mut app, &json!({"language": "ja"})).unwrap();
|
||||
assert_eq!(app.preferences.language, "ja");
|
||||
assert_eq!(app.tr("ui.preferences"), "環境設定");
|
||||
assert_eq!(app.tr("ui.extrude"), "押し出し");
|
||||
|
||||
// Preferences round-trip
|
||||
let mut other = SolveApp::new(solvecraft_engine::Session::default(), Default::default());
|
||||
other.load_prefs(&app.prefs());
|
||||
assert_eq!(other.preferences.language, "ja");
|
||||
assert_eq!(other.tr("ui.preferences"), "環境設定");
|
||||
}
|
||||
|
||||
/// A STEP file opened Z-up is offered to be stood up: a quarter turn about X, undoable.
|
||||
|
||||
@@ -167,6 +167,7 @@ pub fn set_linetype(app: &mut SolveApp, kind: u8) {
|
||||
|
||||
/// Draw the palette (while sketching and no command dialog is open).
|
||||
pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
let _language = crate::i18n::scope(&app.preferences.language);
|
||||
if app.dialog.is_some() {
|
||||
return;
|
||||
}
|
||||
@@ -201,7 +202,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
let mut finish = false;
|
||||
let has_curves = !selected_curves(app).is_empty();
|
||||
crate::frame::docked(
|
||||
"SKETCH PALETTE",
|
||||
app.tr("ui.heading_sketch_palette"),
|
||||
egui::Id::new("sc_sketch_palette"),
|
||||
anchor,
|
||||
ctx.content_rect().bottom() - anchor.y - 8.0,
|
||||
@@ -215,39 +216,47 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
if ui.add(egui::Button::new(RichText::new(if folded { "+" } else { "−" }).size(14.0)).frame(false)).clicked() {
|
||||
FOLDED.with(|f| f.set(!folded));
|
||||
}
|
||||
ui.label(RichText::new("SKETCH PALETTE").strong().size(12.5));
|
||||
ui.label(RichText::new(app.tr("ui.heading_sketch_palette")).strong().size(12.5));
|
||||
});
|
||||
if FOLDED.with(Cell::get) {
|
||||
return;
|
||||
}
|
||||
let ofold = OPTIONS_FOLDED.with(Cell::get);
|
||||
if ui.add(egui::Button::new(RichText::new(format!("{} Options", if ofold { "+" } else { "−" })).size(12.0)).frame(false)).clicked() {
|
||||
if ui
|
||||
.add(egui::Button::new(RichText::new(format!("{} {}", if ofold { "+" } else { "−" }, app.tr("ui.options"))).size(12.0)).frame(false))
|
||||
.clicked()
|
||||
{
|
||||
OPTIONS_FOLDED.with(|f| f.set(!ofold));
|
||||
}
|
||||
if !ofold {
|
||||
egui::Grid::new("sc_palette_grid").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
|
||||
ui.label("Linetype");
|
||||
ui.label(app.tr("ui.linetype"));
|
||||
ui.horizontal(|ui| {
|
||||
// With a selection: convert it; without: the mode new curves are drawn in.
|
||||
let current = mode(app);
|
||||
for (i, (label, tip)) in [("—", "Normal"), ("- -", "Construction"), ("-·-", "Centerline")].iter().enumerate() {
|
||||
let on = !has_curves && current == i as u8;
|
||||
let tip = if has_curves { format!("Make the selection {tip}") } else { format!("Draw {tip} curves") };
|
||||
if ui.add(egui::Button::new(*label).selected(on).min_size(vec2(30.0, 20.0))).on_hover_text(tip).clicked() {
|
||||
let tr_tip = app.tr_label(tip);
|
||||
let tip_str = if has_curves {
|
||||
crate::i18n::format("sketch.make_selection", &[("linetype", tr_tip)])
|
||||
} else {
|
||||
crate::i18n::format("sketch.draw_curves", &[("linetype", tr_tip)])
|
||||
};
|
||||
if ui.add(egui::Button::new(*label).selected(on).min_size(vec2(30.0, 20.0))).on_hover_text(tip_str).clicked() {
|
||||
linetype = Some(i as u8);
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
ui.label("Look At");
|
||||
if ui.button("Look At").clicked() {
|
||||
ui.label(app.tr("ui.look_at"));
|
||||
if ui.button(app.tr("ui.look_at")).clicked() {
|
||||
look = true;
|
||||
}
|
||||
ui.end_row();
|
||||
for (k, label) in TOGGLES {
|
||||
// The label is part of the checkbox: clicking it toggles too.
|
||||
let mut on = shown(k);
|
||||
if ui.checkbox(&mut on, label).changed() {
|
||||
if ui.checkbox(&mut on, app.tr_label(label)).changed() {
|
||||
set = Some((k.to_string(), on));
|
||||
}
|
||||
ui.end_row();
|
||||
@@ -255,11 +264,12 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
});
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
ui.label(RichText::new(dof_line.as_str()).color(if ok { t.sketch_accent } else { t.text_dim }));
|
||||
let tr_dof = crate::i18n::tr_dyn(&dof_line);
|
||||
ui.label(RichText::new(tr_dof).color(if ok { t.sketch_accent } else { t.text_dim }));
|
||||
ui.add_space(4.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(WIDTH - 100.0);
|
||||
if ui.add(egui::Button::new(RichText::new("Finish Sketch").color(Color32::WHITE)).fill(t.accent)).clicked() {
|
||||
if ui.add(egui::Button::new(RichText::new(app.tr("ui.finish_sketch")).color(Color32::WHITE)).fill(t.accent)).clicked() {
|
||||
finish = true;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -68,26 +68,26 @@ pub fn app_bar(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
);
|
||||
resp.on_hover_text(tip).clicked()
|
||||
};
|
||||
let file = click(ui, "folder", "File");
|
||||
if click(ui, "save", "Save (Ctrl+S)") {
|
||||
let file = click(ui, "folder", app.tr("ui.file"));
|
||||
if click(ui, "save", &format!("{} (Ctrl+S)", app.tr("ui.save"))) {
|
||||
save(app);
|
||||
}
|
||||
if click(ui, "undo", "Undo (Ctrl+Z)") {
|
||||
if click(ui, "undo", &format!("{} (Ctrl+Z)", app.tr("ui.undo"))) {
|
||||
let _ = app.run("edit.undo", json!({}));
|
||||
}
|
||||
if click(ui, "redo", "Redo (Ctrl+Y)") {
|
||||
if click(ui, "redo", &format!("{} (Ctrl+Y)", app.tr("ui.redo"))) {
|
||||
let _ = app.run("edit.redo", json!({}));
|
||||
}
|
||||
if click(ui, "home", "Start page") {
|
||||
if click(ui, "home", app.tr("ui.home")) {
|
||||
let rev = app.session.revision;
|
||||
app.home.show(rev);
|
||||
}
|
||||
let theme_tip = if app.ui.dark { "Light theme" } else { "Dark theme" };
|
||||
let theme_tip = if app.ui.dark { app.tr("ui.light") } else { app.tr("ui.dark") };
|
||||
if click(ui, if app.ui.dark { "sun" } else { "moon" }, theme_tip) {
|
||||
app.ui.dark = !app.ui.dark;
|
||||
app.preferences.theme = if app.ui.dark { "dark" } else { "light" }.into();
|
||||
}
|
||||
if click(ui, "help", "Help") {
|
||||
if click(ui, "help", app.tr("ui.help")) {
|
||||
app.help.menu = !app.help.menu;
|
||||
let at = ui.ctx().input(|i| i.pointer.latest_pos()).map_or(300.0, |p| p.x);
|
||||
ui.ctx().data_mut(|d| {
|
||||
@@ -118,7 +118,12 @@ pub fn app_bar(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
icons::paint(ui.painter(), gear.shrink(4.0), "settings", t.app_bar_text, t.app_bar_text, t.app_bar);
|
||||
crate::scenario::publish_handle("titlebar:preferences", gear.center());
|
||||
if gresp.on_hover_text(if cfg!(target_os = "macos") { "Preferences (Cmd+,)" } else { "Preferences (Ctrl+,)" }).clicked() {
|
||||
let prefs_tip = if cfg!(target_os = "macos") {
|
||||
format!("{} (Cmd+,)", app.tr("ui.preferences"))
|
||||
} else {
|
||||
format!("{} (Ctrl+,)", app.tr("ui.preferences"))
|
||||
};
|
||||
if gresp.on_hover_text(prefs_tip).clicked() {
|
||||
crate::prefs::open_at(app, 0);
|
||||
}
|
||||
ui.painter().text(
|
||||
@@ -155,17 +160,17 @@ fn file_menu(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
let item = |ui: &mut egui::Ui, label: &str, key: &str| {
|
||||
ui.add(egui::Button::new(label).shortcut_text(key).min_size(vec2(180.0, 22.0))).clicked()
|
||||
};
|
||||
if item(ui, "New Design", "Ctrl+N") {
|
||||
if item(ui, app.tr("ui.new_design"), "Ctrl+N") {
|
||||
crate::documents::new_design(app);
|
||||
close = true;
|
||||
}
|
||||
if item(ui, "Open…", "Ctrl+O") {
|
||||
if item(ui, &format!("{}…", app.tr("ui.open")), "Ctrl+O") {
|
||||
if let Some(p) = app.services.pick_open.as_ref().and_then(|f| f()) {
|
||||
app.open_path_async(&p);
|
||||
}
|
||||
close = true;
|
||||
}
|
||||
ui.menu_button("Open Recent", |ui| {
|
||||
ui.menu_button(app.tr("ui.open_recent"), |ui| {
|
||||
for path in app.home.recent.clone() {
|
||||
let missing = !solvecraft_engine::io::vfs::exists(&path);
|
||||
let label = format!("{path}{}", if missing { " (missing)" } else { "" });
|
||||
@@ -176,7 +181,7 @@ fn file_menu(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
if ui.button("Clear Recent").clicked() {
|
||||
if ui.button(app.tr("ui.clear_recent")).clicked() {
|
||||
app.home.recent.clear();
|
||||
app.home.opened_at.clear();
|
||||
close = true;
|
||||
@@ -191,11 +196,11 @@ fn file_menu(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
app.start("file.insert_mesh");
|
||||
close = true;
|
||||
}
|
||||
if item(ui, "Save", "Ctrl+S") {
|
||||
if item(ui, app.tr("ui.save"), "Ctrl+S") {
|
||||
save(app);
|
||||
close = true;
|
||||
}
|
||||
if item(ui, "Save As…", "") {
|
||||
if item(ui, &format!("{}…", app.tr("ui.save_as")), "") {
|
||||
save_as(app);
|
||||
close = true;
|
||||
}
|
||||
@@ -217,16 +222,16 @@ fn file_menu(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
close = true;
|
||||
}
|
||||
ui.separator();
|
||||
if item(ui, "Preferences…", "") {
|
||||
if item(ui, &format!("{}…", app.tr("ui.preferences")), "") {
|
||||
app.prefs_window.open = true;
|
||||
close = true;
|
||||
}
|
||||
if item(ui, "Keyboard Shortcuts…", "") {
|
||||
if item(ui, &format!("{}…", app.tr("ui.keyboard_shortcuts")), "") {
|
||||
app.keymap.open = true;
|
||||
app.keymap.read_only = false;
|
||||
close = true;
|
||||
}
|
||||
if item(ui, "Quit", "") {
|
||||
if item(ui, app.tr("ui.quit"), "") {
|
||||
crate::documents::request_quit(app);
|
||||
close = true;
|
||||
}
|
||||
@@ -282,13 +287,13 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
painter.text(
|
||||
pos2(ws.center().x - 5.0, ws.top() + 52.0),
|
||||
Align2::CENTER_CENTER,
|
||||
crate::toolbar_custom::WORKSPACE,
|
||||
app.tr_label(crate::toolbar_custom::WORKSPACE),
|
||||
FontId::proportional(12.0),
|
||||
t.text,
|
||||
);
|
||||
caret(&painter, pos2(ws.center().x + 27.0, ws.top() + 52.0), t.text);
|
||||
painter.text(pos2(ws.center().x, ws.top() + 69.0), Align2::CENTER_CENTER, "workspace", FontId::proportional(10.0), t.text_dim);
|
||||
if wresp.on_hover_text("Change workspace").clicked() {
|
||||
painter.text(pos2(ws.center().x, ws.top() + 69.0), Align2::CENTER_CENTER, app.tr("ui.workspace"), FontId::proportional(10.0), t.text_dim);
|
||||
if wresp.on_hover_text(app.tr("ui.change_workspace")).clicked() {
|
||||
crate::context_menu::open_for(app, ws.left_bottom(), crate::context_menu::Target::Workspace);
|
||||
}
|
||||
crate::scenario::publish_rect("toolbar", r);
|
||||
@@ -300,7 +305,9 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
tabs.extend(TABS.iter());
|
||||
for tab in tabs {
|
||||
let w = 12.0 + tab.len() as f32 * 7.4;
|
||||
let display_tab = app.tr_label(tab);
|
||||
let text_width = painter.layout_no_wrap(display_tab.to_string(), FontId::proportional(12.0), t.text).size().x;
|
||||
let w = 12.0 + text_width.max(28.0);
|
||||
let tr = Rect::from_min_size(pos2(x, tabs_y), vec2(w, 22.0));
|
||||
let active = app.ui.tab == tab;
|
||||
let resp = ui.interact(tr, ui.id().with(("tab", tab)), Sense::click());
|
||||
@@ -317,7 +324,7 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
} else {
|
||||
t.text_dim
|
||||
};
|
||||
painter.text(tr.center(), Align2::CENTER_CENTER, tab, FontId::proportional(12.0), col);
|
||||
painter.text(tr.center(), Align2::CENTER_CENTER, display_tab, FontId::proportional(12.0), col);
|
||||
if resp.clicked() {
|
||||
app.ui.tab = tab.to_string();
|
||||
}
|
||||
@@ -384,9 +391,10 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
};
|
||||
let icon = if c.id == "sketch.finish" { "finish" } else { c.icon };
|
||||
icons::paint(&painter, br.shrink(6.0), icon, ink, fill, acc);
|
||||
let tr_label = app.tr_label(c.label);
|
||||
let tip = match &info.disabled_reason {
|
||||
Some(why) => format!("{}\n{why}", c.label),
|
||||
None => format!("{}{}", c.label, c.shortcut.map(|k| format!(" ({k})")).unwrap_or_default()),
|
||||
Some(why) => format!("{}\n{why}", tr_label),
|
||||
None => format!("{}{}", tr_label, c.shortcut.map(|k| format!(" ({k})")).unwrap_or_default()),
|
||||
};
|
||||
if resp.on_hover_text(tip).clicked() && info.enabled {
|
||||
let id = c.id;
|
||||
@@ -399,7 +407,7 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
// Panel label with drop-down.
|
||||
let lr = Rect::from_min_size(pos2(px, top + 42.0), vec2(width, 18.0));
|
||||
let label = panel.to_string();
|
||||
let label = app.tr_label(panel).to_string();
|
||||
let lresp = ui.interact(lr, ui.id().with(("panel", panel)), Sense::click());
|
||||
crate::scenario::publish_handle(&format!("panel:{tab}:{panel}"), lr.center());
|
||||
if lresp.hovered() && enabled_panel {
|
||||
@@ -437,7 +445,10 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
ui.horizontal(|ui| {
|
||||
let (ir, _) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::hover());
|
||||
icons::paint(ui.painter(), ir, c.icon, t.icon, t.icon_fill, t.accent);
|
||||
let b = ui.add_enabled(info.enabled, egui::Button::new(c.label).frame(false).shortcut_text(c.shortcut.unwrap_or("")));
|
||||
let b = ui.add_enabled(
|
||||
info.enabled,
|
||||
egui::Button::new(app.tr_label(c.label)).frame(false).shortcut_text(c.shortcut.unwrap_or("")),
|
||||
);
|
||||
crate::scenario::publish_handle(&format!("toolbar:{}", c.id), b.rect.center());
|
||||
if b.secondary_clicked() {
|
||||
menu = Some((b.interact_pointer_pos().unwrap_or(b.rect.left_bottom()), c.id));
|
||||
|
||||
@@ -14,6 +14,8 @@ pub enum Kind {
|
||||
Draw(usize),
|
||||
/// Pick sketch entities; `n` picks run the command.
|
||||
Pick(usize),
|
||||
/// Pick one straight line, or two distinct points to align.
|
||||
HorizontalVertical,
|
||||
Dimension,
|
||||
/// A tool from `sketch_tools` (it handles its own clicks).
|
||||
Ext,
|
||||
@@ -56,7 +58,7 @@ impl Tool {
|
||||
"sketch.slot.overall" => ("sketch.slot.overall", Kind::Draw(3)),
|
||||
"sketch.point" => ("sketch.point", Kind::Draw(1)),
|
||||
"sketch.dimension" => ("sketch.dimension", Kind::Dimension),
|
||||
"sketch.constraint.horizontal_vertical" => ("sketch.constraint.horizontal_vertical", Kind::Pick(1)),
|
||||
"sketch.constraint.horizontal_vertical" => ("sketch.constraint.horizontal_vertical", Kind::HorizontalVertical),
|
||||
"sketch.constraint.fix" => ("sketch.constraint.fix", Kind::Pick(1)),
|
||||
"sketch.constraint.coincident" => ("sketch.constraint.coincident", Kind::Pick(2)),
|
||||
"sketch.constraint.tangent" => ("sketch.constraint.tangent", Kind::Pick(2)),
|
||||
@@ -93,6 +95,7 @@ pub fn hint(id: &str) -> String {
|
||||
"sketch.arc.three_point" => "Arc: click start, end, then a point on the arc".into(),
|
||||
"sketch.arc.center_point" => "Arc: click centre and start, then click end or type a signed sweep".into(),
|
||||
"sketch.dimension" => "Dimension: pick one or two entities, then click to place the value".into(),
|
||||
"sketch.constraint.horizontal_vertical" => "Horizontal/Vertical: pick a line or two points to align".into(),
|
||||
"sketch.slot.center_to_center" | "sketch.slot.overall" => "Slot: click both ends, then the width".into(),
|
||||
_ if id.starts_with("sketch.constraint.") => "Constraint: pick the sketch entities".into(),
|
||||
_ => crate::sketch_tools::hint(id).unwrap_or_else(|| "Click in the sketch".into()),
|
||||
@@ -143,6 +146,29 @@ pub fn on_click(app: &mut SolveApp, proj: &Proj, pos: Pos2) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
Kind::HorizontalVertical => {
|
||||
let hits = pick(app, proj, pos);
|
||||
let hit = hits.iter().find(|hit| match hit {
|
||||
Hit::SketchPoint { sketch, .. } | Hit::SketchCurve { sketch, .. } => Some(*sketch) == app.session.active_sketch,
|
||||
_ => false,
|
||||
});
|
||||
match hit {
|
||||
Some(Hit::SketchPoint { id, .. }) => {
|
||||
if let Some(first) = tool.picks.first() {
|
||||
if first != id {
|
||||
let _ = app.run(tool.cmd, json!({"points": [first, id]}));
|
||||
tool.picks.clear();
|
||||
}
|
||||
} else {
|
||||
tool.picks.push(id.clone());
|
||||
}
|
||||
}
|
||||
Some(Hit::SketchCurve { id, straight: true, .. }) if tool.picks.is_empty() => {
|
||||
let _ = app.run(tool.cmd, json!({"line": id}));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Kind::Pick(n) => {
|
||||
let hits = pick(app, proj, pos);
|
||||
if let Some(id) = hits.iter().find_map(|h| match h {
|
||||
@@ -292,7 +318,6 @@ fn run_shape(app: &mut SolveApp, t: &Tool) -> Result<Vec<String>, String> {
|
||||
fn run_pick(app: &mut SolveApp, t: &Tool) {
|
||||
let k = &t.picks;
|
||||
let params = match t.cmd {
|
||||
"sketch.constraint.horizontal_vertical" => json!({"line": k[0]}),
|
||||
"sketch.constraint.fix" => json!({"entity": k[0]}),
|
||||
"sketch.constraint.coincident" => json!({"a": k[0], "b": k[1]}),
|
||||
"sketch.constraint.midpoint" => json!({"point": k[0], "line": k[1]}),
|
||||
|
||||
@@ -342,7 +342,7 @@ fn build_scene(app: &SolveApp) -> GpuScene {
|
||||
if let Some((o, n)) = section_plane(app) {
|
||||
let col = c4(tk.text.gamma_multiply(0.9));
|
||||
for b in st.bodies.iter().filter(|b| app.session.body_visible(&b.name)) {
|
||||
for (a, c) in section_segments(&b.mesh(), o, n) {
|
||||
for (a, c) in solvecraft_engine::view::section_segments(&b.mesh(), o, n) {
|
||||
sc.line(a.to_f32(), c.to_f32(), col, 1.8, false);
|
||||
}
|
||||
}
|
||||
@@ -437,28 +437,6 @@ fn ground_shadow(sc: &mut GpuScene, app: &SolveApp, st: &solvecraft_engine::doc:
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the plane through `o` with normal `n` cuts a mesh: one segment per crossed triangle,
|
||||
/// nudged a hair to the kept side (so the clip leaves them drawn).
|
||||
pub(crate) fn section_segments(m: &solvecraft_engine::geom::Mesh, o: Vec3, n: Vec3) -> Vec<(Vec3, Vec3)> {
|
||||
let eps = 1e-4 * (1.0 + m.bounds().diagonal());
|
||||
let mut out = Vec::new();
|
||||
for t in &m.triangles {
|
||||
let Some(p) = m.tri(t) else { continue };
|
||||
let d = p.map(|q| n.dot(q - o));
|
||||
let mut pts = Vec::with_capacity(2);
|
||||
for (i, j) in [(0, 1), (1, 2), (2, 0)] {
|
||||
if (d[i] < 0.0) != (d[j] < 0.0) {
|
||||
let s = d[i] / (d[i] - d[j]);
|
||||
pts.push(p[i] + (p[j] - p[i]) * s - n * eps);
|
||||
}
|
||||
}
|
||||
if let [a, b] = pts[..] {
|
||||
out.push((a, b));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The drawn pieces of a polyline in a dash pattern (on, off, on, off… lengths).
|
||||
pub(crate) fn dashes(pts: &[Vec3], pattern: &[f64]) -> Vec<(Vec3, Vec3)> {
|
||||
let mut out = Vec::new();
|
||||
@@ -2112,6 +2090,7 @@ fn overlays(app: &SolveApp, painter: &egui::Painter, proj: &Proj) {
|
||||
let Some(ss) = st.sketch(sid) else { return };
|
||||
let sk = &ss.sketch;
|
||||
let locked = sk.locked_points();
|
||||
let virtual_corners: Vec<usize> = sk.virtual_corners().into_iter().map(|(i, _)| i).collect();
|
||||
for (i, p) in sk.points.iter().enumerate() {
|
||||
if !solvecraft_engine::view::sketch_point_visible(sk, i) {
|
||||
continue;
|
||||
@@ -2128,6 +2107,9 @@ fn overlays(app: &SolveApp, painter: &egui::Painter, proj: &Proj) {
|
||||
};
|
||||
if i == 0 {
|
||||
painter.circle(sp, 3.5, Color32::from_rgb(240, 200, 60), Stroke::new(1.0, t.body_edge));
|
||||
} else if virtual_corners.contains(&i) {
|
||||
// A virtual sharp corner: an open square, it is not on any drawn curve.
|
||||
painter.rect_stroke(Rect::from_center_size(sp, vec2(6.0, 6.0)), 0.0, Stroke::new(1.2, c), egui::StrokeKind::Middle);
|
||||
} else {
|
||||
painter.rect_filled(Rect::from_center_size(sp, vec2(5.0, 5.0)), 0.0, c);
|
||||
}
|
||||
@@ -3012,7 +2994,7 @@ mod tests {
|
||||
let st = s.model.state();
|
||||
let m = st.bodies[0].mesh();
|
||||
let (o, n) = (Vec3::new(0.0, 15.0, 0.0), Vec3::new(0.0, -1.0, 0.0));
|
||||
let segs = section_segments(&m, o, n);
|
||||
let segs = solvecraft_engine::view::section_segments(&m, o, n);
|
||||
assert!(segs.len() >= 4, "{segs:?}");
|
||||
// On the plane (a hair to the kept side) and around the 40 x 20 cut.
|
||||
let mut len = 0.0;
|
||||
@@ -3022,7 +3004,7 @@ mod tests {
|
||||
}
|
||||
assert!((len - 120.0).abs() < 0.5, "outline length {len}");
|
||||
// A plane that misses the body cuts nothing.
|
||||
assert!(section_segments(&m, Vec3::new(0.0, 99.0, 0.0), n).is_empty());
|
||||
assert!(solvecraft_engine::view::section_segments(&m, Vec3::new(0.0, 99.0, 0.0), n).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
//! Constraint tools accept a selected curve pair as well as tool-first clicks.
|
||||
|
||||
use serde_json::json;
|
||||
use solvecraft_engine::{Sel, sketch::ConstraintKind};
|
||||
use solvecraft_ui_egui::scenario::Harness;
|
||||
|
||||
fn lines() -> Harness {
|
||||
let mut h = Harness::new();
|
||||
h.run(&json!([
|
||||
{"call":"engine.execute","params":{"command":"sketch.create","params":{"plane":"XY"}}},
|
||||
{"call":"engine.execute","params":{"command":"sketch.line","params":{"points":[[10,10],[40,10]],"infer":false}}},
|
||||
{"call":"engine.execute","params":{"command":"sketch.line","params":{"points":[[10,30],[40,35]],"infer":false}}},
|
||||
{"call":"ui.view","params":{"view":"top"}}
|
||||
]))
|
||||
.unwrap();
|
||||
h
|
||||
}
|
||||
|
||||
fn select_lines(h: &mut Harness) {
|
||||
h.run(&json!([
|
||||
{"click":{"sketch":[25,10]}},
|
||||
{"click":{"sketch":[25,32.5]},"shift":true}
|
||||
]))
|
||||
.unwrap();
|
||||
assert_eq!(h.app.session.selection, vec![Sel::SketchCurve { id: "l1".into() }, Sel::SketchCurve { id: "l2".into() }]);
|
||||
}
|
||||
|
||||
fn assert_line_geometry(h: &Harness, command: &str) {
|
||||
let sk = &h.app.session.model.state().sketches[0].sketch;
|
||||
let point = |name| sk.point(sk.resolve_point(name).unwrap()).unwrap();
|
||||
let a = point("l1.start");
|
||||
let b = point("l2.start");
|
||||
let u = point("l1.end") - a;
|
||||
let v = point("l2.end") - b;
|
||||
// Normalization also rejects a collapsed line; zero-length vectors must not
|
||||
// accidentally satisfy the parallel/perpendicular checks.
|
||||
let du = u.normalized().unwrap();
|
||||
let dv = v.normalized().unwrap();
|
||||
match command {
|
||||
"parallel" => assert!(du.cross(dv).abs() < 1e-6),
|
||||
"perpendicular" => assert!(du.dot(dv).abs() < 1e-6),
|
||||
"equal" => assert!((u.len() - v.len()).abs() < 1e-6),
|
||||
"collinear" => {
|
||||
assert!(du.cross(dv).abs() < 1e-6);
|
||||
assert!((b - a).cross(du).abs() < 1e-6);
|
||||
}
|
||||
_ => panic!("unexpected line constraint: {command}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_lines_apply_constraints_with_one_undo_step() {
|
||||
for command in ["parallel", "perpendicular", "equal", "collinear"] {
|
||||
let mut h = lines();
|
||||
select_lines(&mut h);
|
||||
let before = h.app.session.doc.clone();
|
||||
let undo = h.app.session.undo.len();
|
||||
h.run(&json!([{"start":format!("sketch.constraint.{command}")}])).unwrap();
|
||||
assert!(!h.app.status.as_ref().is_some_and(|(_, _, error)| *error), "{command}");
|
||||
let sk = &h.app.session.model.state().sketches[0].sketch;
|
||||
assert!(
|
||||
sk.constraints.iter().any(|c| match command {
|
||||
"parallel" => matches!(c.kind, ConstraintKind::Parallel { .. }),
|
||||
"perpendicular" => matches!(c.kind, ConstraintKind::Perpendicular { .. }),
|
||||
"equal" => matches!(c.kind, ConstraintKind::Equal { .. }),
|
||||
_ => matches!(c.kind, ConstraintKind::Collinear { .. }),
|
||||
}),
|
||||
"{command}"
|
||||
);
|
||||
assert_line_geometry(&h, command);
|
||||
let after = h.app.session.doc.clone();
|
||||
assert_eq!(h.app.session.undo.len(), undo + 1);
|
||||
assert!(h.app.session.selection.is_empty());
|
||||
assert!(h.app.tool.is_none());
|
||||
h.app.session.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(serde_json::to_value(&h.app.session.doc).unwrap(), serde_json::to_value(before).unwrap());
|
||||
h.app.session.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(serde_json::to_value(&h.app.session.doc).unwrap(), serde_json::to_value(after).unwrap());
|
||||
assert_line_geometry(&h, command);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_circles_apply_constraints() {
|
||||
for command in ["tangent", "concentric", "equal"] {
|
||||
let mut h = Harness::new();
|
||||
h.run(&json!([
|
||||
{"call":"engine.execute","params":{"command":"sketch.create","params":{"plane":"XY"}}},
|
||||
{"call":"engine.execute","params":{"command":"sketch.circle.center","params":{"center":[10,10],"radius":5}}},
|
||||
{"call":"engine.execute","params":{"command":"sketch.circle.center","params":{"center":[25,12],"radius":7}}},
|
||||
{"call":"ui.view","params":{"view":"top"}},
|
||||
{"click":{"sketch":[10,15]}},
|
||||
{"click":{"sketch":[25,19]},"shift":true}
|
||||
]))
|
||||
.unwrap();
|
||||
assert_eq!(h.app.session.selection.len(), 2);
|
||||
let undo = h.app.session.undo.len();
|
||||
h.run(&json!([{"start":format!("sketch.constraint.{command}")}])).unwrap();
|
||||
assert!(!h.app.status.as_ref().is_some_and(|(_, _, error)| *error), "{command}");
|
||||
assert_eq!(h.app.session.undo.len(), undo + 1, "{command}");
|
||||
let sk = &h.app.session.model.state().sketches[0].sketch;
|
||||
assert!(sk.constraints.iter().any(|c| match command {
|
||||
"tangent" => matches!(c.kind, ConstraintKind::Tangent { .. }),
|
||||
"concentric" => matches!(c.kind, ConstraintKind::Concentric { .. }),
|
||||
_ => matches!(c.kind, ConstraintKind::Equal { .. }),
|
||||
}));
|
||||
assert!(h.app.session.selection.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ambiguous_selection_preserves_tool_first_workflow() {
|
||||
for selection in [
|
||||
vec![],
|
||||
vec![Sel::SketchCurve { id: "l1".into() }],
|
||||
vec![Sel::SketchCurve { id: "l1".into() }, Sel::SketchCurve { id: "l2".into() }, Sel::SketchPoint { id: "origin".into() }],
|
||||
] {
|
||||
let mut h = lines();
|
||||
h.app.session.selection = selection;
|
||||
let undo = h.app.session.undo.len();
|
||||
h.run(&json!([{"start":"sketch.constraint.parallel"}])).unwrap();
|
||||
assert_eq!(h.app.session.undo.len(), undo);
|
||||
assert!(h.app.tool.as_ref().unwrap().picks.is_empty());
|
||||
h.run(&json!([{"click":{"sketch":[25,10]}}, {"click":{"sketch":[25,32.5]}}])).unwrap();
|
||||
assert_eq!(h.app.session.undo.len(), undo + 1);
|
||||
assert!(!h.app.status.as_ref().is_some_and(|(_, _, error)| *error));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejected_preselection_preserves_document_and_error() {
|
||||
let mut h = lines();
|
||||
h.app.session.execute("sketch.constraint.fix", &json!({"entities":["l1","l2"]})).unwrap();
|
||||
select_lines(&mut h);
|
||||
let before = serde_json::to_value(&h.app.session.doc).unwrap();
|
||||
let undo = h.app.session.undo.len();
|
||||
let selection = h.app.session.selection.clone();
|
||||
h.run(&json!([{"start":"sketch.constraint.parallel"}])).unwrap();
|
||||
assert!(h.app.status.as_ref().is_some_and(|(_, _, error)| *error));
|
||||
assert_eq!(serde_json::to_value(&h.app.session.doc).unwrap(), before);
|
||||
assert_eq!(h.app.session.undo.len(), undo);
|
||||
assert_eq!(h.app.session.selection, selection);
|
||||
// Correct the conflict and retry without reselecting; the old error must disappear.
|
||||
h.app.session.execute("sketch.constraint.fix", &json!({"entities":["l1","l2"]})).unwrap();
|
||||
h.run(&json!([{"start":"sketch.constraint.parallel"}])).unwrap();
|
||||
assert!(!h.app.status.as_ref().is_some_and(|(_, _, error)| *error));
|
||||
assert!(h.app.session.selection.is_empty());
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
//! Horizontal/Vertical accepts either one line or two distinct sketch points.
|
||||
|
||||
use serde_json::json;
|
||||
use solvecraft_engine::sketch::ConstraintKind;
|
||||
use solvecraft_ui_egui::scenario::Harness;
|
||||
|
||||
#[test]
|
||||
fn horizontal_vertical_aligns_origin_and_circle_center() {
|
||||
for (center, horizontal) in [([20.0, 3.0], true), ([3.0, 20.0], false)] {
|
||||
for origin_first in [true, false] {
|
||||
let mut h = Harness::new();
|
||||
h.run(&json!([
|
||||
{"call":"engine.execute","params":{"command":"sketch.create","params":{"plane":"XY"}}},
|
||||
{"call":"engine.execute","params":{"command":"sketch.circle.center","params":{"center":center,"radius":5}}},
|
||||
{"call":"ui.view","params":{"view":"top"}},
|
||||
{"start":"sketch.constraint.horizontal_vertical"}
|
||||
]))
|
||||
.unwrap();
|
||||
let (first, second) = if origin_first { ([0.0, 0.0], center) } else { (center, [0.0, 0.0]) };
|
||||
let undo = h.app.session.undo.len();
|
||||
h.run(&json!([{"click":{"sketch":first}}, {"expect":{"errors":0}}])).unwrap();
|
||||
assert_eq!(h.app.tool.as_ref().unwrap().picks.len(), 1);
|
||||
assert!(!h.app.status.as_ref().is_some_and(|(_, _, error)| *error));
|
||||
assert_eq!(h.app.session.undo.len(), undo);
|
||||
// Repeating the first point must not complete a self-constraint.
|
||||
h.run(&json!([{"click":{"sketch":first}}, {"click":{"sketch":second}}, {"expect":{"errors":0}}])).unwrap();
|
||||
let aligned = |h: &Harness| {
|
||||
let sk = &h.app.session.model.state().sketches[0].sketch;
|
||||
let p = sk.point(sk.resolve_point("c1.center").unwrap()).unwrap();
|
||||
if horizontal { p.y } else { p.x }
|
||||
};
|
||||
assert!(aligned(&h).abs() < 1e-6);
|
||||
let sk = &h.app.session.model.state().sketches[0].sketch;
|
||||
assert!(sk.constraints.iter().any(|c| if horizontal {
|
||||
matches!(c.kind, ConstraintKind::HorizontalPoints { .. })
|
||||
} else {
|
||||
matches!(c.kind, ConstraintKind::VerticalPoints { .. })
|
||||
}));
|
||||
assert_eq!(h.app.session.undo.len(), undo + 1);
|
||||
assert!(h.app.tool.as_ref().unwrap().picks.is_empty());
|
||||
h.run(&json!([{"call":"engine.execute","params":{"command":"edit.undo","params":{}}}])).unwrap();
|
||||
assert!((aligned(&h) - 3.0).abs() < 1e-6);
|
||||
h.run(&json!([{"call":"engine.execute","params":{"command":"edit.redo","params":{}}}])).unwrap();
|
||||
assert!(aligned(&h).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn horizontal_vertical_still_accepts_one_line() {
|
||||
let mut h = Harness::new();
|
||||
h.run(&json!([
|
||||
{"call":"engine.execute","params":{"command":"sketch.create","params":{"plane":"XY"}}},
|
||||
{"call":"engine.execute","params":{"command":"sketch.line","params":{"points":[[10,10],[40,13]],"infer":false}}},
|
||||
{"call":"ui.view","params":{"view":"top"}},
|
||||
{"start":"sketch.constraint.horizontal_vertical"},
|
||||
{"click":{"sketch":[25,11.5]}},
|
||||
{"expect":{"errors":0}}
|
||||
]))
|
||||
.unwrap();
|
||||
let sk = &h.app.session.model.state().sketches[0].sketch;
|
||||
assert!(sk.constraints.iter().any(|c| matches!(c.kind, ConstraintKind::Horizontal { .. })));
|
||||
let a = sk.point(sk.resolve_point("l1.start").unwrap()).unwrap();
|
||||
let b = sk.point(sk.resolve_point("l1.end").unwrap()).unwrap();
|
||||
assert!((a.y - b.y).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn horizontal_vertical_ignores_curves_and_cancels_a_pending_point() {
|
||||
let mut h = Harness::new();
|
||||
h.run(&json!([
|
||||
{"call":"engine.execute","params":{"command":"sketch.create","params":{"plane":"XY"}}},
|
||||
{"call":"engine.execute","params":{"command":"sketch.circle.center","params":{"center":[20,3],"radius":5}}},
|
||||
{"call":"ui.view","params":{"view":"top"}},
|
||||
{"start":"sketch.constraint.horizontal_vertical"},
|
||||
{"click":{"sketch":[25,3]}}
|
||||
]))
|
||||
.unwrap();
|
||||
assert!(h.app.tool.as_ref().unwrap().picks.is_empty());
|
||||
assert!(!h.app.status.as_ref().is_some_and(|(_, _, error)| *error));
|
||||
let undo = h.app.session.undo.len();
|
||||
h.run(&json!([{"click":{"sketch":[0,0]}}, {"click":{"sketch":[25,3]}}])).unwrap();
|
||||
assert_eq!(h.app.tool.as_ref().unwrap().picks.len(), 1);
|
||||
h.run(&json!([{"key":"Escape"}])).unwrap();
|
||||
assert!(h.app.tool.is_none());
|
||||
assert_eq!(h.app.session.undo.len(), undo);
|
||||
let sk = &h.app.session.model.state().sketches[0].sketch;
|
||||
let p = sk.point(sk.resolve_point("c1.center").unwrap()).unwrap();
|
||||
assert!((p.y - 3.0).abs() < 1e-6);
|
||||
}
|
||||
Reference in new issue
Block a user