diff --git a/Cargo.lock b/Cargo.lock index d4f35623..65ab5435 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3242,6 +3242,7 @@ dependencies = [ "serde", "serde_json", "solvecraft-engine", + "ttf-parser", "web-time", ] diff --git a/apps/solvecraft/src/main.rs b/apps/solvecraft/src/main.rs index f7e14d3f..0d9cd71b 100644 --- a/apps/solvecraft/src/main.rs +++ b/apps/solvecraft/src/main.rs @@ -169,6 +169,7 @@ fn main() -> eframe::Result { "SolveCraft", options, Box::new(move |cc| { + solvecraft_ui_egui::fonts::setup_fonts(&cc.egui_ctx); let mut app = SolveApp::new(Session::default(), services()); app.custom_titlebar = CUSTOM_TITLEBAR; app.integrated_titlebar = cfg!(target_os = "macos"); diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 8eec64ee..426389bb 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -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; diff --git a/crates/engine/src/cmd/sketch.rs b/crates/engine/src/cmd/sketch.rs index a3921e3b..ffdfd478 100644 --- a/crates/engine/src/cmd/sketch.rs +++ b/crates/engine/src/cmd/sketch.rs @@ -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::>(); + 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, Vec), 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; diff --git a/crates/engine/src/cmd/sketch_3d.rs b/crates/engine/src/cmd/sketch_3d.rs index d2a243c0..bdd65665 100644 --- a/crates/engine/src/cmd/sketch_3d.rs +++ b/crates/engine/src/cmd/sketch_3d.rs @@ -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"), diff --git a/crates/engine/src/cmd/sketch_create.rs b/crates/engine/src/cmd/sketch_create.rs index 6b8948d2..92d61a99 100644 --- a/crates/engine/src/cmd/sketch_create.rs +++ b/crates/engine/src/cmd/sketch_create.rs @@ -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 { let (t0, at0, h0, a0) = old.unwrap_or((String::new(), Vec2::ZERO, 5.0, 0.0)); diff --git a/crates/engine/src/cmd/sketch_ids_tests.rs b/crates/engine/src/cmd/sketch_ids_tests.rs new file mode 100644 index 00000000..65063ba0 --- /dev/null +++ b/crates/engine/src/cmd/sketch_ids_tests.rs @@ -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 { + 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#""#; + 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 = ["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::>()})); + 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 = ["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}"); +} diff --git a/crates/engine/src/cmd/sketch_import.rs b/crates/engine/src/cmd/sketch_import.rs index 801683bf..e776852a 100644 --- a/crates/engine/src/cmd/sketch_import.rs +++ b/crates/engine/src/cmd/sketch_import.rs @@ -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)"), diff --git a/crates/engine/src/cmd/sketch_modify.rs b/crates/engine/src/cmd/sketch_modify.rs index c9fc562d..21582431 100644 --- a/crates/engine/src/cmd/sketch_modify.rs +++ b/crates/engine/src/cmd/sketch_modify.rs @@ -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. 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`)"), + .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; diff --git a/crates/engine/src/cmd/sketch_project.rs b/crates/engine/src/cmd/sketch_project.rs index 05daef5f..4605b24e 100644 --- a/crates/engine/src/cmd/sketch_project.rs +++ b/crates/engine/src/cmd/sketch_project.rs @@ -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 { if let Some(name) = v.as_str() { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index e371eb15..1d157477 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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; diff --git a/crates/engine/src/view.rs b/crates/engine/src/view.rs index ce5fddb1..6da37897 100644 --- a/crates/engine/src/view.rs +++ b/crates/engine/src/view.rs @@ -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. @@ -126,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). @@ -188,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); @@ -201,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 { @@ -331,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}"); + } + } } diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 938e0de7..72ed25d1 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -58,7 +58,10 @@ pub fn tool_definitions() -> Vec { {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 { "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)"}, diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 38e7a0aa..56d940d0 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -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}; diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs index 69ac29aa..3b049533 100644 --- a/crates/render/src/raster.rs +++ b/crates/render/src/raster.rs @@ -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, } +/// 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> = 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 = 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> { 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); diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 110b5b96..29de9638 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -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 } diff --git a/crates/ui-egui/src/browser.rs b/crates/ui-egui/src/browser.rs index e07bf0b2..b904b633 100644 --- a/crates/ui-egui/src/browser.rs +++ b/crates/ui-egui/src/browser.rs @@ -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 { diff --git a/crates/ui-egui/src/context_menu.rs b/crates/ui-egui/src/context_menu.rs index e22b126d..abf95097 100644 --- a/crates/ui-egui/src/context_menu.rs +++ b/crates/ui-egui/src/context_menu.rs @@ -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 { 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 { ); } 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()) } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 7b68414a..e4b23a41 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -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 { + 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); + } +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 1e0500f4..284f048e 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -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 = 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:`. 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; } }); diff --git a/crates/ui-egui/src/documents.rs b/crates/ui-egui/src/documents.rs index 1e97d42b..954498fb 100644 --- a/crates/ui-egui/src/documents.rs +++ b/crates/ui-egui/src/documents.rs @@ -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 = 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); } }); diff --git a/crates/ui-egui/src/fonts.rs b/crates/ui-egui/src/fonts.rs new file mode 100644 index 00000000..46f69e0b --- /dev/null +++ b/crates/ui-egui/src/fonts.rs @@ -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> = 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) {} diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs new file mode 100644 index 00000000..7baae237 --- /dev/null +++ b/crates/ui-egui/src/i18n.rs @@ -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; + +/// 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, +} + +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> = 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 = 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>, +} + +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"); + } +} diff --git a/crates/ui-egui/src/i18n_ui_tests.rs b/crates/ui-egui/src/i18n_ui_tests.rs new file mode 100644 index 00000000..7c27d391 --- /dev/null +++ b/crates/ui-egui/src/i18n_ui_tests.rs @@ -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 { + 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(); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 11577212..4293e258 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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()); diff --git a/crates/ui-egui/src/locales/en.json b/crates/ui-egui/src/locales/en.json new file mode 100644 index 00000000..ea6704e0 --- /dev/null +++ b/crates/ui-egui/src/locales/en.json @@ -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" +} diff --git a/crates/ui-egui/src/locales/ja.json b/crates/ui-egui/src/locales/ja.json new file mode 100644 index 00000000..c445b1de --- /dev/null +++ b/crates/ui-egui/src/locales/ja.json @@ -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": "カーソル位置へズーム" +} diff --git a/crates/ui-egui/src/prefs.rs b/crates/ui-egui/src/prefs.rs index 4dcfe354..b7e509ef 100644 --- a/crates/ui-egui/src/prefs.rs +++ b/crates/ui-egui/src/prefs.rs @@ -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>().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. diff --git a/crates/ui-egui/src/sketch_palette.rs b/crates/ui-egui/src/sketch_palette.rs index aacf69fe..f80862a6 100644 --- a/crates/ui-egui/src/sketch_palette.rs +++ b/crates/ui-egui/src/sketch_palette.rs @@ -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; } }); diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 8e3571c6..e77c0327 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -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)); diff --git a/crates/ui-egui/src/tools.rs b/crates/ui-egui/src/tools.rs index adbefac1..a2a68433 100644 --- a/crates/ui-egui/src/tools.rs +++ b/crates/ui-egui/src/tools.rs @@ -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, 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]}), diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index 9cd55559..b752919c 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -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(); @@ -3016,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; @@ -3026,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] diff --git a/crates/ui-egui/tests/constraint_preselection.rs b/crates/ui-egui/tests/constraint_preselection.rs new file mode 100644 index 00000000..6787aee7 --- /dev/null +++ b/crates/ui-egui/tests/constraint_preselection.rs @@ -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()); +} diff --git a/crates/ui-egui/tests/point_alignment.rs b/crates/ui-egui/tests/point_alignment.rs new file mode 100644 index 00000000..45c6e126 --- /dev/null +++ b/crates/ui-egui/tests/point_alignment.rs @@ -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); +} diff --git a/docs/book/src/guide/sketching.md b/docs/book/src/guide/sketching.md index de24f0d4..7b4e0ce7 100644 --- a/docs/book/src/guide/sketching.md +++ b/docs/book/src/guide/sketching.md @@ -15,6 +15,11 @@ fillet, chamfer, offset (O), mirror, patterns, move/copy and scale. **Project** (P) and **Intersect** bring model edges into the sketch as linked geometry that follows later changes. Sketching on a face projects its edges automatically. +Run from a script or the control channel, every command that adds sketch geometry returns the +ids of what it added (`created: {points, curves, constraints, dimensions}`). Ids are stable and +never reused within a sketch, even after a delete, so a script can dimension or constrain the new +geometry right away. + Extend finds intersections with exact ellipses, rational conics and spline spans. It keeps the original line or circular support and extends only beyond the selected endpoint; conflicting constraints or an ambiguous overlap leave the sketch unchanged. @@ -54,12 +59,21 @@ fresh link once the source is available; their stored point values are preserved ## Constraints and dimensions +**Horizontal/Vertical** accepts a click on a line or clicks on two distinct sketch points, +including the origin, endpoints and circle centres. For two points, it chooses horizontal +or vertical alignment from their current positions. Esc cancels a pending first point. + The solver is SolveCraft's own (damped Gauss–Newton with degree-of-freedom analysis). Constraints: coincident, horizontal/vertical, parallel, perpendicular, tangent, equal, concentric, collinear, midpoint, symmetry, fix, curvature (G2) and polygon. **Sketch Dimension** (D) adds linear, aligned, horizontal, vertical, radius, diameter and angle dimensions. Each is a parameter (`d1`, `d2`, …), so it can hold an expression. +For Parallel, Perpendicular, Equal, Tangent, Concentric and Collinear, select exactly two +sketch curves (Shift-click to add the second) and choose the constraint to apply it immediately. +You can also start the constraint first and then pick its curves. An incomplete or mixed +selection starts that pick workflow; a rejected constraint keeps the selection for correction. + Under-constrained geometry is blue and fully constrained geometry turns dark, and the status shows the remaining degrees of freedom. A constraint or dimension that would over-constrain the sketch is refused rather than applied. Drag a point to move it; the solver keeps every diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 35a82c20..e833f5a4 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -17,6 +17,18 @@ events, one pointer event per frame. Commands never open dialogs when run this way. `solvecraft-cli commands` prints the same registry. +Every `sketch.*` command that changes a sketch adds `created: {points, curves, constraints, +dimensions}` to its result (and `wires` for 3D curves): the ids of what it added, in creation +order, next to whatever else the command returns (`curves`, `sides`, `corners`…). Sketch ids are +stable and never reused, even after a delete (`l1`, `l2`, then `l3` once `l2` is gone; only undo +rolls the numbering back with the design), so they can go straight into later commands: + +```text +{"id":7,"method":"engine.execute","params":{"command":"sketch.line","params":{"points":[[0,0],[40,0]]}}} +→ {"curves":["l1"],"created":{"points":["l1.start","l1.end"],"curves":["l1"],"constraints":["k1"],"dimensions":[]},…} +{"id":8,"method":"engine.execute","params":{"command":"sketch.dimension","params":{"entities":["l1"],"value":50}}} +``` + ## UI | Method | Params | Notes | @@ -43,14 +55,14 @@ Commands never open dialogs when run this way. `solvecraft-cli commands` prints | `ui.key` | `key` (egui key name, `Enter`, `Escape`…), `cmd?`, `shift?` | | | `ui.text` | `text` | typed text | | `ui.screenshot` | `path?` | PNG of the window (needs a presented frame) | -| `ui.render` | `path`, `width?`, `height?` | CPU render of the model with the current camera (no window needed) | +| `ui.render` | `path`, `width?`, `height?` | CPU render of the model with the current camera (no window needed); cut by the section the viewport shows (`inspect.section`, an open Section Analysis dialog's preview, a sketch's Slice), with the cut face capped | | `ui.confirm` | `accept?`: bool | answers the Delete confirmation (shown when a delete takes other features with it or makes some fail); without `accept`, what it lists | | `ui.documents` | `action?`: `new`, `switch`, `close`, `quit`; `index?`; `force?` (close without asking); `answer?`: `save`, `dont_save`, `cancel` (answers "save changes?") | the open designs (tabs): names, unsaved flags, the active one, a pending "save changes?" prompt, whether a quit is asking | | `ui.file` | `path`; `insert?`: bool | starts an interactive background Open or Insert; poll `ui.inspect.file_busy` for completion; the window stays responsive | | `ui.home` | `open?`: bool; `sample?`: index | the start page: show or hide it, open a built-in sample (once built); returns `open` and the recent designs | | `ui.shortcut` | `command`, `key?`, `replace?` | a command's key, or rebinds it (`""` clears; a key another command uses is refused unless `replace`) | | `ui.help` | `item?`: `about`, `shortcuts` (read only), `report` (copies diagnostics) | the version, commit, build date and diagnostic text | -| `ui.prefs` | any of `default_units`, `nav` (`fusion`, `solidworks`, `inventor`, `alias`, `tinkercad`), `zoom_reverse`, `zoom_to_cursor`, `orbit_cursor`, `look_at_anim`, `look_at_speed`, `msaa`, `length_decimals`, `angle_decimals`, `theme` (`dark`, `light`, `system`), `ui_scale`, `up_axis` (`z`, `y`), `offer_stand_up`; `open` (a page: General, Navigation, Keyboard Shortcuts, Design, Graphics, Sketch, Selection) opens the Preferences dialog there; `button`: `ok`, `cancel`, `apply`, `defaults`; `stand_up: true` stands up a just-opened Y-up file | sets preferences (kept between runs), returns them all plus `dialog` (the open page or null), `dark` and `stand_up_offer` | +| `ui.prefs` | any of `default_units`, `nav` (`fusion`, `solidworks`, `inventor`, `alias`, `tinkercad`), `zoom_reverse`, `zoom_to_cursor`, `orbit_cursor`, `look_at_anim`, `look_at_speed`, `msaa`, `length_decimals`, `angle_decimals`, `theme` (`dark`, `light`, `system`), `language` (`en`, `ja`; see [localization](localization.md)), `ui_scale`, `up_axis` (`z`, `y`), `offer_stand_up`; `open` (a page: General, Navigation, Keyboard Shortcuts, Design, Graphics, Sketch, Selection) opens the Preferences dialog there; `button`: `ok`, `cancel`, `apply`, `defaults`; `stand_up: true` stands up a just-opened Y-up file | sets preferences (kept between runs), returns them all plus `dialog` (the open page or null), `dark` and `stand_up_offer` | | `ui.capture` | `action?`: `capture`, `revert`, `cancel` | answers the Capture Position question (asked when a command starts or the design is saved while components were moved without capturing); without `action`, what waits | | `ui.window` | `action`: minimize, maximize, restore, toggle, close | what the title bar's caption buttons do (on Windows and Linux the application bar is the title bar); close asks about unsaved designs first | | `ui.agent_cursor` | `show` (bool), `speed`: a number from 1 to 5 (times the normal speed) or `"instant"` (no animation: the command runs at once and the pointer jumps to where it acted), `follow_camera` (bool); all optional | the settings. While the cursor is shown, `engine.execute` first glides to the command's toolbar button and to each pick, then runs and replies; later requests wait their turn. `"animate": false` on a call skips it | diff --git a/docs/localization.md b/docs/localization.md new file mode 100644 index 00000000..39bb44ea --- /dev/null +++ b/docs/localization.md @@ -0,0 +1,41 @@ +# UI localization + +SolveCraft embeds its translations and works offline. English and Japanese are currently +registered in `crates/ui-egui/src/i18n.rs`. Adding a language requires an embedded catalog and +one registration there; the preferences selector and validation use the same registry. + +## Messages + +`crates/ui-egui/src/locales/en.json` defines stable message IDs and English fallback text. +Keep IDs when changing wording. Other catalogs use the same IDs; missing messages fall back +to English. Use `app.tr("ui.preferences")` for static messages and +`Locale::format` for full sentences with named placeholders. Translate the entire sentence, +including punctuation, so a translator can reorder its arguments. Placeholder values are +inserted literally, even if a document name contains braces. + +The English-label adapter (`app.tr_label`) supports existing engine and UI tables. It maps +display text to a message ID, then uses the same catalog lookup. New UI messages should use +stable IDs directly. The adapter does not change command IDs, model names, or scenario handles. +When a table's English wording changes, update the corresponding English catalog entry too. +Generated browser labels use named templates while their underlying model names stay intact. + +Catalogs are parsed once and looked up through ordered maps. This is offline localization, +not a claim of zero runtime overhead. The catalog tests check registered locale IDs, +translation keys, and placeholder consistency. Run `cargo xtask ci` before submitting changes. + +## Rendering and coverage + +One immutable `Locale` type handles all translations. Table widgets without an app reference +use a render scope that restores the previous locale when it exits, including nested renders. +Standalone translated panels enter the same scope. Measure rendered text to size custom +widgets; UTF-8 byte lengths do not describe their displayed width. + +The desktop host tries system CJK fonts and skips files that cannot be parsed. No system fonts +are redistributed. Japanese glyph coverage depends on installed fonts; browser hosts cannot +load local system fonts and must provide suitably licensed font assets separately. + +The current messages support named substitution, not locale-specific plural selection, +number/date formatting, or right-to-left layout and shaping. Add those capabilities when +introducing messages or languages that need them; use an established message formatter for +plural rules instead of adding language branches to panels. The catalogs cover the UI surfaces +introduced by this PR, not every engine error or every newer command dialog.