Merge remote-tracking branch 'fork/fix/flush-pad-over-hole' into fix/flush-pad-over-hole

# Conflicts:
#	crates/kernel/src/tests.rs
This commit is contained in:
CypherNaugh_0x committed 2026-10-11 15:07:32 +02:00
commit b93698c42e
37 files changed
+2267 -254

No files matched your search

Generated
+1
View File
@@ -3242,6 +3242,7 @@ dependencies = [
"serde",
"serde_json",
"solvecraft-engine",
"ttf-parser",
"web-time",
]
+1
View File
@@ -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");
+8
View File
@@ -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;
+51 -16
View File
@@ -34,74 +34,74 @@ pub static COMMANDS: &[CommandSpec] = &[
.icon("line")
.key("L")
.enabled(in_sketch)
.params(concat!("points: [[x,y] | \"l1.end\" | {at, ref}…], closed?: bool, construction?: bool, ids?: [..], infer?: bool (add horizontal/vertical)", connect_doc!())),
.params(concat!("points: [[x,y] | \"l1.end\" | {at, ref}…], closed?: bool, construction?: bool, ids?: [..], infer?: bool (add horizontal/vertical)", connect_doc!(), created_doc!())),
CommandSpec::new("sketch.draw.dimensioned", "Draw Dimensioned Sketch Geometry", draw_dimensioned)
.enabled(in_sketch)
.params("command: sketch creation command, params: object, dimensions: object of role to numeric or expression value; creates geometry and all driving dimensions atomically in one undo step"),
.params(concat!("command: sketch creation command, params: object, dimensions: object of role to numeric or expression value; creates geometry and all driving dimensions atomically in one undo step", created_doc!())),
CommandSpec::new("sketch.rectangle.two_point", "2-Point Rectangle", rect_two_point)
.at("SKETCH", "CREATE")
.icon("rect")
.key("R")
.enabled(in_sketch)
.params(concat!("p0, p1: opposite corners [x,y]; construction?. Returns sides {bottom, right, top, left} (line ids) and corners {bottom_left, bottom_right, top_right, top_left} (point ids)", connect_doc!())),
.params(concat!("p0, p1: opposite corners [x,y]; construction?. Returns sides {bottom, right, top, left} (line ids) and corners {bottom_left, bottom_right, top_right, top_left} (point ids)", connect_doc!(), created_doc!())),
CommandSpec::new("sketch.rectangle.three_point", "3-Point Rectangle", rect_three_point)
.at("SKETCH", "CREATE")
.icon("rect3")
.enabled(in_sketch)
.params(concat!("p0, p1: first edge; p2: point on the opposite edge", connect_doc!())),
.params(concat!("p0, p1: first edge; p2: point on the opposite edge", connect_doc!(), created_doc!())),
CommandSpec::new("sketch.rectangle.center", "Center Rectangle", rect_center)
.at("SKETCH", "CREATE")
.icon("rect_center")
.enabled(in_sketch)
.params(concat!("center, corner. Returns sides {bottom, right, top, left} (line ids) and corners {bottom_left, bottom_right, top_right, top_left} (point ids)", connect_doc!())),
.params(concat!("center, corner. Returns sides {bottom, right, top, left} (line ids) and corners {bottom_left, bottom_right, top_right, top_left} (point ids)", connect_doc!(), created_doc!())),
CommandSpec::new("sketch.circle.center", "Center Diameter Circle", circle_center)
.at("SKETCH", "CREATE")
.icon("circle")
.key("C")
.enabled(in_sketch)
.params(concat!("center: [x,y] or point ref, radius | diameter: number", connect_doc!())),
.params(concat!("center: [x,y] or point ref, radius | diameter: number", connect_doc!(), created_doc!())),
CommandSpec::new("sketch.circle.two_point", "2-Point Circle", circle_two)
.at("SKETCH", "CREATE")
.icon("circle2")
.enabled(in_sketch)
.params(concat!("p0, p1: diameter ends", connect_doc!())),
.params(concat!("p0, p1: diameter ends", connect_doc!(), created_doc!())),
CommandSpec::new("sketch.circle.three_point", "3-Point Circle", circle_three)
.at("SKETCH", "CREATE")
.icon("circle3")
.enabled(in_sketch)
.params(concat!("p0, p1, p2", connect_doc!())),
CommandSpec::new("sketch.arc.three_point", "3-Point Arc", arc_three).at("SKETCH", "CREATE").icon("arc3").enabled(in_sketch).params(concat!("start, end, through", connect_doc!())),
.params(concat!("p0, p1, p2", connect_doc!(), created_doc!())),
CommandSpec::new("sketch.arc.three_point", "3-Point Arc", arc_three).at("SKETCH", "CREATE").icon("arc3").enabled(in_sketch).params(concat!("start, end, through", connect_doc!(), created_doc!())),
CommandSpec::new("sketch.arc.center_point", "Center Point Arc", arc_center)
.at("SKETCH", "CREATE")
.icon("arc_center")
.enabled(in_sketch)
.params(concat!("center, start, end (counter-clockwise from start) | center, start, sweep: signed degrees", connect_doc!())),
.params(concat!("center, start, end (counter-clockwise from start) | center, start, sweep: signed degrees", connect_doc!(), created_doc!())),
CommandSpec::new("sketch.polygon.inscribed", "Inscribed Polygon", polygon_inscribed)
.at("SKETCH", "CREATE")
.icon("polygon")
.enabled(in_sketch)
.params(concat!("center, radius (to vertices), sides, angle?: deg", connect_doc!())),
.params(concat!("center, radius (to vertices), sides, angle?: deg", connect_doc!(), created_doc!())),
CommandSpec::new("sketch.polygon.circumscribed", "Circumscribed Polygon", polygon_circumscribed)
.at("SKETCH", "CREATE")
.icon("polygon")
.enabled(in_sketch)
.params(concat!("center, radius (to edge midpoints), sides, angle?: deg", connect_doc!())),
.params(concat!("center, radius (to edge midpoints), sides, angle?: deg", connect_doc!(), created_doc!())),
CommandSpec::new("sketch.polygon.edge", "Edge Polygon", polygon_edge)
.at("SKETCH", "CREATE")
.icon("polygon")
.enabled(in_sketch)
.params(concat!("p0, p1: one edge, sides", connect_doc!())),
.params(concat!("p0, p1: one edge, sides", connect_doc!(), created_doc!())),
CommandSpec::new("sketch.slot.center_to_center", "Center to Center Slot", slot_c2c)
.at("SKETCH", "CREATE")
.icon("slot")
.enabled(in_sketch)
.params(concat!("p0, p1: arc centres, width", connect_doc!())),
.params(concat!("p0, p1: arc centres, width", connect_doc!(), created_doc!())),
CommandSpec::new("sketch.slot.overall", "Overall Slot", slot_overall)
.at("SKETCH", "CREATE")
.icon("slot")
.enabled(in_sketch)
.params(concat!("p0, p1: overall ends, width", connect_doc!())),
CommandSpec::new("sketch.point", "Point", draw_point).at("SKETCH", "CREATE").icon("point").enabled(in_sketch).params(concat!("point: [x,y], id?", connect_doc!())),
.params(concat!("p0, p1: overall ends, width", connect_doc!(), created_doc!())),
CommandSpec::new("sketch.point", "Point", draw_point).at("SKETCH", "CREATE").icon("point").enabled(in_sketch).params(concat!("point: [x,y], id?", connect_doc!(), created_doc!())),
CommandSpec::new("sketch.dimension", "Sketch Dimension", dimension).at("SKETCH", "CREATE").icon("dimension").key("D").enabled(in_sketch).params(
"entities: [refs], type?: auto|distance|horizontal|vertical|length|radius|diameter|angle, value?: number or expression (default: current); text_at?: [x, y]. Two lines: the angle between them (acute, or 180° − it when the value is nearer that) unless text_at picks a sector. Returns measured in unit (mm or deg)",
),
@@ -353,6 +353,37 @@ pub(super) fn mark_construction(sk: &mut Sketch, curves: &[usize], p: &Value) {
}
}
/// What a command added to a sketch: the ids of the points, curves, constraints and dimensions
/// in `after` that `before` does not have (`None`: a new sketch), in creation order, plus the
/// 3D curves (`wires`) when there are any. Ids are stable and never reused in a sketch, so they
/// can be used in later commands (`sketch.dimension`, `sketch.constraint.*`, `sketch.delete`…).
pub(crate) fn created(before: Option<&Sketch>, after: &Sketch) -> Value {
use std::collections::HashSet;
let empty = Sketch::new();
let before = before.unwrap_or(&empty);
let old: HashSet<&str> = before
.points
.iter()
.map(|x| x.id.as_str())
.chain(before.curves.iter().map(|x| x.id.as_str()))
.chain(before.constraints.iter().map(|x| x.id.as_str()))
.chain(before.wires.iter().map(|x| x.id.as_str()))
.collect();
let new = |id: &&str| !old.contains(*id);
let points: Vec<&str> = after.points.iter().map(|x| x.id.as_str()).filter(new).collect();
let curves: Vec<&str> = after.curves.iter().map(|x| x.id.as_str()).filter(new).collect();
let (dims, cons): (Vec<_>, Vec<_>) = after.constraints.iter().filter(|c| new(&c.id.as_str())).partition(|c| c.kind.is_dimension());
let ids = |v: Vec<&solvecraft_sketch::Constraint>| v.into_iter().map(|c| c.id.clone()).collect::<Vec<_>>();
let mut out = json!({"points": points, "curves": curves, "constraints": ids(cons), "dimensions": ids(dims)});
let wires: Vec<&str> = after.wires.iter().map(|x| x.id.as_str()).filter(new).collect();
if !wires.is_empty()
&& let Some(o) = out.as_object_mut()
{
o.insert("wires".into(), json!(wires));
}
out
}
pub(super) fn result(sk_out: (Vec<String>, Vec<String>), info: Value) -> Value {
json!({"curves": sk_out.0, "constraints": sk_out.1, "sketch": info})
}
@@ -1909,3 +1940,7 @@ mod rect_tests;
#[cfg(test)]
#[path = "sketch_ux_tests.rs"]
mod ux_tests;
#[cfg(test)]
#[path = "sketch_ids_tests.rs"]
mod ids_tests;
+8 -5
View File
@@ -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"),
+75 -77
View File
@@ -10,83 +10,81 @@ use super::{CommandSpec, in_sketch};
use crate::params::{bad, num, req_vec2, str_, string_list};
use crate::{Result, Session};
pub static COMMANDS: &[CommandSpec] =
&[
CommandSpec::new("sketch.line.midpoint", "Midpoint Line", midpoint_line)
.at("SKETCH", "CREATE")
.icon("line_mid")
.enabled(in_sketch)
.params("mid: [x,y] or point ref, end: [x,y]; construction?"),
CommandSpec::new("sketch.arc.tangent", "Tangent Arc", arc_tangent).at("SKETCH", "CREATE").icon("arc_tangent").enabled(in_sketch).params(
"start: end point of a line or arc (\"l1.end\"), end: [x,y]; curve?: the curve to be tangent to (default: the one ending at start)",
),
CommandSpec::new("sketch.circle.two_tangent", "2-Tangent Circle", circle_tan_tan)
.at("SKETCH", "CREATE")
.icon("circle_tt")
.enabled(in_sketch)
.params("curves: [two lines/circles/arcs], radius | diameter: expr, near: [x,y] (which of the solutions)"),
CommandSpec::new("sketch.circle.three_tangent", "3-Tangent Circle", circle_three_tan)
.at("SKETCH", "CREATE")
.icon("circle_ttt")
.enabled(in_sketch)
.params("curves: [three lines/circles/arcs], near: [x,y] (which of the solutions)"),
CommandSpec::new("sketch.slot.center_point", "Center Point Slot", slot_center_point)
.at("SKETCH", "CREATE")
.icon("slot")
.enabled(in_sketch)
.params("center: slot centre, end: one arc centre, width"),
CommandSpec::new("sketch.slot.arc_three_point", "Three Point Arc Slot", arc_slot_three)
.at("SKETCH", "CREATE")
.icon("arc_slot")
.enabled(in_sketch)
.params("start, through, end: points on the centre arc, width"),
CommandSpec::new("sketch.slot.arc_center", "Center Point Arc Slot", arc_slot_center)
.at("SKETCH", "CREATE")
.icon("arc_slot")
.enabled(in_sketch)
.params("center, start: centre arc start, end: direction of the centre arc end (counter-clockwise), width"),
CommandSpec::new("sketch.ellipse", "Ellipse", ellipse)
.at("SKETCH", "CREATE")
.icon("ellipse")
.enabled(in_sketch)
.params("center, major: end of the major axis [x,y], minor: a point the ellipse passes through on the minor side [x,y] | minor_radius"),
CommandSpec::new("sketch.spline.fit_point", "Fit Point Spline", spline_fit)
.at("SKETCH", "CREATE")
.icon("spline")
.enabled(in_sketch)
.params("points: [[x,y] or point refs…] (2…500), periodic?: boolean (default false); periodic cubic fit closes smoothly through at least 3 distinct samples"),
CommandSpec::new("sketch.spline.control_point", "Control Point Spline", spline_cv3)
.at("SKETCH", "CREATE")
.icon("spline_cv")
.enabled(in_sketch)
.params("points: control points (2…500), cubic"),
CommandSpec::new("sketch.spline.control_point_5", "Control Point Spline (Degree 5)", spline_cv5)
.at("SKETCH", "CREATE")
.icon("spline_cv")
.enabled(in_sketch)
.params("points: control points (2…500), degree 5"),
CommandSpec::new("sketch.conic", "Conic Curve", conic)
.at("SKETCH", "CREATE")
.icon("conic")
.enabled(in_sketch)
.params("start, end, apex: [x,y] or point refs; rho?: 0…1 (default 0.5: parabola)"),
CommandSpec::new("sketch.blend_curve", "Blend Curve", blend)
.at("SKETCH", "MODIFY")
.icon("blend")
.enabled(in_sketch)
.params("a, b: end points of two curves (\"l1.end\", \"a2.start\"): a smooth (tangent) spline joining them"),
CommandSpec::new("sketch.centerline", "Centerline", centerline)
.enabled(in_sketch)
.params("curves: [line ids], value?: bool (default toggles): centerline line type (not in profiles; an axis)"),
CommandSpec::new("sketch.text", "Text", text)
.at("SKETCH", "CREATE")
.icon("text")
.enabled(in_sketch)
.params("text: string (\\n breaks lines), at: baseline start [x,y], height?: capital height mm (default 5), angle?: deg"),
CommandSpec::new("sketch.edit_text", "Edit Text", edit_text)
.enabled(in_sketch)
.params("link: the text's link id (or entity: one of its curves), text?, at?, height?, angle?: deg"),
];
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("sketch.line.midpoint", "Midpoint Line", midpoint_line)
.at("SKETCH", "CREATE")
.icon("line_mid")
.enabled(in_sketch)
.params(concat!("mid: [x,y] or point ref, end: [x,y]; construction?", created_doc!())),
CommandSpec::new("sketch.arc.tangent", "Tangent Arc", arc_tangent).at("SKETCH", "CREATE").icon("arc_tangent").enabled(in_sketch).params(concat!(
"start: end point of a line or arc (\"l1.end\"), end: [x,y]; curve?: the curve to be tangent to (default: the one ending at start)",
created_doc!()
)),
CommandSpec::new("sketch.circle.two_tangent", "2-Tangent Circle", circle_tan_tan)
.at("SKETCH", "CREATE")
.icon("circle_tt")
.enabled(in_sketch)
.params(concat!("curves: [two lines/circles/arcs], radius | diameter: expr, near: [x,y] (which of the solutions)", created_doc!())),
CommandSpec::new("sketch.circle.three_tangent", "3-Tangent Circle", circle_three_tan)
.at("SKETCH", "CREATE")
.icon("circle_ttt")
.enabled(in_sketch)
.params(concat!("curves: [three lines/circles/arcs], near: [x,y] (which of the solutions)", created_doc!())),
CommandSpec::new("sketch.slot.center_point", "Center Point Slot", slot_center_point)
.at("SKETCH", "CREATE")
.icon("slot")
.enabled(in_sketch)
.params(concat!("center: slot centre, end: one arc centre, width", created_doc!())),
CommandSpec::new("sketch.slot.arc_three_point", "Three Point Arc Slot", arc_slot_three)
.at("SKETCH", "CREATE")
.icon("arc_slot")
.enabled(in_sketch)
.params(concat!("start, through, end: points on the centre arc, width", created_doc!())),
CommandSpec::new("sketch.slot.arc_center", "Center Point Arc Slot", arc_slot_center)
.at("SKETCH", "CREATE")
.icon("arc_slot")
.enabled(in_sketch)
.params(concat!("center, start: centre arc start, end: direction of the centre arc end (counter-clockwise), width", created_doc!())),
CommandSpec::new("sketch.ellipse", "Ellipse", ellipse).at("SKETCH", "CREATE").icon("ellipse").enabled(in_sketch).params(concat!(
"center, major: end of the major axis [x,y], minor: a point the ellipse passes through on the minor side [x,y] | minor_radius",
created_doc!()
)),
CommandSpec::new("sketch.spline.fit_point", "Fit Point Spline", spline_fit)
.at("SKETCH", "CREATE")
.icon("spline")
.enabled(in_sketch)
.params(concat!("points: [[x,y] or point refs…] (2…500): the spline passes through them, periodic?: boolean (default false); periodic cubic fit closes smoothly through at least 3 distinct samples", created_doc!())),
CommandSpec::new("sketch.spline.control_point", "Control Point Spline", spline_cv3)
.at("SKETCH", "CREATE")
.icon("spline_cv")
.enabled(in_sketch)
.params(concat!("points: control points (2…500), cubic", created_doc!())),
CommandSpec::new("sketch.spline.control_point_5", "Control Point Spline (Degree 5)", spline_cv5)
.at("SKETCH", "CREATE")
.icon("spline_cv")
.enabled(in_sketch)
.params(concat!("points: control points (2…500), degree 5", created_doc!())),
CommandSpec::new("sketch.conic", "Conic Curve", conic)
.at("SKETCH", "CREATE")
.icon("conic")
.enabled(in_sketch)
.params(concat!("start, end, apex: [x,y] or point refs; rho?: 0…1 (default 0.5: parabola)", created_doc!())),
CommandSpec::new("sketch.blend_curve", "Blend Curve", blend)
.at("SKETCH", "MODIFY")
.icon("blend")
.enabled(in_sketch)
.params(concat!("a, b: end points of two curves (\"l1.end\", \"a2.start\"): a smooth (tangent) spline joining them", created_doc!())),
CommandSpec::new("sketch.centerline", "Centerline", centerline)
.enabled(in_sketch)
.params("curves: [line ids], value?: bool (default toggles): centerline line type (not in profiles; an axis)"),
CommandSpec::new("sketch.text", "Text", text).at("SKETCH", "CREATE").icon("text").enabled(in_sketch).params(concat!(
"text: string (\\n breaks lines), at: baseline start [x,y], height?: capital height mm (default 5), angle?: deg",
created_doc!()
)),
CommandSpec::new("sketch.edit_text", "Edit Text", edit_text)
.enabled(in_sketch)
.params(concat!("link: the text's link id (or entity: one of its curves), text?, at?, height?, angle?: deg", created_doc!())),
];
fn text_source(p: &Value, cmd: &str, old: Option<(String, Vec2, f64, f64)>) -> Result<LinkSource> {
let (t0, at0, h0, a0) = old.unwrap_or((String::new(), Vec2::ZERO, 5.0, 0.0));
+330
View File
@@ -0,0 +1,330 @@
//! Every command that adds sketch geometry returns `created: {points, curves, constraints,
//! dimensions}` with the real ids of what it added, so a script can dimension and constrain the
//! new geometry right away; ids are never reused after a delete.
use serde_json::{Value, json};
use solvecraft_sketch::{CurveKind, Sketch};
use crate::Session;
fn run(s: &mut Session, id: &str, p: Value) -> Value {
match s.execute(id, &p) {
Ok(v) => v,
Err(e) => panic!("{id} {p}: {e}"),
}
}
fn strs(v: &Value) -> Vec<String> {
v.as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default()
}
fn sketch(s: &Session) -> Sketch {
s.doc.sketch(s.active_sketch.unwrap()).unwrap().clone()
}
fn new_sketch() -> Session {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY"}));
s
}
/// The ids in `r.created` exist in the active sketch, and each curve and point takes a
/// dimension (driven, so it never conflicts) and a fix right away. `before`: the sketch before
/// the command. Returns `created`.
fn check(s: &mut Session, cmd: &str, r: &Value, before: &Sketch) -> Value {
let c = r.get("created").cloned().unwrap_or_else(|| panic!("{cmd}: no `created` in {r}"));
for k in ["points", "curves", "constraints", "dimensions"] {
assert!(c[k].is_array(), "{cmd}: created.{k} in {c}");
}
let sk = sketch(s);
let curves = strs(&c["curves"]);
let points = strs(&c["points"]);
assert!(!curves.is_empty() || !points.is_empty() || c.get("wires").is_some(), "{cmd}: nothing created: {c}");
for id in &points {
assert!(sk.point_index(id).is_some(), "{cmd}: point {id} not in the sketch");
assert_ne!(id, "origin", "{cmd}");
}
for id in strs(&c["constraints"]).iter().chain(&strs(&c["dimensions"])) {
assert!(sk.constraints.iter().any(|k| &k.id == id), "{cmd}: constraint {id} not in the sketch");
}
for id in strs(&c["dimensions"]) {
assert!(sk.constraints.iter().any(|k| k.id == id && k.kind.is_dimension()), "{cmd}: {id} is not a dimension");
}
for id in strs(&c.get("wires").cloned().unwrap_or_default()) {
assert!(sk.wire_index(&id).is_some(), "{cmd}: wire {id} not in the sketch");
}
// The new curves in the command's own list (where it has one) are in `created`.
let wires = strs(&c.get("wires").cloned().unwrap_or_default());
for id in strs(&r["curves"]) {
let old = before.curve_index(&id).is_some() || before.wire_index(&id).is_some();
assert!(old || curves.contains(&id) || wires.contains(&id), "{cmd}: returned curve {id} missing from created {c}");
}
for id in curves.iter().chain(&points) {
assert!(before.curve_index(id).is_none() && before.point_index(id).is_none(), "{cmd}: {id} is not new");
}
for id in &curves {
let i = sk.curve_index(id).unwrap_or_else(|| panic!("{cmd}: curve {id} not in the sketch"));
let measurable = matches!(sk.curves[i].kind, CurveKind::Line { .. } | CurveKind::Circle { .. } | CurveKind::Arc { .. });
if measurable {
run(s, "sketch.dimension", json!({"entities": [id], "driven": true}));
}
run(s, "sketch.constraint.fix", json!({"entity": id, "fixed": true}));
run(s, "edit.undo", json!({}));
if measurable {
run(s, "edit.undo", json!({}));
}
}
for id in &points {
run(s, "sketch.dimension", json!({"entities": [id, "origin"], "driven": true}));
run(s, "edit.undo", json!({}));
}
c
}
/// Run `cmd` in `s` and check what it reports.
fn made(s: &mut Session, cmd: &str, p: Value) -> Value {
let before = sketch(s);
let r = run(s, cmd, p);
check(s, cmd, &r, &before)
}
#[test]
fn drawing_commands_return_the_ids_they_create() {
let cases: Vec<(&str, Value)> = vec![
("sketch.line", json!({"points": [[0, 0], [10, 0], [10, 5]]})),
("sketch.rectangle.two_point", json!({"p0": [1, 1], "p1": [21, 15]})),
("sketch.rectangle.three_point", json!({"p0": [0, 0], "p1": [20, 5], "p2": [18, 15]})),
("sketch.rectangle.center", json!({"center": [3, 2], "corner": [13, 9]})),
("sketch.circle.center", json!({"center": [5, 5], "diameter": 8})),
("sketch.circle.two_point", json!({"p0": [0, 0], "p1": [10, 0]})),
("sketch.circle.three_point", json!({"p0": [0, 0], "p1": [10, 0], "p2": [5, 6]})),
("sketch.arc.three_point", json!({"start": [0, 0], "end": [10, 0], "through": [5, 4]})),
("sketch.arc.center_point", json!({"center": [0, 0], "start": [10, 0], "end": [0, 10]})),
("sketch.polygon.inscribed", json!({"center": [0, 0], "radius": 10, "sides": 6})),
("sketch.polygon.circumscribed", json!({"center": [0, 0], "radius": 10, "sides": 5})),
("sketch.polygon.edge", json!({"p0": [0, 0], "p1": [10, 0], "sides": 4})),
("sketch.slot.center_to_center", json!({"p0": [0, 0], "p1": [20, 0], "width": 6})),
("sketch.slot.overall", json!({"p0": [0, 0], "p1": [20, 0], "width": 6})),
("sketch.slot.center_point", json!({"center": [0, 0], "end": [10, 0], "width": 4})),
("sketch.slot.arc_three_point", json!({"start": [0, 0], "through": [10, 5], "end": [20, 0], "width": 3})),
("sketch.slot.arc_center", json!({"center": [0, 0], "start": [10, 0], "end": [0, 10], "width": 3})),
("sketch.point", json!({"point": [3, 4]})),
("sketch.line.midpoint", json!({"mid": [0, 0], "end": [10, 5]})),
("sketch.ellipse", json!({"center": [0, 0], "major": [10, 0], "minor_radius": 4})),
("sketch.spline.fit_point", json!({"points": [[0, 0], [5, 5], [10, 0]]})),
("sketch.spline.fit_point", json!({"points": [[0, 0], [5, 5], [10, 0]], "periodic": true})),
("sketch.spline.control_point", json!({"points": [[0, 0], [5, 5], [10, 0], [15, 5]]})),
("sketch.spline.control_point_5", json!({"points": [[0, 0], [3, 5], [6, 0], [9, 5], [12, 0], [15, 5]]})),
("sketch.conic", json!({"start": [0, 0], "end": [10, 0], "apex": [5, 6]})),
("sketch.text", json!({"text": "IT", "at": [0, 0], "height": 10})),
];
for (cmd, p) in cases {
let mut s = new_sketch();
made(&mut s, cmd, p);
}
}
#[test]
fn drawn_ids_take_driving_dimensions_and_constraints() {
let mut s = new_sketch();
let c = made(&mut s, "sketch.line", json!({"points": [[0, 0], [10, 0]], "connect": false}));
let l = strs(&c["curves"]);
let pts = strs(&c["points"]);
assert_eq!((l.len(), pts.len()), (1, 2), "{c}");
run(&mut s, "sketch.constraint.coincident", json!({"a": pts[0], "b": "origin"}));
run(&mut s, "sketch.constraint.horizontal_vertical", json!({"line": l[0]}));
let d = run(&mut s, "sketch.dimension", json!({"entities": [l[0]], "value": 30}));
assert_eq!(strs(&d["created"]["dimensions"]).len(), 1, "{d}");
let sk = sketch(&s);
let end = sk.point(sk.point_index(&pts[1]).unwrap()).unwrap();
assert!((end.x - 30.0).abs() < 1e-6 && end.y.abs() < 1e-6, "{end:?}");
let c = made(&mut s, "sketch.circle.center", json!({"center": [50, 0], "radius": 4}));
let circle = strs(&c["curves"]);
run(&mut s, "sketch.dimension", json!({"entities": [circle[0]], "type": "diameter", "value": 12}));
let sk = sketch(&s);
let i = sk.curve_index(&circle[0]).unwrap();
assert!(matches!(sk.curves[i].kind, CurveKind::Circle { r, .. } if (r - 6.0).abs() < 1e-6));
}
#[test]
fn modify_commands_return_the_ids_they_create() {
// Corner tools: an open L of two lines.
for (cmd, extra) in [
("sketch.fillet", json!({"radius": 2})),
("sketch.chamfer.equal_distance", json!({"distance": 2})),
("sketch.chamfer.distance_angle", json!({"distance": 2, "angle": 40})),
("sketch.chamfer.two_distance", json!({"distance": 2, "distance2": 3})),
] {
let mut s = new_sketch();
let c = made(&mut s, "sketch.line", json!({"points": [[0, 10], [0, 0], [10, 0]]}));
let l = strs(&c["curves"]);
let mut p = extra;
p["point"] = json!(format!("{}.end", l[0]));
let c = made(&mut s, cmd, p);
assert!(!strs(&c["curves"]).is_empty(), "{cmd}: {c}");
}
let mut s = new_sketch();
let rect = strs(&made(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [20, 10]}))["curves"]);
let c = made(&mut s, "sketch.offset", json!({"curves": [rect[0]], "distance": 2, "side": [10, 5]}));
assert_eq!(strs(&c["curves"]).len(), 4, "{c}");
made(&mut s, "sketch.move", json!({"entities": [rect[0]], "translate": [0, -20], "copy": true}));
made(&mut s, "sketch.pattern.rectangular", json!({"entities": [rect[2]], "count": 3, "spacing": 30}));
let circle = strs(&made(&mut s, "sketch.circle.center", json!({"center": [100, 0], "radius": 2}))["curves"]);
made(&mut s, "sketch.pattern.circular", json!({"entities": [circle[0]], "center": [90, 0], "count": 4}));
let axis = strs(&made(&mut s, "sketch.line", json!({"points": [[200, -10], [200, 10]]}))["curves"]);
let sm = strs(&made(&mut s, "sketch.circle.center", json!({"center": [190, 0], "radius": 2}))["curves"]);
made(&mut s, "sketch.mirror", json!({"entities": [sm[0]], "line": axis[0]}));
// Break and trim split a curve where another crosses it.
let mut s = new_sketch();
let h = strs(&made(&mut s, "sketch.line", json!({"points": [[0, 0], [20, 0]]}))["curves"]);
made(&mut s, "sketch.line", json!({"points": [[10, -5], [10, 5]]}));
made(&mut s, "sketch.break", json!({"curve": h[0], "at": [5, 0]}));
let mut s = new_sketch();
let h = strs(&made(&mut s, "sketch.line", json!({"points": [[0, 0], [30, 0]]}))["curves"]);
made(&mut s, "sketch.line", json!({"points": [[10, -5], [10, 5]]}));
made(&mut s, "sketch.line", json!({"points": [[20, -5], [20, 5]]}));
made(&mut s, "sketch.trim", json!({"curve": h[0], "at": [15, 0]}));
// Tangent tools and blends start from existing curves.
let mut s = new_sketch();
let l = strs(&made(&mut s, "sketch.line", json!({"points": [[0, 0], [10, 0]]}))["curves"]);
made(&mut s, "sketch.arc.tangent", json!({"start": format!("{}.end", l[0]), "end": [20, 10]}));
let a = strs(&made(&mut s, "sketch.line", json!({"points": [[0, 30], [10, 30]]}))["curves"]);
let b = strs(&made(&mut s, "sketch.line", json!({"points": [[20, 40], [30, 40]]}))["curves"]);
made(&mut s, "sketch.blend_curve", json!({"a": format!("{}.end", a[0]), "b": format!("{}.start", b[0])}));
let x = strs(&made(&mut s, "sketch.line", json!({"points": [[100, 0], [140, 0]], "connect": false}))["curves"]);
let y = strs(&made(&mut s, "sketch.line", json!({"points": [[100, 0], [100, 40]], "connect": false}))["curves"]);
made(&mut s, "sketch.circle.two_tangent", json!({"curves": [x[0], y[0]], "radius": 5, "near": [106, 4]}));
let z = strs(&made(&mut s, "sketch.line", json!({"points": [[100, 40], [140, 40]], "connect": false}))["curves"]);
made(&mut s, "sketch.circle.three_tangent", json!({"curves": [x[0], y[0], z[0]], "near": [120, 20]}));
}
#[test]
fn dimensioned_drawing_reports_its_geometry_and_driving_dimensions() {
// The creator runs nested inside sketch.draw.dimensioned; `created` must still cover the
// dimensions the wrapper adds afterwards, not just the nested creator's geometry.
for (cmd, p, dims) in [
("sketch.line", json!({"points": [[0, 0], [5, 5]]}), json!({"length": 10, "angle": 45})),
("sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [20, 10]}), json!({"width": 30, "height": 15})),
("sketch.circle.center", json!({"center": [0, 0], "radius": 3}), json!({"diameter": 12})),
("sketch.polygon.inscribed", json!({"center": [0, 0], "radius": 10, "sides": 6}), json!({"radius": 8})),
] {
let mut s = new_sketch();
let c = made(&mut s, "sketch.draw.dimensioned", json!({"command": cmd, "params": p, "dimensions": dims}));
let n = dims.as_object().map_or(0, |d| d.len());
assert_eq!(strs(&c["dimensions"]).len(), n, "{cmd}: {c}");
assert!(!strs(&c["curves"]).is_empty(), "{cmd}: {c}");
}
}
#[test]
fn breaking_an_offset_association_creates_nothing() {
let mut s = new_sketch();
let e = strs(&made(&mut s, "sketch.ellipse", json!({"center": [0, 0], "major": [10, 0], "minor_radius": 5, "connect": false}))["curves"]);
let c = made(&mut s, "sketch.offset", json!({"curves": [e[0]], "distance": -1, "chain": false}));
let out = strs(&c["curves"]);
let r = run(&mut s, "sketch.offset.break", json!({"curve": out[0]}));
let c = &r["created"];
for k in ["points", "curves", "constraints", "dimensions"] {
assert_eq!(c[k], json!([]), "{r}");
}
}
#[test]
fn text_edits_and_imports_return_the_ids_they_create() {
let mut s = new_sketch();
let r = run(&mut s, "sketch.text", json!({"text": "I", "at": [0, 0], "height": 10}));
check(&mut s, "sketch.text", &r, &Sketch::new());
let before = sketch(&s);
let link = r["link"].as_str().map(str::to_string).unwrap_or_else(|| panic!("{r}"));
let r = run(&mut s, "sketch.edit_text", json!({"link": link, "text": "II"}));
check(&mut s, "sketch.edit_text", &r, &before);
let svg = r#"<svg width="40mm" height="40mm" viewBox="0 0 40 40"><path d="M0 0 H40 V40 H0 Z"/></svg>"#;
let mut s = new_sketch();
let c = made(&mut s, "sketch.insert_svg", json!({"text": svg}));
assert_eq!(strs(&c["curves"]).len(), 4, "{c}");
let dxf = run(&mut s, "sketch.export_dxf", json!({}))["dxf"].as_str().unwrap_or_default().to_string();
let mut s = new_sketch();
let c = made(&mut s, "sketch.insert_dxf", json!({"text": dxf}));
assert_eq!(strs(&c["curves"]).len(), 4, "{c}");
}
#[test]
fn projections_and_3d_curves_return_the_ids_they_create() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"corner": [0, 0, 0], "length": 20, "width": 20, "height": 10}));
let body = s.model.state().bodies.first().map(|b| b.name.clone()).unwrap_or_default();
run(&mut s, "sketch.create", json!({"plane": "XY", "offset": 5}));
made(&mut s, "sketch.project", json!({"refs": [{"edge": [10, 0, 10]}]}));
made(&mut s, "sketch.intersect", json!({"refs": [{"body": body}]}));
made(&mut s, "sketch.include_3d", json!({"refs": [{"edge": [20, 10, 10]}]}));
made(&mut s, "sketch.fit_curves_to_section", json!({"body": body}));
made(&mut s, "sketch.isoparametric_curve", json!({"face": [10, 10, 10]}));
let mut s = new_sketch();
run(&mut s, "sketch.options", json!({"sketch_3d": true}));
let c = made(&mut s, "sketch.line3d", json!({"points": [[0, 0, 0], [0, 0, 10], [10, 0, 10]]}));
assert_eq!(strs(&c["wires"]).len(), 2, "{c}");
made(&mut s, "sketch.spline3d", json!({"points": [[0, 0, 0], [5, 5, 5], [10, 0, 10]]}));
made(&mut s, "sketch.point3d", json!({"point": [5, 5, 5]}));
}
#[test]
fn a_new_sketch_reports_its_projected_face_edges() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"corner": [0, 0, 0], "length": 20, "width": 20, "height": 10}));
let r = run(&mut s, "sketch.create", json!({"plane": {"face": [10, 10, 10]}}));
let c = check(&mut s, "sketch.create", &r, &Sketch::new());
assert_eq!(strs(&c["curves"]).len(), 4, "{c}");
}
#[test]
fn ids_are_never_reused_after_a_delete() {
let mut s = new_sketch();
let first = made(&mut s, "sketch.line", json!({"points": [[0, 0], [10, 0]]}));
let circle = made(&mut s, "sketch.circle.center", json!({"center": [30, 0], "radius": 3}));
let dim = run(&mut s, "sketch.dimension", json!({"entities": [strs(&circle["curves"])[0]], "value": 8}));
let mut gone: Vec<String> = ["points", "curves"].iter().flat_map(|k| strs(&first[k]).into_iter().chain(strs(&circle[k]))).collect();
gone.extend(strs(&dim["created"]["dimensions"]));
// Deleting the curves takes their own end and centre points (and the dimension) with them.
run(&mut s, "sketch.delete", json!({"entities": strs(&first["curves"]).into_iter().chain(strs(&circle["curves"])).collect::<Vec<_>>()}));
let sk = sketch(&s);
assert!(sk.curves.is_empty() && sk.points.len() == 1 && sk.constraints.is_empty(), "{:?}", sk.curves);
// The same commands again: every id is new.
let again = made(&mut s, "sketch.line", json!({"points": [[0, 0], [10, 0]]}));
let circle2 = made(&mut s, "sketch.circle.center", json!({"center": [30, 0], "radius": 3}));
let dim2 = run(&mut s, "sketch.dimension", json!({"entities": [strs(&circle2["curves"])[0]], "value": 8}));
let mut fresh: Vec<String> = ["points", "curves"].iter().flat_map(|k| strs(&again[k]).into_iter().chain(strs(&circle2[k]))).collect();
fresh.extend(strs(&dim2["created"]["dimensions"]));
assert_eq!(fresh.len(), gone.len(), "{fresh:?} {gone:?}");
for id in &fresh {
assert!(!gone.contains(id), "id {id} reused: {gone:?} -> {fresh:?}");
}
// Undo of a delete brings the old ids back unchanged (they still name the same geometry).
let line = strs(&again["curves"])[0].clone();
run(&mut s, "sketch.delete", json!({"entities": [line]}));
run(&mut s, "edit.undo", json!({}));
assert!(sketch(&s).curve_index(&line).is_some());
}
/// The example in docs/control-protocol.md.
#[test]
fn documented_example_holds() {
let mut s = new_sketch();
let r = run(&mut s, "sketch.line", json!({"points": [[0, 0], [40, 0]]}));
assert_eq!(r["curves"], json!(["l1"]));
assert_eq!(r["created"], json!({"points": ["l1.start", "l1.end"], "curves": ["l1"], "constraints": ["k1"], "dimensions": []}));
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 50}));
}
#[test]
fn commands_that_add_nothing_report_empty_lists() {
let mut s = new_sketch();
let c = made(&mut s, "sketch.line", json!({"points": [[0, 0], [10, 5]]}));
let r = run(&mut s, "sketch.move_point", json!({"point": strs(&c["points"])[1], "to": [12, 6]}));
assert_eq!(r["created"], json!({"points": [], "curves": [], "constraints": [], "dimensions": []}), "{r}");
}
+2 -2
View File
@@ -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)"),
+11 -11
View File
@@ -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;
+14 -10
View File
@@ -13,41 +13,47 @@ use super::{CommandSpec, in_sketch};
use crate::params::{bad, bool_, str_, vec3};
use crate::{EngineError, Result, Session};
macro_rules! refs_doc {
() => {
"refs: [{edge: [x,y,z], body?} | {face: [x,y,z], body?} (its edge loops) | {body: name} (silhouette) | {vertex: [x,y,z], body?} | {sketch, curve} | {sketch, point} | \"origin\" | {axis: X|Y|Z} | {plane: XY|XZ|YZ|name}]"
};
}
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("sketch.project", "Project", project_cmd).at("SKETCH", "CREATE").icon("project").key("P").enabled(in_sketch).params(REFS_DOC),
CommandSpec::new("sketch.project", "Project", project_cmd).at("SKETCH", "CREATE").icon("project").key("P").enabled(in_sketch).params(concat!(refs_doc!(), created_doc!())),
CommandSpec::new("sketch.intersect", "Intersect", intersect_cmd).at("SKETCH", "CREATE").icon("intersect").enabled(in_sketch).params(
"refs: [{body}, {face: [x,y,z], body?}, {edge: [x,y,z], body?}, {sketch, curve}]: section curves (or points) with the sketch plane, linked",
concat!("refs: [{body}, {face: [x,y,z], body?}, {edge: [x,y,z], body?}, {sketch, curve}]: section curves (or points) with the sketch plane, linked", created_doc!()),
),
CommandSpec::new("sketch.include_3d", "Include 3D Geometry", include_cmd)
.at("SKETCH", "CREATE")
.icon("include")
.enabled(in_sketch)
.params("refs: like Project (edges, vertices, sketch curves and points); curves out of the sketch plane are flattened onto it"),
.params(concat!("refs: like Project (edges, vertices, sketch curves and points); curves out of the sketch plane are flattened onto it", created_doc!())),
CommandSpec::new("sketch.project_to_surface", "Project To Surface", project_to_surface)
.at("SKETCH", "CREATE")
.icon("project_surface")
.enabled(in_sketch)
.params("curves: [{sketch, curve}…] (curves of other sketches), face: [x,y,z] point on the target face, body?: projected along the active sketch's normal onto the face, as linked 3D curves"),
.params(concat!("curves: [{sketch, curve}…] (curves of other sketches), face: [x,y,z] point on the target face, body?: projected along the active sketch's normal onto the face, as linked 3D curves", created_doc!())),
CommandSpec::new("sketch.intersection_curve", "Intersection Curve", intersection_curve)
.at("SKETCH", "CREATE")
.icon("intersection_curve")
.enabled(in_sketch)
.params("a, b: {body} | {face: [x,y,z], body?}: where they meet, as linked 3D curves"),
.params(concat!("a, b: {body} | {face: [x,y,z], body?}: where they meet, as linked 3D curves", created_doc!())),
CommandSpec::new("sketch.spun_profile", "Spun Profile", spun_profile)
.at("SKETCH", "CREATE")
.icon("spun")
.enabled(in_sketch)
.params("body: name, axis: a line of the active sketch (curve id) | X|Y|Z: the body's outline revolved about the axis, in the sketch plane"),
.params(concat!("body: name, axis: a line of the active sketch (curve id) | X|Y|Z: the body's outline revolved about the axis, in the sketch plane", created_doc!())),
CommandSpec::new("sketch.isoparametric_curve", "Isoparametric Curve", iso_curve)
.at("SKETCH", "CREATE")
.icon("iso_curve")
.enabled(in_sketch)
.params("face: [x,y,z] point on the face (the curve passes through it), body?, direction?: u|v|[x,y,z] (default u): linked 3D curve"),
.params(concat!("face: [x,y,z] point on the face (the curve passes through it), body?, direction?: u|v|[x,y,z] (default u): linked 3D curve", created_doc!())),
CommandSpec::new("sketch.fit_curves_to_section", "Fit Curves to Mesh Section", fit_section)
.at("SKETCH", "CREATE")
.icon("fit_section")
.enabled(in_sketch)
.params("body: name (a mesh or solid body): its section with the sketch plane, fitted with lines and arcs, as normal (unlinked) geometry"),
.params(concat!("body: name (a mesh or solid body): its section with the sketch plane, fitted with lines and arcs, as normal (unlinked) geometry", created_doc!())),
CommandSpec::new("sketch.break_link", "Break Link", break_link)
.enabled(in_sketch)
.params("link?: link id (j1) | entities?: [curve or point ids]: linked geometry becomes normal sketch geometry"),
@@ -56,8 +62,6 @@ pub static COMMANDS: &[CommandSpec] = &[
.params("value?: bool (default toggles): project a face's edges when a sketch is created on it"),
];
const REFS_DOC: &str = "refs: [{edge: [x,y,z], body?} | {face: [x,y,z], body?} (its edge loops) | {body: name} (silhouette) | {vertex: [x,y,z], body?} | {sketch, curve} | {sketch, point} | \"origin\" | {axis: X|Y|Z} | {plane: XY|XZ|YZ|name}]";
/// Parse one reference.
fn source(s: &Session, v: &Value, cmd: &str) -> Result<LinkSource> {
if let Some(name) = v.as_str() {
+22
View File
@@ -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;
+72 -2
View File
@@ -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}");
}
}
}
+6 -2
View File
@@ -58,7 +58,10 @@ pub fn tool_definitions() -> Vec<Value> {
{center: [20,15], radius: 5}; sketch.dimension {entities: [curve id], value: \"width\"}; sketch.finish {}; solid.extrude \
{distance: 20, operation: \"cut\"}; solid.fillet {edges: [[0,0,10]], radius: 3}; solid.box {length, \
width, height}. Lengths are mm; expressions may use parameters and units (\"width / 2\", \"30 deg\"). Returns \
the command's result; failures leave the design unchanged.",
the command's result; failures leave the design unchanged. Sketch commands that add geometry return \
created: {points, curves, constraints, dimensions}: the ids of what they added, stable and not reused \
after a delete (undo rewinds the counters with the design), usable right away \
in sketch.dimension, sketch.constraint.* and sketch.delete.",
obj(
json!({
"command": string("Command id (see list_commands)"),
@@ -148,7 +151,8 @@ pub fn tool_definitions() -> Vec<Value> {
"Look at the design",
"Render the design and return it as a PNG image. Headless: a CPU render of the model, fitted to the view. \
Connected to the app: a screenshot of the window (source \"window\", the default) or a render of the model \
with the app's camera (source \"model\"). `view` first turns the camera to a standard view.",
with the app's camera (source \"model\"). Both show an active section (inspect.section) with its cut face \
capped. `view` first turns the camera to a standard view.",
obj(
json!({
"view": {"type": "string", "enum": VIEWS, "description": "Standard view (default: keep the current one; headless starts at iso)"},
+1 -1
View File
@@ -8,4 +8,4 @@ mod camera;
mod raster;
pub use camera::{Camera, CameraAnim, Mat4, Quat, StandardView, UpAxis, ease_in_out};
pub use raster::{Canvas, Rgb, Scene, SceneLine, SceneMesh, png, render, render_png, render_transparent};
pub use raster::{Canvas, Rgb, SECTION_CAP, Scene, SceneLine, SceneMesh, png, render, render_png, render_transparent};
+91 -14
View File
@@ -43,8 +43,13 @@ pub struct Scene {
/// A section cut: (normal, d); what lies where normal·p > d is cut away, and the inside
/// shows as a hatched cap.
pub clip: Option<(Vec3, f64)>,
/// The colour the section cap is tinted toward (default: [`SECTION_CAP`]).
pub cap: Option<Rgb>,
}
/// The section cap's default tint, as in the viewport.
pub const SECTION_CAP: Rgb = Rgb(214, 128, 72);
/// RGBA pixels plus depth.
pub struct Canvas {
pub w: usize,
@@ -105,16 +110,28 @@ pub fn render(scene: &Scene, cam: &Camera, w: usize, h: usize) -> Canvas {
Some([(q[0] / q[3] + 1.0) * 0.5 * sw as f64, (1.0 - q[1] / q[3]) * 0.5 * sh as f64, q[2] / q[3]])
};
let view_dir = cam.back();
// Where the section cap is the visible surface: the depth of the section plane on that
// pixel's view ray. Written into the depth buffer once the solids are drawn, so the cut face
// hides edges inside the body (holes, far corners) instead of showing them like a pane of
// glass, while holes through the cut stay open (as the viewport does with its stencil).
let cap_z = scene.clip.and_then(|(n, d)| cap_plane(cam, n, d, scene.radius.max(1.0), &project));
let mut cap_depth = if cap_z.is_some() { vec![f32::INFINITY; sw * sh] } else { Vec::new() };
let cap_tint = scene.cap.unwrap_or(SECTION_CAP).f();
for sm in &scene.meshes {
let m = &sm.mesh;
let body = sm.color.f();
let pts: Vec<Option<[f64; 3]>> = m.positions.iter().map(|p| project(*p)).collect();
// 1 / clip w per vertex, for perspective-correct interpolation of world positions (so
// the section cuts along the plane, not a curve, in perspective views).
let inv_w: Vec<f64> = m.positions.iter().map(|p| 1.0 / vp.apply(*p)[3].max(1e-9)).collect();
for (ti, t) in m.triangles.iter().enumerate() {
let face = m.tri_face.get(ti).copied();
let base = sm.face_colors.iter().find(|(f, _)| Some(*f) == face).map_or(body, |(_, c)| c.f());
let wp = t.map(|k| m.positions.get(k as usize).copied().unwrap_or_default());
// Seen from inside (only where a section cuts the body open).
let inside = (wp[1] - wp[0]).cross(wp[2] - wp[0]).dot(view_dir) < 0.0;
let to_eye = if cam.fov > 0.0 { cam.eye() - wp[0] } else { view_dir };
let inside = (wp[1] - wp[0]).cross(wp[2] - wp[0]).dot(to_eye) < 0.0;
let iw = t.map(|k| inv_w.get(k as usize).copied().unwrap_or(1.0));
let (Some(Some(a)), Some(Some(b)), Some(Some(cc))) = (pts.get(t[0] as usize), pts.get(t[1] as usize), pts.get(t[2] as usize)) else {
continue;
};
@@ -140,8 +157,11 @@ pub fn render(scene: &Scene, cam: &Camera, w: usize, h: usize) -> Canvas {
if w0 < -1e-9 || w1 < -1e-9 || w2 < -1e-9 {
continue;
}
let (q0, q1, q2) = (w0 * iw[0], w1 * iw[1], w2 * iw[2]);
let qs = q0 + q1 + q2;
let (p0, p1, p2) = if qs > 1e-12 { (q0 / qs, q1 / qs, q2 / qs) } else { (w0, w1, w2) };
if let Some((cn, cd)) = scene.clip
&& cn.dot(wp[0] * w0 + wp[1] * w1 + wp[2] * w2) > cd
&& cn.dot(wp[0] * p0 + wp[1] * p1 + wp[2] * p2) > cd
{
continue;
}
@@ -151,11 +171,20 @@ pub fn render(scene: &Scene, cam: &Camera, w: usize, h: usize) -> Canvas {
if let Some(d) = c.depth.get_mut(i) {
*d = z;
}
let n = (ns[0] * w0 + ns[1] * w1 + ns[2] * w2).normalized().unwrap_or(Vec3::Z);
if scene.clip.is_some() && inside {
// The cut: the body's colour, hatched at 45 degrees.
let k = if ((x + y) / SS) % 9 < 2 { 0.5 } else { 0.8 };
c.put(x, y, base.map(|v| v * k), 1.0);
let n = (ns[0] * p0 + ns[1] * p1 + ns[2] * p2).normalized().unwrap_or(Vec3::Z);
let cut = scene.clip.is_some() && inside;
if let Some(d) = cap_depth.get_mut(i) {
*d = match cap_z {
Some([a, b, k]) if cut => ((a * px + b * py + k) as f32).min(z),
_ => f32::INFINITY,
};
}
if cut {
// The cut: the body's colour tinted toward the cap colour, hatched
// at 45 degrees (as the viewport draws it).
let tinted: [f64; 3] = std::array::from_fn(|k| base[k] + (cap_tint[k] - base[k]) * 0.35);
let k = if ((x + y) / SS) % 9 < 2 { 0.6 } else { 1.0 };
c.put(x, y, tinted.map(|v| v * k), 1.0);
continue;
}
let mut col = shade(n, view_dir, base);
@@ -172,21 +201,24 @@ pub fn render(scene: &Scene, cam: &Camera, w: usize, h: usize) -> Canvas {
}
}
}
for (d, cz) in c.depth.iter_mut().zip(&cap_depth) {
if cz.is_finite() {
*d = *cz;
}
}
for l in &scene.lines {
let col = l.color.f();
let half = (l.width * SS as f64 * 0.5).max(0.5);
for seg in l.points.windows(2) {
let (Some(p), Some(q)) = (seg.first(), seg.get(1)) else { continue };
let (Some(a), Some(b)) = (project(*p), project(*q)) else { continue };
let (Some(&p), Some(&q)) = (seg.first(), seg.get(1)) else { continue };
// The section cuts the segment in 3D (screen space is not linear in depth in
// perspective views).
let Some((p, q)) = clip_segment(p, q, scene.clip) else { continue };
let (Some(a), Some(b)) = (project(p), project(q)) else { continue };
let len = ((b[0] - a[0]).powi(2) + (b[1] - a[1]).powi(2)).sqrt();
let steps = (len.ceil() as usize).clamp(1, 20_000);
for k in 0..=steps {
let t = k as f64 / steps as f64;
if let Some((cn, cd)) = scene.clip
&& cn.dot(*p + (*q - *p) * t) > cd + 1e-6
{
continue;
}
let (x, y, z) = (a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t);
let r = half.ceil() as i64;
for dy in -r..=r {
@@ -234,6 +266,39 @@ pub fn render(scene: &Scene, cam: &Camera, w: usize, h: usize) -> Canvas {
out
}
/// The part of segment `p`–`q` the section keeps (where normal·x <= d).
fn clip_segment(p: Vec3, q: Vec3, clip: Option<(Vec3, f64)>) -> Option<(Vec3, Vec3)> {
let Some((n, d)) = clip else { return Some((p, q)) };
let (dp, dq) = (n.dot(p) - d - 1e-6, n.dot(q) - d - 1e-6);
match (dp > 0.0, dq > 0.0) {
(true, true) => None,
(false, false) => Some((p, q)),
(true, false) => Some((p + (q - p) * (dp / (dp - dq)), q)),
(false, true) => Some((p, p + (q - p) * (dp / (dp - dq)))),
}
}
/// Screen depth of the section plane `n·p = d` as `z = a·x + b·y + k` in canvas pixels (a plane
/// stays a plane under projection), set a hair behind the plane so the cut's own outline stays
/// drawn. None when the plane is seen edge-on or lies behind the eye.
fn cap_plane(cam: &Camera, n: Vec3, d: f64, radius: f64, project: &dyn Fn(Vec3) -> Option<[f64; 3]>) -> Option<[f64; 3]> {
let margin = 1e-3 * (1.0 + radius);
let o = cam.target - n * (n.dot(cam.target) - d - margin);
let (u, v) = (n.any_perp(), n.cross(n.any_perp()));
let s = radius * 0.1;
let (p0, p1, p2) = (project(o)?, project(o + u * s)?, project(o + v * s)?);
let (ax, ay, az) = (p1[0] - p0[0], p1[1] - p0[1], p1[2] - p0[2]);
let (bx, by, bz) = (p2[0] - p0[0], p2[1] - p0[1], p2[2] - p0[2]);
let det = ax * by - ay * bx;
if det.abs() < 1e-9 {
return None;
}
let a = (az * by - ay * bz) / det;
let b = (ax * bz - az * bx) / det;
let k = p0[2] - a * p0[0] - b * p0[1];
[a, b, k].iter().all(|x| x.is_finite()).then_some([a, b, k])
}
/// Render to PNG bytes.
pub fn render_png(scene: &Scene, cam: &Camera, w: usize, h: usize) -> Option<Vec<u8>> {
let c = render(scene, cam, w, h);
@@ -274,6 +339,17 @@ mod tests {
use super::*;
use crate::StandardView;
/// Lines are cut by the section in 3D: the kept piece ends on the plane.
#[test]
fn section_clips_lines_at_the_plane() {
let clip = Some((Vec3::X, 2.0));
let (p, q) = clip_segment(Vec3::ZERO, Vec3::new(10.0, 4.0, 0.0), clip).unwrap();
assert_eq!(p, Vec3::ZERO);
assert!((q.x - 2.0).abs() < 1e-5 && (q.y - 0.8).abs() < 1e-5, "{q:?}");
assert!(clip_segment(Vec3::new(3.0, 0.0, 0.0), Vec3::new(5.0, 0.0, 0.0), clip).is_none());
assert!(clip_segment(Vec3::new(3.0, 0.0, 0.0), Vec3::new(5.0, 0.0, 0.0), None).is_some());
}
#[test]
fn renders_a_triangle() {
let mesh = Mesh {
@@ -291,6 +367,7 @@ mod tests {
background: None,
radius: 20.0,
clip: None,
cap: None,
};
let mut cam = Camera { distance: 50.0, ..Default::default() };
cam.set_view(StandardView::Front);
+1
View File
@@ -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 }
+5 -4
View File
@@ -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 {
+8 -5
View File
@@ -1385,7 +1385,8 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
painter.circle_filled(c, 3.0, t.text_dim);
}
for (k, item) in radial.iter().enumerate() {
let galley = painter.layout_no_wrap(item.label.clone(), font.clone(), if item.enabled { t.text } else { t.text_dim });
let display_label = crate::i18n::tr_label(&item.label);
let galley = painter.layout_no_wrap(display_label.to_string(), font.clone(), if item.enabled { t.text } else { t.text_dim });
let size = vec2(galley.size().x + 40.0, 28.0);
let rr = slot_rect(c, k, size);
let resp = ui.interact(rr, ui.id().with(("radial", k)), Sense::click());
@@ -1407,7 +1408,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
picked = Some(item.clone());
}
if item.enabled {
resp.on_hover_text(item.shortcut.clone());
resp.on_hover_text(crate::i18n::tr_label(&item.shortcut).to_string());
}
}
});
@@ -1501,7 +1502,7 @@ pub fn list_row_lit(ui: &mut egui::Ui, item: &Item, lit: bool) -> Option<Item> {
return None;
}
if item.is_heading() {
let text = ui.painter().layout(item.label.to_uppercase(), FontId::proportional(10.5), t.text_dim, w - 16.0);
let text = ui.painter().layout(crate::i18n::tr_label(&item.label).to_uppercase(), FontId::proportional(10.5), t.text_dim, w - 16.0);
let (r, _) = ui.allocate_exact_size(vec2(w, (text.size().y + 8.0).max(20.0)), Sense::hover());
ui.painter().galley(pos2(r.left() + 8.0, r.top() + 4.0), text, t.text_dim);
return None;
@@ -1522,9 +1523,11 @@ pub fn list_row_lit(ui: &mut egui::Ui, item: &Item, lit: bool) -> Option<Item> {
);
}
let text = if item.enabled { t.text } else { t.text_dim.gamma_multiply(0.75) };
ui.painter().text(pos2(r.left() + 32.0, r.center().y), Align2::LEFT_CENTER, &item.label, FontId::proportional(13.0), text);
let display_label = crate::i18n::tr_label(&item.label);
ui.painter().text(pos2(r.left() + 32.0, r.center().y), Align2::LEFT_CENTER, display_label, FontId::proportional(13.0), text);
if !item.shortcut.is_empty() {
ui.painter().text(pos2(r.right() - 10.0, r.center().y), Align2::RIGHT_CENTER, &item.shortcut, FontId::proportional(11.5), t.text_dim);
let display_shortcut = crate::i18n::tr_label(&item.shortcut);
ui.painter().text(pos2(r.right() - 10.0, r.center().y), Align2::RIGHT_CENTER, display_shortcut, FontId::proportional(11.5), t.text_dim);
}
(item.enabled && resp.clicked()).then(|| item.clone())
}
+47 -2
View File
@@ -15,7 +15,8 @@
//! - `ui.click {x, y, button?, shift?}`, `ui.move {x, y}`, `ui.scroll {x, y, delta}`: real
//! pointer input in screen points; `ui.key {key, cmd?, shift?}`, `ui.text {text}`
//! - `ui.screenshot {path?}`: PNG of the window; `ui.render {path, width?, height?}`: headless
//! CPU render of the model with the current camera (no window needed)
//! CPU render of the model with the current camera and the section the viewport shows (no
//! window needed)
//! - `ui.menu {x?, y?, target?, close?}`: open a context menu at a point (the viewport's marking
//! menu, or a browser menu with `target: {type: body|sketch|component, …}`); without a point,
//! the open menu and its items. `ui.menuPick {item}` runs an item by id or label;
@@ -523,7 +524,9 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) ->
"ui.render" => {
let w = f("width").unwrap_or(1280.0).clamp(16.0, 8192.0) as usize;
let h = f("height").unwrap_or(800.0).clamp(16.0, 8192.0) as usize;
let scene = solvecraft_engine::view::scene(&app.session, &app.cam);
// Cut by the section the viewport shows (an open Section Analysis dialog's preview,
// a sketch's Slice), not only the committed one.
let scene = solvecraft_engine::view::scene_with_section(&app.session, &app.cam, crate::viewport::section_plane(app));
let Some(png) = render_png(&scene, &app.cam, w, h) else { return err("render failed") };
match s("path") {
Some(path) => match std::fs::write(path, &png) {
@@ -563,3 +566,45 @@ pub fn save_screenshot(image: &egui::ColorImage, path: Option<&str>) -> Value {
Err(e) => json!({"ok": false, "error": e.to_string()}),
}
}
#[cfg(test)]
mod tests {
use serde_json::json;
use solvecraft_engine::{Sel, Session};
use super::*;
use crate::Services;
fn render(app: &mut SolveApp, path: &std::path::Path) -> Vec<u8> {
let (req, _rx) = ControlRequest::new("ui.render", json!({"path": path.to_string_lossy(), "width": 200, "height": 140}));
match handle(app, &egui::Context::default(), &req) {
Outcome::Done(v) => assert_eq!(v["ok"], true, "{v}"),
Outcome::Screenshot { .. } => panic!("ui.render took a screenshot"),
}
std::fs::read(path).unwrap()
}
/// A model render shows the section the window shows: the open Section Analysis dialog's
/// preview too, not only a committed `inspect.section`.
#[test]
fn model_render_shows_the_section_dialog_preview() {
let mut s = Session::default();
s.execute("solid.box", &json!({"length": 40, "width": 30, "height": 20})).unwrap();
let mut app = SolveApp::new(s, Services::default());
let dir = std::env::temp_dir().join(format!("solvecraft-render-section-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let whole = render(&mut app, &dir.join("whole.png"));
app.session.selection = vec![Sel::Plane { name: "XZ".into() }];
app.start("inspect.section");
let Some(crate::dialogs::Kind::Section { offset, .. }) = app.dialog.as_mut().map(|d| &mut d.kind) else { panic!("no section dialog") };
*offset = "15 mm".into();
assert!(app.session.section.is_none() && crate::viewport::section_plane(&app).is_some());
let preview = render(&mut app, &dir.join("preview.png"));
assert!(whole != preview, "the model render ignores the section the window shows");
// Committed, it renders the same.
app.run("inspect.section", json!({"plane": "XZ", "offset": 15})).unwrap();
app.dialog = None;
assert!(render(&mut app, &dir.join("committed.png")) == preview, "the committed section renders differently");
let _ = std::fs::remove_dir_all(&dir);
}
}
+34 -13
View File
@@ -775,9 +775,10 @@ pub(crate) fn field(ui: &mut egui::Ui, s: &mut String) -> bool {
pub(crate) fn combo(ui: &mut egui::Ui, id: &str, labels: &[&str], sel: &mut usize) {
let row = ROW.with(|r| r.borrow().clone());
let r = egui::ComboBox::from_id_salt(id).selected_text(labels.get(*sel).copied().unwrap_or("")).width(FIELD_W).show_ui(ui, |ui| {
let selected = labels.get(*sel).copied().map(tr).unwrap_or("");
let r = egui::ComboBox::from_id_salt(id).selected_text(selected).width(FIELD_W).show_ui(ui, |ui| {
for (i, l) in labels.iter().enumerate() {
let item = ui.selectable_value(sel, i, *l);
let item = ui.selectable_value(sel, i, tr(l));
crate::scenario::publish_handle(&format!("dialog:{row}:{l}"), item.rect.center());
}
});
@@ -789,6 +790,10 @@ thread_local! {
static ROW: std::cell::RefCell<String> = const { std::cell::RefCell::new(String::new()) };
}
fn tr(text: &str) -> &str {
crate::i18n::tr_label(text)
}
/// A dialog row's widget, for scenarios and demos: the handle `dialog:<row label>`.
fn publish_row(r: &egui::Response) {
ROW.with(|row| crate::scenario::publish_handle(&format!("dialog:{}", row.borrow()), r.rect.center()));
@@ -878,11 +883,12 @@ const LABEL_MAX_W: f32 = 150.0;
/// A row label in the dialog's label column (the grid sizes the column to its widest label).
pub(crate) fn row_label(ui: &mut egui::Ui, text: &str) {
ROW.with(|r| *r.borrow_mut() = text.to_string());
let full = egui::WidgetText::from(text).into_galley(ui, Some(egui::TextWrapMode::Extend), f32::INFINITY, egui::TextStyle::Body).size().x;
let display_text = tr(text);
let full = egui::WidgetText::from(display_text).into_galley(ui, Some(egui::TextWrapMode::Extend), f32::INFINITY, egui::TextStyle::Body).size().x;
let w = full.clamp(LABEL_W, LABEL_MAX_W);
ui.allocate_ui_with_layout(vec2(w, 22.0), egui::Layout::left_to_right(egui::Align::Center), |ui| {
ui.set_width(w);
ui.add(egui::Label::new(text).wrap());
ui.add(egui::Label::new(display_text).wrap());
});
}
@@ -1057,9 +1063,14 @@ fn input_rows(d: &mut Dialog, ui: &mut egui::Ui) {
let custom_axis = i == 1 && inp.items.is_empty() && matches!(d.kind, Kind::Revolve { axis: Some(_), .. });
let filled = !inp.items.is_empty() || custom_axis;
let text = if filled {
RichText::new(if custom_axis { "↖ Custom Axis".into() } else { format!("↖ {} selected", inp.items.len()) }).color(Color32::WHITE)
RichText::new(if custom_axis {
crate::i18n::tr("dialog.custom_axis").into()
} else {
crate::i18n::format("dialog.selected_count", &[("count", &inp.items.len().to_string())])
})
.color(Color32::WHITE)
} else {
RichText::new("↖ Select").color(t.text)
RichText::new(crate::i18n::tr("dialog.select")).color(t.text)
};
let stroke = if active { Stroke::new(1.5, t.accent) } else { Stroke::new(1.0, t.dialog_border.gamma_multiply(1.6)) };
// Not in the Tab order: Tab moves between values.
@@ -1255,6 +1266,7 @@ fn auto_operation(app: &SolveApp, d: &mut Dialog) {
/// the view cube, sized to its content.
pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
let Some(mut d) = app.dialog.take() else { return };
let _language = crate::i18n::scope(&app.preferences.language);
auto_operation(app, &mut d);
update_measure(app, &mut d);
update_fill_cells(app, &mut d);
@@ -1267,7 +1279,8 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
let mut cancel = false;
let mut enter = false;
let wide = d.is_wide();
let heading = if d.editing.is_some() { format!("EDIT {}", title(&d.kind)) } else { title(&d.kind).to_string() };
let raw_title = title(&d.kind);
let heading = if d.editing.is_some() { crate::i18n::format("dialog.edit_title", &[("title", tr(raw_title))]) } else { tr(raw_title).to_string() };
let frame = egui::Frame::window(&ctx.global_style())
.fill(t.dialog_bg)
.stroke(Stroke::new(1.0, t.dialog_border))
@@ -1917,21 +1930,29 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
egui::Spinner::new().size(14.0).paint_at(ui, sr);
sresp.on_hover_text("Updating the preview");
}
let buttons = if has_ok { 64.0 * 2.0 + ui.spacing().item_spacing.x } else { 64.0 };
let raw_label = if matches!(d.kind, Kind::ConfirmDelete { .. }) { "Delete" } else { "OK" };
let label = tr(raw_label);
let raw_close = if matches!(d.kind, Kind::Params { .. } | Kind::Measure { .. }) { "Close" } else { "Cancel" };
let close = tr(raw_close);
let button_width = |text: &str| {
let galley = egui::WidgetText::from(text).into_galley(ui, Some(egui::TextWrapMode::Extend), f32::INFINITY, egui::TextStyle::Button);
(galley.size().x + 2.0 * ui.spacing().button_padding.x).max(64.0)
};
let (ok_width, close_width) = (button_width(label), button_width(close));
let buttons = close_width + if has_ok { ok_width + ui.spacing().item_spacing.x } else { 0.0 };
crate::frame::right_aligned(ui, total, buttons, |ui| {
if has_ok {
let label = if matches!(d.kind, Kind::ConfirmDelete { .. }) { "Delete" } else { "OK" };
let b = egui::Button::new(RichText::new(label).color(if valid { Color32::WHITE } else { t.text_dim }))
.fill(if valid { t.accent } else { Color32::TRANSPARENT })
.min_size(vec2(64.0, 24.0));
.wrap_mode(egui::TextWrapMode::Extend)
.min_size(vec2(ok_width, 24.0));
let r = ui.add_enabled(valid, b);
crate::scenario::publish_handle(&format!("dialog:{label}"), r.rect.center());
crate::scenario::publish_handle(&format!("dialog:{raw_label}"), r.rect.center());
if r.clicked() {
ok = true;
}
}
let close = if matches!(d.kind, Kind::Params { .. } | Kind::Measure { .. }) { "Close" } else { "Cancel" };
if ui.add(egui::Button::new(close).min_size(vec2(64.0, 24.0))).clicked() {
if ui.add(egui::Button::new(close).wrap_mode(egui::TextWrapMode::Extend).min_size(vec2(close_width, 24.0))).clicked() {
cancel = true;
}
});
+18 -7
View File
@@ -311,7 +311,14 @@ pub fn tabs(app: &mut SolveApp, ui: &mut egui::Ui, r: Rect, x0: f32, x1: f32) {
t.app_bar
},
);
ui.painter().text(home.center(), Align2::CENTER_CENTER, "Home", FontId::proportional(13.0), if app.home.open { t.text } else { t.app_bar_text });
let home_text = app.tr("ui.home");
ui.painter().text(
home.center(),
Align2::CENTER_CENTER,
home_text,
FontId::proportional(13.0),
if app.home.open { t.text } else { t.app_bar_text },
);
crate::scenario::publish_handle("tab:home", home.center());
if response.clicked() {
app.home.show(app.session.revision);
@@ -346,7 +353,8 @@ pub fn tabs(app: &mut SolveApp, ui: &mut egui::Ui, r: Rect, x0: f32, x1: f32) {
};
ui.painter().rect_filled(tr, egui::CornerRadius { nw: 5, ne: 5, sw: 0, se: 0 }, fill);
let ink = if active { t.text } else { t.app_bar_text };
let label = format!("{name}{}", if *dirty { " •" } else { "" });
let name_display = if name == "Untitled" { app.tr("ui.untitled") } else { name };
let label = format!("{name_display}{}", if *dirty { " •" } else { "" });
let g = ui.painter().layout_no_wrap(label, FontId::proportional(13.0), ink);
let text_w = (tr.width() - 30.0).max(10.0);
let clip = Rect::from_min_size(tr.min, vec2(text_w + 10.0, tr.height()));
@@ -448,26 +456,29 @@ pub enum Answer {
/// The "save changes?" prompt for a tab being closed, or for each unsaved design when quitting.
pub fn prompt(app: &mut SolveApp, ctx: &egui::Context) {
let _language = crate::i18n::scope(&app.preferences.language);
quit_step(app);
let Some(i) = app.docs.closing else { return };
let name = tabs_info(app).get(i).map(|x| x.0.clone()).unwrap_or_default();
let name_display = if name == "Untitled" { crate::i18n::tr("ui.untitled") } else { &name };
let mut answer: Option<Answer> = None;
crate::frame::window(ctx, format!("Save changes to {name}?"), crate::frame::Width::Normal)
let title = crate::i18n::format("document.save_changes", &[("name", name_display)]);
crate::frame::window(ctx, title, crate::frame::Width::Normal)
.id(egui::Id::new("sc_close_prompt"))
.collapsible(false)
.resizable(false)
.anchor(Align2::CENTER_CENTER, vec2(0.0, 0.0))
.show(ctx, |ui| {
ui.label(format!("{name} has unsaved changes. If you don't save, they are lost."));
ui.label(crate::i18n::format("document.unsaved_changes", &[("name", name_display)]));
ui.add_space(8.0);
ui.horizontal(|ui| {
if ui.button("Save").clicked() {
if ui.button(crate::i18n::tr("ui.save")).clicked() {
answer = Some(Answer::Save);
}
if ui.button("Don't Save").clicked() {
if ui.button(crate::i18n::tr("ui.don_t_save")).clicked() {
answer = Some(Answer::DontSave);
}
if ui.button("Cancel").clicked() {
if ui.button(crate::i18n::tr("ui.cancel")).clicked() {
answer = Some(Answer::Cancel);
}
});
+56
View File
@@ -0,0 +1,56 @@
//! System font loading and fallback setup for multi-language (CJK/Japanese) support.
#[cfg(not(target_arch = "wasm32"))]
use std::sync::Arc;
/// Candidate font paths to look for on different operating systems.
#[cfg(not(target_arch = "wasm32"))]
const FONT_CANDIDATES: &[&str] = &[
// Windows
"C:\\Windows\\Fonts\\meiryo.ttc",
"C:\\Windows\\Fonts\\YuGothM.ttc",
"C:\\Windows\\Fonts\\YuGothR.ttc",
"C:\\Windows\\Fonts\\msgothic.ttc",
"C:\\Windows\\Fonts\\BIZ-UDGothicR.ttc",
// macOS
"/System/Library/Fonts/ヒラギノ角ゴシック W3.ttc",
"/System/Library/Fonts/Hiragino Sans GB.ttc",
"/System/Library/Fonts/PingFang.ttc",
"/Library/Fonts/Arial Unicode.ttf",
// Linux
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/truetype/droid/DroidSansFallbackFull.ttf",
];
/// Configure font definitions with system CJK fallback if available.
#[cfg(not(target_arch = "wasm32"))]
pub fn setup_fonts(ctx: &egui::Context) {
let mut font_bytes: Option<Vec<u8>> = None;
for path in FONT_CANDIDATES {
if let Ok(bytes) = std::fs::read(path)
&& ttf_parser::Face::parse(&bytes, 0).is_ok()
{
font_bytes = Some(bytes);
break;
}
}
let Some(bytes) = font_bytes else {
return;
};
let mut fonts = egui::FontDefinitions::default();
fonts.font_data.insert("cjk_fallback".to_owned(), Arc::new(egui::FontData::from_owned(bytes)));
fonts.families.entry(egui::FontFamily::Proportional).or_default().push("cjk_fallback".to_owned());
fonts.families.entry(egui::FontFamily::Monospace).or_default().push("cjk_fallback".to_owned());
ctx.set_fonts(fonts);
}
/// Browsers cannot read system font files. A web host can supply licensed fonts through egui.
#[cfg(target_arch = "wasm32")]
pub fn setup_fonts(_ctx: &egui::Context) {}
+266
View File
@@ -0,0 +1,266 @@
//! Embedded offline message catalogs. UI messages use stable IDs; engine display labels
//! pass through an adapter without changing engine or scenario identifiers.
use std::cell::Cell;
use std::collections::BTreeMap;
use std::sync::OnceLock;
type Messages = BTreeMap<String, String>;
/// Register locales here after adding their catalogs. Missing translations use English.
pub struct Catalog {
pub id: &'static str,
pub name: &'static str,
source: &'static str,
messages: OnceLock<Messages>,
}
impl Catalog {
const fn new(id: &'static str, name: &'static str, source: &'static str) -> Self {
Self { id, name, source, messages: OnceLock::new() }
}
fn messages(&'static self) -> &'static Messages {
self.messages.get_or_init(|| match serde_json::from_str(self.source) {
Ok(messages) => messages,
Err(error) => {
log::error!("invalid embedded {} message catalog: {error}", self.id);
Messages::new()
}
})
}
}
static ENGLISH: Catalog = Catalog::new("en", "English", include_str!("locales/en.json"));
static JAPANESE: Catalog = Catalog::new("ja", "日本語", include_str!("locales/ja.json"));
pub const LOCALES: &[&Catalog] = &[&ENGLISH, &JAPANESE];
pub fn is_supported(id: &str) -> bool {
LOCALES.iter().any(|catalog| catalog.id == id)
}
/// Immutable locale reference. Catalogs parse once; lookups are logarithmic.
#[derive(Clone, Copy)]
pub struct Locale(&'static Catalog);
impl Locale {
pub fn for_language(id: &str) -> Self {
Self(LOCALES.iter().copied().find(|catalog| catalog.id == id).unwrap_or(&ENGLISH))
}
pub fn name(self) -> &'static str {
self.0.name
}
/// Stable message ID → translation → English → ID.
pub fn text(self, id: &str) -> &str {
self.0.messages().get(id).or_else(|| ENGLISH.messages().get(id)).map_or(id, String::as_str)
}
/// Translate source labels from engine/UI tables, preserving unknown labels.
/// Also accepts stable IDs while table helpers migrate away from source text.
pub fn label(self, label: &str) -> &str {
static LABEL_IDS: OnceLock<BTreeMap<&'static str, &'static str>> = OnceLock::new();
let ids = LABEL_IDS.get_or_init(|| {
ENGLISH.messages().iter().filter(|(_, text)| !text.contains('{')).map(|(id, text)| (text.as_str(), id.as_str())).collect()
});
ids.get(label).map_or_else(|| self.text(label), |id| self.text(id))
}
pub fn format(self, id: &str, args: &[(&str, &str)]) -> String {
interpolate(self.text(id), args)
}
/// Translate generated browser names and labels, preserving custom names otherwise.
pub fn dynamic(self, text: &str) -> String {
for (prefix, id) in [("Sketch", "browser.sketch_name"), ("Body", "browser.body_name"), ("Component", "browser.component_name")] {
if let Some(number) = text.strip_prefix(prefix)
&& !number.is_empty()
&& number.chars().all(|c| c.is_ascii_digit())
{
return self.format(id, &[("number", number)]);
}
}
for (prefix, id, arg) in [("Units: ", "browser.units", "units"), ("Precision: ", "browser.precision", "precision")] {
if let Some(value) = text.strip_prefix(prefix) {
return self.format(id, &[(arg, value)]);
}
}
if let Some(count) = text.strip_prefix("Parameters (").and_then(|s| s.strip_suffix(')')) {
return self.format("browser.parameters", &[("count", count)]);
}
if let Some(count) = text.strip_suffix(" degrees of freedom")
&& !count.is_empty()
&& count.chars().all(|c| c.is_ascii_digit())
{
return self.format("sketch.degrees_of_freedom", &[("count", count)]);
}
self.label(text).to_string()
}
}
/// One-pass substitution: argument values are literal, even when they contain braces.
/// Missing arguments remain visible rather than crashing or silently removing text.
fn interpolate(template: &str, args: &[(&str, &str)]) -> String {
let mut output = String::with_capacity(template.len());
let mut remaining = template;
while let Some((before, after)) = remaining.split_once('{') {
output.push_str(before);
let Some((name, rest)) = after.split_once('}') else {
output.push('{');
output.push_str(after);
return output;
};
if let Some((_, value)) = args.iter().find(|(key, _)| *key == name) {
output.push_str(value);
} else {
output.push('{');
output.push_str(name);
output.push('}');
}
remaining = rest;
}
output.push_str(remaining);
output
}
thread_local! {
// Helpers without an app reference share a render scope. Nested renders restore
// their caller's locale; no owned language strings or persistent global setting.
static CURRENT: Cell<Locale> = const { Cell::new(Locale(&ENGLISH)) };
}
#[must_use]
pub struct LanguageScope {
previous: Locale,
// Drop on the thread where the scope was entered.
_thread: std::marker::PhantomData<std::rc::Rc<()>>,
}
pub fn scope(id: &str) -> LanguageScope {
let previous = CURRENT.with(|current| current.replace(Locale::for_language(id)));
LanguageScope { previous, _thread: std::marker::PhantomData }
}
impl Drop for LanguageScope {
fn drop(&mut self) {
CURRENT.with(|current| current.set(self.previous));
}
}
pub fn tr(id: &str) -> &str {
CURRENT.with(|current| current.get().text(id))
}
pub fn tr_label(label: &str) -> &str {
CURRENT.with(|current| current.get().label(label))
}
pub fn format(id: &str, args: &[(&str, &str)]) -> String {
CURRENT.with(|current| current.get().format(id, args))
}
pub fn tr_dyn(text: &str) -> String {
CURRENT.with(|current| current.get().dynamic(text))
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeSet;
fn placeholders(text: &str) -> BTreeSet<&str> {
let mut result = BTreeSet::new();
let mut rest = text;
while let Some((_, after)) = rest.split_once('{') {
let (name, remaining) = after.split_once('}').expect("unclosed placeholder");
assert!(!name.is_empty() && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_'));
result.insert(name);
rest = remaining;
}
result
}
#[test]
fn catalogs_have_valid_ids_and_matching_placeholders() {
let english: Messages = serde_json::from_str(ENGLISH.source).unwrap();
let mut ids = BTreeSet::new();
for catalog in LOCALES {
assert!(ids.insert(catalog.id), "duplicate locale");
let messages: Messages = serde_json::from_str(catalog.source).unwrap();
assert!(!messages.is_empty());
for (id, text) in messages {
let original = english.get(&id).expect("unknown message ID");
assert_eq!(placeholders(&text), placeholders(original), "{}: {id}", catalog.id);
}
}
}
#[test]
fn direct_ui_message_ids_exist_in_english_catalog() {
// Catch a misspelled key before it is shown verbatim in the UI. Engine-table labels
// deliberately use the separate label adapter and fall back to their source text.
for source in [
include_str!("toolbar.rs"),
include_str!("prefs.rs"),
include_str!("documents.rs"),
include_str!("dialogs.rs"),
include_str!("browser.rs"),
include_str!("sketch_palette.rs"),
] {
for call in [".tr(\"", "i18n::tr(\"", "i18n::format(\""] {
for tail in source.split(call).skip(1) {
let id = tail.split('"').next().unwrap();
assert!(ENGLISH.messages().contains_key(id), "missing English message: {id}");
}
}
}
}
#[test]
fn keys_and_engine_labels_translate_with_english_fallback() {
let ja = Locale::for_language("ja");
assert_eq!(ja.text("ui.preferences"), "環境設定");
assert_eq!(ja.label("Extrude"), "押し出し");
assert_eq!(ja.label("CREATE SKETCH"), "スケッチを作成");
assert_eq!(ja.label("NonExistentKey"), "NonExistentKey");
assert_eq!(Locale::for_language("unknown").text("ui.preferences"), "Preferences");
static PARTIAL: Catalog = Catalog::new("test", "Test", r#"{"ui.preferences":"Prefs"}"#);
assert_eq!(Locale(&PARTIAL).text("ui.preferences"), "Prefs");
assert_eq!(Locale(&PARTIAL).text("ui.extrude"), "Extrude");
}
#[test]
fn templates_allow_reordering_and_literal_braces_in_values() {
assert_eq!(interpolate("{second} then {first}", &[("first", "{second}"), ("second", "B")]), "B then {second}");
assert_eq!(interpolate("{missing} / {", &[]), "{missing} / {");
assert_eq!(Locale::for_language("ja").format("document.save_changes", &[("name", "My {design}")]), "My {design} への変更を保存しますか?");
assert_eq!(Locale::for_language("en").format("dialog.selected_count", &[("count", "2")]), "↖ 2 selected");
}
#[test]
fn browser_patterns_preserve_numbers_and_custom_names() {
let ja = Locale::for_language("ja");
assert_eq!(ja.dynamic("Sketch1"), "スケッチ1");
assert_eq!(ja.dynamic("Body2"), "ボディ2");
assert_eq!(ja.dynamic("Component12"), "コンポーネント12");
assert_eq!(ja.dynamic("Units: mm"), "単位: mm");
assert_eq!(ja.dynamic("Precision: 3"), "精度: 3");
assert_eq!(ja.dynamic("Parameters (5)"), "パラメータ (5)");
assert_eq!(ja.dynamic("4 degrees of freedom"), "自由度 4");
assert_eq!(ja.dynamic("Sketch of bracket"), "Sketch of bracket");
assert_eq!(Locale::for_language("en").dynamic("Sketch1"), "Sketch1");
}
#[test]
fn render_scopes_restore_the_previous_language() {
let _english = scope("en");
{
let _japanese = scope("ja");
assert_eq!(tr("ui.preferences"), "環境設定");
{
let _nested = scope("en");
assert_eq!(tr("ui.preferences"), "Preferences");
}
assert_eq!(tr("ui.preferences"), "環境設定");
}
assert_eq!(tr("ui.preferences"), "Preferences");
}
}
+57
View File
@@ -0,0 +1,57 @@
//! Localization must not change scenario handles or leave the next app in another locale.
use crate::scenario::{self, Harness};
use serde_json::json;
#[test]
fn japanese_dialog_keeps_english_scenario_handles() {
let mut h = Harness::new();
h.app.preferences.language = "ja".into();
h.app.home.open = false;
h.call("ui.start", json!({"command":"sketch.create"}));
h.frames(3);
assert!(h.widget("平面").is_some(), "the input label must be translated");
assert!(h.widget("キャンセル").is_some(), "the close button must be translated");
assert!(!scenario::handles("dialog:Plane").is_empty(), "scenario handles must retain source labels");
assert_eq!(crate::i18n::tr("ui.preferences"), "Preferences", "the render scope must restore English");
h.app.preferences.language = "en".into();
h.frames(3);
assert!(h.widget("Plane").is_some());
assert!(h.widget("Cancel").is_some());
}
#[test]
fn language_preference_cancel_restores_previous_locale() {
let mut h = Harness::new();
h.app.home.open = false;
h.call("ui.prefs", json!({"open":"General"}));
h.call("ui.prefs", json!({"language":"ja"}));
assert!(h.widget("言語").is_some());
assert_eq!(h.app.preferences.language, "ja");
h.call("ui.prefs", json!({"button":"cancel"}));
assert_eq!(h.app.preferences.language, "en");
assert_eq!(h.app.tr("ui.preferences"), "Preferences");
}
#[test]
fn translated_dialog_buttons_do_not_wrap() {
fn cancel_rows(shape: &egui::Shape) -> Option<usize> {
match shape {
egui::Shape::Text(text) if text.galley.job.text == "キャンセル" => Some(text.galley.rows.len()),
egui::Shape::Vec(shapes) => shapes.iter().find_map(cancel_rows),
_ => None,
}
}
let mut h = Harness::new();
crate::fonts::setup_fonts(&h.ctx);
h.app.preferences.language = "ja".into();
h.app.home.open = false;
h.call("ui.start", json!({"command":"solid.extrude"}));
let mut out = h.ctx.run_ui(
egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 1000.0))), ..Default::default() },
|ui| h.app.ui(ui),
);
assert_eq!(out.shapes.iter().find_map(|shape| cancel_rows(&shape.shape)), Some(1));
out.textures_delta.clear();
}
+36
View File
@@ -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());
+296
View File
@@ -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"
}
+296
View File
@@ -0,0 +1,296 @@
{
"browser.body_name": "ボディ{number}",
"browser.component_name": "コンポーネント{number}",
"browser.parameters": "パラメータ ({count})",
"browser.precision": "精度: {precision}",
"browser.sketch_name": "スケッチ{number}",
"browser.units": "単位: {units}",
"dialog.custom_axis": "↖ カスタム軸",
"dialog.edit_title": "{title}を編集",
"dialog.select": "↖ 選択",
"dialog.selected_count": "↖ {count} 個選択",
"document.save_changes": "{name} への変更を保存しますか?",
"document.unsaved_changes": "{name} には保存されていない変更があります。保存しない場合、変更内容は失われます。",
"sketch.degrees_of_freedom": "自由度 {count}",
"sketch.draw_curves": "{linetype}の曲線を描く",
"sketch.make_selection": "選択を{linetype}にする",
"ui.1_degree_of_freedom": "自由度 1",
"ui.3d_sketch": "3D スケッチ",
"ui.about": "SolveCraft について",
"ui.activate": "アクティブ化",
"ui.active_component": "アクティブなコンポーネント",
"ui.agent_cursor": "AIエージェントカーソル",
"ui.agent_cursor_speed": "AIエージェント速度",
"ui.all": "すべて",
"ui.angle": "角度",
"ui.angle_decimals": "角度の小数点桁数",
"ui.animate_view_changes": "視点変更をアニメーション表示",
"ui.animation": "アニメーション",
"ui.animation_speed": "アニメーション速度",
"ui.apply": "適用",
"ui.arc": "円弧",
"ui.assemble": "アセンブリ",
"ui.autosave_every": "自動保存の間隔",
"ui.axes": "軸",
"ui.axis": "軸",
"ui.bodies": "ボディ",
"ui.box": "直方体",
"ui.break": "分割",
"ui.cancel": "キャンセル",
"ui.canvases": "キャンバス",
"ui.centerline": "中心線",
"ui.chamfer": "面取り",
"ui.change_workspace": "ワークスペースの変更",
"ui.chord_length": "弦長",
"ui.circle": "円",
"ui.circular_pattern": "円形パターン",
"ui.clear_recent": "最近使った項目をクリア",
"ui.clear_the_selection": "選択をクリア",
"ui.close": "閉じる",
"ui.coil": "コイル",
"ui.coincident": "一致",
"ui.collapse": "折りたたむ",
"ui.collinear": "同一直線",
"ui.combine": "結合",
"ui.coming_later": "準備中",
"ui.concentric": "同心円",
"ui.conic_curve": "円錐曲線",
"ui.constant": "一定",
"ui.constraints_conflict": "拘束が競合しています",
"ui.construct": "構築",
"ui.construction": "構築",
"ui.copy": "コピー",
"ui.counterbore": "座ぐり",
"ui.countersink": "皿もみ",
"ui.create": "作成",
"ui.create_sketch": "スケッチを作成",
"ui.current": "現在",
"ui.curvature": "曲率",
"ui.cut": "切り取り",
"ui.cylinder": "円柱",
"ui.dark": "ダーク",
"ui.default_modelling_orientation": "デフォルトのモデル方向",
"ui.degrees_of_freedom": "自由度",
"ui.delete": "削除",
"ui.depth": "深さ",
"ui.design": "デザイン",
"ui.diameter": "直径",
"ui.dimension": "寸法",
"ui.direction": "方向",
"ui.distance": "距離",
"ui.distance_1": "距離 1",
"ui.distance_2": "距離 2",
"ui.document_settings": "ドキュメントの設定",
"ui.don_t_save": "保存しない",
"ui.draft": "勾配",
"ui.drawing": "図面",
"ui.drill_point": "先端",
"ui.edit": "編集",
"ui.ellipse": "楕円",
"ui.end_radius": "終了半径",
"ui.equal": "等しい",
"ui.expand": "展開",
"ui.export_ellipsis": "エクスポート...",
"ui.extend": "延長",
"ui.extent_type": "範囲タイプ",
"ui.extrude": "押し出し",
"ui.features": "フィーチャー",
"ui.file": "ファイル",
"ui.fillet": "フィレット",
"ui.finish_sketch": "スケッチを終了",
"ui.fix_unfix": "固定/固定解除",
"ui.flat": "フラット",
"ui.flip": "反転",
"ui.front": "正面",
"ui.fully_constrained": "完全拘束",
"ui.general": "一般",
"ui.generative_design": "ジェネレーティブ デザイン",
"ui.graphics": "グラフィックス",
"ui.heading_assemble": "アセンブリ",
"ui.heading_box": "直方体",
"ui.heading_chamfer": "面取り",
"ui.heading_circular_pattern": "円形パターン",
"ui.heading_coil": "コイル",
"ui.heading_combine": "結合",
"ui.heading_construct": "構築",
"ui.heading_create": "作成",
"ui.heading_create_sketch": "スケッチを作成",
"ui.heading_cylinder": "円柱",
"ui.heading_design": "デザイン",
"ui.heading_draft": "勾配",
"ui.heading_edit": "編集",
"ui.heading_extrude": "押し出し",
"ui.heading_fillet": "フィレット",
"ui.heading_finish_sketch": "スケッチを終了",
"ui.heading_front": "正面",
"ui.heading_hole": "穴",
"ui.heading_home": "ホーム",
"ui.heading_insert": "挿入",
"ui.heading_inspect": "検査",
"ui.heading_loft": "ロフト",
"ui.heading_measure": "計測",
"ui.heading_mesh": "メッシュ",
"ui.heading_mirror": "ミラー",
"ui.heading_modify": "修正",
"ui.heading_move": "移動",
"ui.heading_offset_plane": "オフセット平面",
"ui.heading_ok": "OK",
"ui.heading_parameters": "パラメータ",
"ui.heading_pattern_on_path": "パス上のパターン",
"ui.heading_pipe": "パイプ",
"ui.heading_plane_at_angle": "傾斜平面",
"ui.heading_plastic": "プラスチック",
"ui.heading_rectangular_pattern": "矩形パターン",
"ui.heading_revolve": "回転",
"ui.heading_right": "右側面",
"ui.heading_scale": "尺度",
"ui.heading_section_analysis": "断面解析",
"ui.heading_select": "選択",
"ui.heading_sheet_metal": "板金",
"ui.heading_shell": "シェル",
"ui.heading_sketch": "スケッチ",
"ui.heading_sketch_palette": "スケッチ パレット",
"ui.heading_solid": "ソリッド",
"ui.heading_sphere": "球",
"ui.heading_split_body": "ボディを分割",
"ui.heading_surface": "サーフェス",
"ui.heading_sweep": "スイープ",
"ui.heading_thread": "ねじ",
"ui.heading_top": "上面",
"ui.heading_torus": "トーラス",
"ui.help": "ヘルプ",
"ui.hide": "非表示",
"ui.hole": "穴",
"ui.hole_tap_type": "タップタイプ",
"ui.hole_type": "穴タイプ",
"ui.home": "ホーム",
"ui.horizontal_vertical": "水平/垂直",
"ui.insert": "挿入",
"ui.inspect": "検査",
"ui.intersect": "交差",
"ui.join": "結合",
"ui.joints": "ジョイント",
"ui.keyboard_shortcuts": "キーボードショートカット",
"ui.language": "言語",
"ui.length_decimals": "長さの小数点桁数",
"ui.light": "ライト",
"ui.line": "線分",
"ui.linetype": "線種",
"ui.loft": "ロフト",
"ui.look_at": "視点を合わせる",
"ui.manufacture": "製造",
"ui.measure": "計測",
"ui.midpoint": "中点",
"ui.mirror": "ミラー",
"ui.modify": "修正",
"ui.move": "移動",
"ui.multiple": "複数",
"ui.named_views": "名前付きビュー",
"ui.navigation": "ナビゲーション",
"ui.new_body": "新規ボディ",
"ui.new_design": "新規デザイン",
"ui.normal": "標準",
"ui.object_type": "オブジェクトタイプ",
"ui.objects": "オブジェクト",
"ui.offer_to_stand_up_y_up_files": "Y-upファイルを立ち上げるか確認",
"ui.offset": "オフセット",
"ui.offset_plane": "オフセット平面",
"ui.one_side": "片側",
"ui.open": "開く",
"ui.open_ellipsis": "開く...",
"ui.open_recent": "最近使った項目を開く",
"ui.operation": "操作",
"ui.options": "オプション",
"ui.orbit_around": "回転の中心",
"ui.origin": "原点",
"ui.pan_zoom_orbit": "パン、ズーム、オービット",
"ui.parallel": "平行",
"ui.parameters": "パラメータ",
"ui.paste": "貼り付け",
"ui.path": "パス",
"ui.pattern_on_path": "パス上のパターン",
"ui.perpendicular": "直交",
"ui.pipe": "パイプ",
"ui.placement": "配置",
"ui.plane": "平面",
"ui.plane_at_angle": "傾斜平面",
"ui.point": "点",
"ui.point_under_the_cursor": "カーソル下の点",
"ui.points": "点",
"ui.polygon": "ポリゴン",
"ui.preferences": "環境設定",
"ui.profile": "プロファイル",
"ui.profile_plane": "プロファイル平面",
"ui.profiles": "プロファイル",
"ui.project": "投影",
"ui.quit": "終了",
"ui.radius": "半径",
"ui.radius_type": "半径タイプ",
"ui.rectangle": "長方形",
"ui.rectangular_pattern": "矩形パターン",
"ui.redo": "やり直す",
"ui.render": "レンダリング",
"ui.reset_the_settings_on_this_page": "このページの設定をリセット",
"ui.restore_defaults": "初期値に戻す",
"ui.reverse_zoom_direction": "ズーム方向を反転",
"ui.revolve": "回転",
"ui.right": "右側面",
"ui.save": "保存",
"ui.save_as": "名前を付けて保存",
"ui.save_as_ellipsis": "名前を付けて保存...",
"ui.scale": "尺度",
"ui.section_analysis": "断面解析",
"ui.select": "選択",
"ui.selection": "選択",
"ui.shell": "シェル",
"ui.show": "表示",
"ui.show_constraints": "拘束を表示",
"ui.show_dimensions": "寸法を表示",
"ui.show_points": "点を表示",
"ui.show_profile": "プロファイルを表示",
"ui.show_projected_geometries": "投影ジオメトリを表示",
"ui.simple": "単純",
"ui.simulation": "シミュレーション",
"ui.single": "単一",
"ui.sketch": "スケッチ",
"ui.sketch_dimension": "スケッチ寸法",
"ui.sketch_grid": "スケッチ グリッド",
"ui.sketch_scale": "スケッチ尺度",
"ui.sketches": "スケッチ",
"ui.slice": "スライス",
"ui.slot": "スロット",
"ui.smooth": "スムーズ",
"ui.snap": "スナップ",
"ui.sphere": "球",
"ui.spline": "スプライン",
"ui.split_body": "ボディを分割",
"ui.start": "開始",
"ui.start_radius": "開始半径",
"ui.sweep": "スイープ",
"ui.symmetric": "対称",
"ui.symmetry": "対称",
"ui.system": "システム",
"ui.tangent": "正接",
"ui.taper_angle": "テーパ角度",
"ui.tapped": "タップ立て",
"ui.text": "テキスト",
"ui.theme": "テーマ",
"ui.thickness": "厚さ",
"ui.thread": "ねじ",
"ui.tip_angle": "先端角度",
"ui.to_object": "オブジェクトまで",
"ui.top": "上面",
"ui.torus": "トーラス",
"ui.trim": "トリム",
"ui.two_sides": "両側",
"ui.ui_scale": "UIスケール",
"ui.undo": "元に戻す",
"ui.units": "単位",
"ui.units_of_new_designs": "新規デザインの単位",
"ui.untitled": "無題",
"ui.variable": "可変",
"ui.view_centre": "ビュー中心",
"ui.visual_style": "表示スタイル",
"ui.workspace": "ワークスペース",
"ui.zoom_to_the_cursor": "カーソル位置へズーム"
}
+59 -17
View File
@@ -46,6 +46,8 @@ pub struct Prefs {
pub angle_decimals: u8,
/// `dark`, `light` or `system` (follow the operating system).
pub theme: String,
/// Interface language (a registered locale ID).
pub language: String,
/// Interface scale (1 = 100 %).
pub ui_scale: f32,
/// The wheel zooms toward the point under the cursor (else toward the view's centre).
@@ -70,6 +72,7 @@ impl Default for Prefs {
length_decimals: 2,
angle_decimals: 1,
theme: "dark".into(),
language: "en".into(),
ui_scale: 1.0,
zoom_to_cursor: true,
look_at_anim: true,
@@ -157,6 +160,7 @@ fn defaults(app: &mut SolveApp, page: usize) {
match page {
0 => {
p.theme = d.theme;
p.language = d.language;
p.ui_scale = d.ui_scale;
p.default_units = d.default_units;
p.length_decimals = d.length_decimals;
@@ -376,7 +380,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
let mut open = true;
let mut button: Option<&str> = None;
let before = app.preferences.clone();
crate::frame::window(ctx, "Preferences", crate::frame::Width::Wide)
crate::frame::window(ctx, app.tr("ui.preferences"), crate::frame::Width::Wide)
.id(egui::Id::new("sc_prefs"))
.open(&mut open)
.collapsible(false)
@@ -389,7 +393,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
ui.vertical(|ui| {
ui.set_width(140.0);
for (i, s) in PAGES.iter().enumerate() {
if ui.selectable_label(app.prefs_window.section == i, *s).clicked() {
if ui.selectable_label(app.prefs_window.section == i, app.tr_label(s)).clicked() {
app.prefs_window.section = i;
app.keymap.read_only = false;
}
@@ -399,24 +403,24 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
ui.vertical(|ui| {
// A fixed width, so the window keeps its size from page to page.
ui.set_width(540.0);
ui.label(RichText::new(PAGES.get(app.prefs_window.section).copied().unwrap_or_default()).strong().size(14.0));
ui.label(RichText::new(app.tr_label(PAGES.get(app.prefs_window.section).copied().unwrap_or_default())).strong().size(14.0));
ui.add_space(6.0);
page(app, ui, &t);
});
});
ui.separator();
ui.horizontal(|ui| {
if ui.button("Restore Defaults").on_hover_text("Reset the settings on this page").clicked() {
if ui.button(app.tr("ui.restore_defaults")).on_hover_text(app.tr("ui.reset_the_settings_on_this_page")).clicked() {
button = Some("defaults");
}
crate::frame::right_aligned(ui, 700.0, 170.0, |ui| {
if ui.button("OK").clicked() {
if ui.button(app.tr("ui.heading_ok")).clicked() {
button = Some("ok");
}
if ui.button("Cancel").clicked() {
if ui.button(app.tr("ui.cancel")).clicked() {
button = Some("cancel");
}
if ui.button("Apply").clicked() {
if ui.button(app.tr("ui.apply")).clicked() {
button = Some("apply");
}
});
@@ -467,6 +471,7 @@ fn up_axis_changed(app: &mut SolveApp) {
/// One page of the dialog.
fn page(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) {
let _language = crate::i18n::scope(&app.preferences.language);
let note = |ui: &mut egui::Ui, s: &str| {
ui.label("");
ui.label(RichText::new(s).size(11.0).color(t.text_dim));
@@ -478,10 +483,21 @@ fn page(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) {
}
egui::Grid::new(("sc_prefs_grid", app.prefs_window.section)).num_columns(2).spacing([16.0, 8.0]).show(ui, |ui| match app.prefs_window.section {
0 => {
ui.label("Theme");
ui.label(app.tr("ui.language"));
egui::ComboBox::from_id_salt("pref_language").selected_text(crate::i18n::Locale::for_language(&app.preferences.language).name()).show_ui(
ui,
|ui| {
for locale in crate::i18n::LOCALES {
let (id, label) = (locale.id, locale.name);
ui.selectable_value(&mut app.preferences.language, id.to_string(), label);
}
},
);
ui.end_row();
ui.label(app.tr("ui.theme"));
ui.horizontal(|ui| {
for (id, label) in THEMES {
if ui.radio(app.preferences.theme == id, label).clicked() {
if ui.radio(app.preferences.theme == id, app.tr_label(label)).clicked() {
app.preferences.theme = id.to_string();
if id != "system" {
app.ui.dark = id == "dark";
@@ -490,33 +506,34 @@ fn page(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) {
}
});
ui.end_row();
ui.label("UI scale");
ui.label(app.tr("ui.ui_scale"));
ui.add(egui::Slider::new(&mut app.preferences.ui_scale, 0.75..=2.0).step_by(0.05).custom_formatter(|v, _| format!("{:.0} %", v * 100.0)));
ui.end_row();
ui.label("Units of new designs");
ui.label(app.tr("ui.units_of_new_designs"));
egui::ComboBox::from_id_salt("pref_units").selected_text(app.preferences.default_units.clone()).show_ui(ui, |ui| {
for u in UNITS {
ui.selectable_value(&mut app.preferences.default_units, u.to_string(), u);
}
});
ui.end_row();
ui.label("Autosave every");
ui.label(app.tr("ui.autosave_every"));
if ui.add(egui::DragValue::new(&mut app.autosave_minutes).range(0.0..=120.0).speed(0.5).suffix(" min")).changed() {
set_autosave(app);
}
ui.end_row();
note(ui, "0 turns it off");
ui.label("Length decimals");
ui.label(app.tr("ui.length_decimals"));
ui.add(egui::DragValue::new(&mut app.preferences.length_decimals).range(0..=6));
ui.end_row();
ui.label("Angle decimals");
ui.label(app.tr("ui.angle_decimals"));
ui.add(egui::DragValue::new(&mut app.preferences.angle_decimals).range(0..=6));
ui.end_row();
ui.label("Agent cursor");
ui.label(app.tr("ui.agent_cursor"));
let speed_label = app.tr("ui.agent_cursor_speed");
let s = &mut app.ui.agent_cursor;
ui.checkbox(&mut s.show, "Show the pointer an AI agent moves (#35)");
ui.end_row();
ui.label("Agent cursor speed");
ui.label(speed_label);
ui.add_enabled_ui(s.show, |ui| {
crate::agent_cursor::speed_slider(ui, &mut s.speed);
});
@@ -693,6 +710,9 @@ pub fn set(app: &mut SolveApp, p: &serde_json::Value) -> Result<serde_json::Valu
if !THEMES.iter().any(|(id, _)| *id == new.theme) || !["z", "y"].contains(&new.up_axis.as_str()) {
return Err("`theme` must be dark, light or system and `up_axis` z or y".into());
}
if !crate::i18n::is_supported(&new.language) {
return Err(format!("`language` must be one of: {}", crate::i18n::LOCALES.iter().map(|locale| locale.id).collect::<Vec<_>>().join(", ")));
}
if !(0.5..=2.5).contains(&new.ui_scale) || !(0.25..=4.0).contains(&new.look_at_speed) {
return Err("`ui_scale` must be 0.5…2.5 and `look_at_speed` 0.25…4".into());
}
@@ -778,11 +798,33 @@ mod tests {
// File and Help › Keyboard Shortcuts open the same dialog at its page.
app.keymap.open = true;
let ctx = egui::Context::default();
let _ = ctx.run_ui(Default::default(), |ui| show(&mut app, ui.ctx()));
let mut out = ctx.run_ui(Default::default(), |ui| show(&mut app, ui.ctx()));
out.textures_delta.clear();
assert!(app.prefs_window.open && app.prefs_window.section == PAGE_SHORTCUTS && !app.keymap.open);
assert!(control(&mut app, &json!({"open": "Plugins"})).is_err());
assert!(set(&mut app, &json!({"up_axis": "x"})).is_err());
assert!(set(&mut app, &json!({"theme": "sepia"})).is_err());
assert!(set(&mut app, &json!({"language": "fr"})).is_err());
}
#[test]
fn language_preference_switches_and_persists() {
let mut app = SolveApp::new(solvecraft_engine::Session::default(), Default::default());
assert_eq!(app.preferences.language, "en");
assert_eq!(app.tr("ui.preferences"), "Preferences");
assert_eq!(app.tr("ui.extrude"), "Extrude");
// Switch language to Japanese
set(&mut app, &json!({"language": "ja"})).unwrap();
assert_eq!(app.preferences.language, "ja");
assert_eq!(app.tr("ui.preferences"), "環境設定");
assert_eq!(app.tr("ui.extrude"), "押し出し");
// Preferences round-trip
let mut other = SolveApp::new(solvecraft_engine::Session::default(), Default::default());
other.load_prefs(&app.prefs());
assert_eq!(other.preferences.language, "ja");
assert_eq!(other.tr("ui.preferences"), "環境設定");
}
/// A STEP file opened Z-up is offered to be stood up: a quarter turn about X, undoable.
+21 -11
View File
@@ -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;
}
});
+37 -26
View File
@@ -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));
+27 -2
View File
@@ -14,6 +14,8 @@ pub enum Kind {
Draw(usize),
/// Pick sketch entities; `n` picks run the command.
Pick(usize),
/// Pick one straight line, or two distinct points to align.
HorizontalVertical,
Dimension,
/// A tool from `sketch_tools` (it handles its own clicks).
Ext,
@@ -56,7 +58,7 @@ impl Tool {
"sketch.slot.overall" => ("sketch.slot.overall", Kind::Draw(3)),
"sketch.point" => ("sketch.point", Kind::Draw(1)),
"sketch.dimension" => ("sketch.dimension", Kind::Dimension),
"sketch.constraint.horizontal_vertical" => ("sketch.constraint.horizontal_vertical", Kind::Pick(1)),
"sketch.constraint.horizontal_vertical" => ("sketch.constraint.horizontal_vertical", Kind::HorizontalVertical),
"sketch.constraint.fix" => ("sketch.constraint.fix", Kind::Pick(1)),
"sketch.constraint.coincident" => ("sketch.constraint.coincident", Kind::Pick(2)),
"sketch.constraint.tangent" => ("sketch.constraint.tangent", Kind::Pick(2)),
@@ -93,6 +95,7 @@ pub fn hint(id: &str) -> String {
"sketch.arc.three_point" => "Arc: click start, end, then a point on the arc".into(),
"sketch.arc.center_point" => "Arc: click centre and start, then click end or type a signed sweep".into(),
"sketch.dimension" => "Dimension: pick one or two entities, then click to place the value".into(),
"sketch.constraint.horizontal_vertical" => "Horizontal/Vertical: pick a line or two points to align".into(),
"sketch.slot.center_to_center" | "sketch.slot.overall" => "Slot: click both ends, then the width".into(),
_ if id.starts_with("sketch.constraint.") => "Constraint: pick the sketch entities".into(),
_ => crate::sketch_tools::hint(id).unwrap_or_else(|| "Click in the sketch".into()),
@@ -143,6 +146,29 @@ pub fn on_click(app: &mut SolveApp, proj: &Proj, pos: Pos2) {
return;
}
}
Kind::HorizontalVertical => {
let hits = pick(app, proj, pos);
let hit = hits.iter().find(|hit| match hit {
Hit::SketchPoint { sketch, .. } | Hit::SketchCurve { sketch, .. } => Some(*sketch) == app.session.active_sketch,
_ => false,
});
match hit {
Some(Hit::SketchPoint { id, .. }) => {
if let Some(first) = tool.picks.first() {
if first != id {
let _ = app.run(tool.cmd, json!({"points": [first, id]}));
tool.picks.clear();
}
} else {
tool.picks.push(id.clone());
}
}
Some(Hit::SketchCurve { id, straight: true, .. }) if tool.picks.is_empty() => {
let _ = app.run(tool.cmd, json!({"line": id}));
}
_ => {}
}
}
Kind::Pick(n) => {
let hits = pick(app, proj, pos);
if let Some(id) = hits.iter().find_map(|h| match h {
@@ -292,7 +318,6 @@ fn run_shape(app: &mut SolveApp, t: &Tool) -> Result<Vec<String>, String> {
fn run_pick(app: &mut SolveApp, t: &Tool) {
let k = &t.picks;
let params = match t.cmd {
"sketch.constraint.horizontal_vertical" => json!({"line": k[0]}),
"sketch.constraint.fix" => json!({"entity": k[0]}),
"sketch.constraint.coincident" => json!({"a": k[0], "b": k[1]}),
"sketch.constraint.midpoint" => json!({"point": k[0], "line": k[1]}),
+3 -25
View File
@@ -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]
@@ -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());
}
+90
View File
@@ -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);
}
+14
View File
@@ -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
+14 -2
View File
@@ -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 |
+41
View File
@@ -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.