Merge origin/main into feat/profiles-by-curve-ids

This commit is contained in:
CypherNaugh_0x committed 2026-10-11 14:53:08 +02:00
commit 4c466466a2
62 files changed
+3591 -347

No files matched your search

+19
View File
@@ -0,0 +1,19 @@
---
name: Bug report
about: Help reproduce and fix a SolveCraft problem
title: ''
labels: ''
assignees: ''
---
## Behavior
What did you expect, and what happened instead?
## Reproduction
Include steps, SolveCraft version/commit and relevant environment details. Prefer a small example without private design data.
## Concrete visual context (when helpful)
If a screenshot, short demo, diagram or example model view helps make the problem concrete, include it with the reproduction steps, even for kernel, geometry, assembly or backend behavior. Coding agents should run the example and capture the actual result themselves when possible, rather than asking a human for evidence they can produce. Annotate the focus when helpful. Exclude private designs and sensitive details; attach media or use an accessible link, not committed generated artifacts. Problems with no useful visual explanation do not require media.
+15
View File
@@ -0,0 +1,15 @@
## Change
Describe the problem and resulting behavior. Link the issue when applicable.
## Validation
What did you run, and what were the results? Include expected and measured values for geometry changes.
## Concrete visual context (when helpful)
If a screenshot, short demo, diagram or before/after model view helps explain what this change means, include it alongside the prose. This includes kernel, geometry, assembly and backend changes, not only UI work. Coding agents should run the example and capture the actual reproduction/result themselves, adding brief steps and annotations where helpful. Use a public-safe example; exclude private designs and sensitive details, and attach media rather than committing generated review artifacts. Changes with no useful visual explanation can omit this section.
## Integration status
Record the upstream commit and PR status checked before handoff, any conflicts resolved, and validation after integration. Keep up with upstream merges and monitor this PR while the task is active; rebase before pushing and never force-push.
+19
View File
@@ -71,6 +71,25 @@ People trust SolveCraft with their designs; a crash loses their work. **This out
- **Never break wasm**: everything below the apps must build for `wasm32-unknown-unknown`
(`cargo xtask wasm`, part of `cargo xtask ci`).
## Showing and following through on changes
- Give issues and PRs concrete visual context whenever it helps explain what is broken or
changed, including kernel, geometry, assembly and backend work. Coding agents should run
the relevant example and capture a screenshot, short recording, diagram or before/after
model view themselves. Show the actual reproduction/result, annotate the focus when useful,
and pair it with brief expected/actual behavior; do not ask a human for evidence the agent
can produce. Changes with no useful visual explanation do not need media.
- Use a small public-safe example and check for private designs, paths, account details or
other sensitive content before sharing. Attach media to the issue/PR or link to an accessible
recording; do not commit generated screenshots or videos just to provide review evidence.
- Throughout the work and before handoff, check current upstream commits and the relevant
PR's merge status. Keep your branch current with upstream merges, resolve conflicts without
discarding others' work, and re-run validation when integration changes the result. Use
`git pull --rebase origin main` before pushing as above; never force-push. Report the checked
upstream/PR state, any conflicts resolved and the validation performed. If a PR remains open,
keep monitoring it while the task is active and make its pending status clear at handoff;
do not claim it merged without checking.
## Application releases
Development pushes are not application releases. Follow `docs/releasing.md` when asked to
prepare or ship a release: choose the version from the actual changes, update Cargo.toml and
+57
View File
@@ -28,6 +28,46 @@ HTTPS or localhost, because it uses the Origin Private File System.
Windows from Linux: `cargo xwin build --release --target x86_64-pc-windows-msvc -p solvecraft`.
### NixOS: `nix develop`
NixOS has no `/usr/lib`, so nothing is on `LD_LIBRARY_PATH` and a plain `cargo run` finds none of
the libraries the app loads at run time: the Wayland ones winit opens the window through, the EGL
loader wgpu renders through, and libdbus-1, which rfd's file dialogs reach xdg-desktop-portal
through (a dialog then never appears and the pick returns no path):
```
ERROR solvecraft: winit EventLoopError: ... The wayland library could not be loaded
ERROR eframe::native::run: Exiting because of error: WGPU error: Failed to create surface for any enabled backend: {}
ERROR rfd::backend::xdg_desktop_portal::portal::libdbus: Can't connect to a portal: libdbus-1.so not found
```
`nix develop` (the [`flake.nix`](flake.nix) in the repository root) puts the Wayland, X11,
EGL-loader and D-Bus libraries on `LD_LIBRARY_PATH`, so `cargo run --release -p solvecraft` works
from that shell as on any other distribution, file dialogs included. `nix run` builds the app and
wraps the binary the same way. The Mesa drivers stay the system's, from `/run/opengl-driver`
(`hardware.graphics.enable`): that is the driver stack the compositor runs on, and wgpu reaches the
integrated GPU through the EGL loader. The shell deliberately brings no Rust toolchain — the one on
`PATH` keeps its build cache, where a second `rustc` would make every build recompile the workspace
from scratch; `nix develop .#rust` is there for a machine with no Rust at all.
**Anti-aliasing on the OpenGL backend.** NixOS keeps the Vulkan ICD descriptions
(`/run/opengl-driver/share/vulkan/icd.d`) off the Vulkan loader's search path, so wgpu picks its
OpenGL backend on the integrated GPU. On that backend any multisampling above 1× — the default is
4× — draws a black window: the UI still lays out and the control channel still answers, but every
frame, `ui.screenshot` included, comes out empty. Set **Preferences ▸ Graphics ▸ Anti-aliasing** to
1× and restart (the setting applies on the next start), or write it before the first run, since a
black window is no way to reach the dialog:
```sh
mkdir -p ~/.local/share/solvecraft
echo '{"msaa": 1}' > ~/.local/share/solvecraft/graphics.json
```
`apps/solvecraft/src/main.rs` reads that file (`graphics_path()`, eframe's storage dir) before the
window opens; 2× and 4× are the same black window. The Vulkan backend is no way out of it either:
`libvulkan.so.1` is not on `LD_LIBRARY_PATH`, so `WGPU_BACKEND=vulkan` exits with a
`CreateSurfaceError` instead.
## How the code is laid out
The [README](README.md#architecture) has the layer table. The short version:
@@ -128,6 +168,23 @@ install. If you work on geometry and want to check against it, ask in an issue.
4. Reference the issue (`Fixes #12`). In the PR, say what you tested and how. For geometry work, include the measured numbers (and
the expected ones).
Words alone can leave a change hard to picture. When a screenshot, short demo, diagram or
before/after model view helps explain what is broken or changed, please include that concrete
context in the issue or PR. This applies to kernel, geometry, assembly and backend work as well
as the UI. Coding agents should run the relevant example and capture the actual reproduction
or result themselves, with a brief expected/actual explanation and annotations where useful;
please don't ask a human for evidence the agent can produce. Changes with no useful visual
explanation do not need media. Use a small example without private designs or sensitive
details; attach the media or link to an accessible recording rather than committing generated
review artifacts.
Keep checking upstream commits and the relevant PR's merge status throughout the work and
before handoff. Integrate upstream merges, resolve conflicts without losing others' changes,
and validate the integrated result. Before pushing, run `git pull --rebase origin main`, re-run
CI if it brought changes, and push without force. Report the upstream/PR state you checked,
any conflicts resolved and validation results. While a task remains active, follow an open
PR through its status changes; at handoff, clearly identify anything still awaiting merge.
Much of SolveCraft so far was written by AI coding agents (Claude) working in parallel, each
owning one area, with a human setting direction and reporting bugs. Their working notes are in
[AGENTS.md](AGENTS.md). Human contributors are just as welcome. Use whatever tools you like, as
Generated
+1
View File
@@ -3242,6 +3242,7 @@ dependencies = [
"serde",
"serde_json",
"solvecraft-engine",
"ttf-parser",
"web-time",
]
+2 -1
View File
@@ -139,7 +139,8 @@ headline counts in-scope tabs only and lists the deferred ones separately.
than retarget. General free-form profiles, seams, stepped cylinders and other curved surfaces
remain unsupported. STEP round-trip and independent radial/volume checks cover this subset.
- Shell and offset faces work on bodies of planes, cylinders, cones and spheres (convex or not:
filleted boxes, bosses, domes, countersinks); draft on convex planar bodies and on walls (planes, cylinders → cones) between caps; lofts through three or more sections are smooth (B-spline sides through every section), two
filleted boxes, bosses, domes, countersinks; corners where four or more planes meet, such as a
floor falling to a drain, as long as the corner's edges are all convex or all concave); draft on convex planar bodies and on walls (planes, cylinders → cones) between caps; lofts through three or more sections are smooth (B-spline sides through every section), two
sections ruled.
- Sketch corner tools: fillet and all three chamfer modes pick a connected corner even when
+7 -6
View File
@@ -64,6 +64,8 @@ struct Job {
pub fn run(args: &[String]) -> Result<(), String> {
let from = flag(args, "--from", 0u64);
let count = flag(args, "--count", 100u64);
let last = from.checked_add(count.saturating_sub(1)).ok_or("seed range exceeds u64::MAX: reduce --from or --count")?;
let mut seeds = (count > 0).then_some(from..=last).into_iter().flatten().peekable();
let jobs = flag(args, "--jobs", 4usize).clamp(1, 64);
let timeout = Duration::from_secs(flag(args, "--timeout", 120u64));
let steps = flag(args, "--steps", 8usize);
@@ -74,14 +76,14 @@ pub fn run(args: &[String]) -> Result<(), String> {
if let Some(d) = &out_dir {
std::fs::create_dir_all(d).map_err(|e| format!("{d}: {e}"))?;
}
let mut next = from;
let mut running: Vec<Job> = Vec::new();
let mut results: Vec<Value> = Vec::new();
let t0 = Instant::now();
while next < from + count || !running.is_empty() {
while running.len() < jobs && next < from + count {
while seeds.peek().is_some() || !running.is_empty() {
while running.len() < jobs {
let Some(seed) = seeds.next() else { break };
let mut cmd = Command::new(&exe);
cmd.args(["fuzz-one", &next.to_string(), "--steps", &steps.to_string()]);
cmd.args(["fuzz-one", &seed.to_string(), "--steps", &steps.to_string()]);
if minimise {
cmd.arg("--minimise");
}
@@ -96,8 +98,7 @@ pub fn run(args: &[String]) -> Result<(), String> {
}
});
}
running.push(Job { seed: next, child, started: Instant::now(), err: rx, last: String::new() });
next += 1;
running.push(Job { seed, child, started: Instant::now(), err: rx, last: String::new() });
}
std::thread::sleep(Duration::from_millis(50));
let mut i = 0;
+31
View File
@@ -0,0 +1,31 @@
//! Seed ranges must not crash or wrap, including at the last representable seed.
use std::process::Command;
#[test]
fn overflowing_seed_range_is_an_error_not_a_panic() {
let output =
Command::new(env!("CARGO_BIN_EXE_solvecraft-cli")).args(["fuzz", "--from", "18446744073709551615", "--count", "2"]).output().unwrap();
assert_eq!(output.status.code(), Some(1));
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("seed range exceeds u64::MAX"), "{stderr}");
assert!(!stderr.contains("panicked"), "{stderr}");
}
#[test]
fn empty_seed_range_at_maximum_is_valid() {
let output =
Command::new(env!("CARGO_BIN_EXE_solvecraft-cli")).args(["fuzz", "--from", "18446744073709551615", "--count", "0"]).output().unwrap();
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
assert!(String::from_utf8_lossy(&output.stdout).starts_with("0 seeds, 0 design steps,"));
}
#[test]
fn maximum_seed_runs_once_without_wrapping() {
let output = Command::new(env!("CARGO_BIN_EXE_solvecraft-cli"))
.args(["fuzz", "--from", "18446744073709551615", "--count", "1", "--steps", "0", "--timeout", "0"])
.output()
.unwrap();
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
assert!(String::from_utf8_lossy(&output.stdout).starts_with("1 seeds,"));
}
+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");
+53 -2
View File
@@ -35,8 +35,13 @@ pub static COMMANDS: &[CommandSpec] = &[
.params("feature: id|name → features deleted with it, and features that would fail without it"),
CommandSpec::new("timeline.suppress", "Suppress Feature", suppress).params("feature: id|name, suppressed?: bool (default toggles)"),
CommandSpec::new("timeline.edit", "Edit Feature", edit_feature).params(
"feature: id|name, set: {fields to change, e.g. {\"extent\": {\"distance\": \"30\"}}} → recomputed: features rebuilt, \
restored: features whose earlier result for the same inputs was reused",
"feature: id|name, set: {fields to change, merged into the feature's definition as timeline.inspect returns it, \
e.g. {\"extent\": {\"distance\": \"30\"}} (extrude), {\"angle\": \"90 deg\"} (revolve); \
expressions are text} → recomputed: features rebuilt, restored: features whose earlier result for the same inputs was reused",
),
CommandSpec::new("timeline.inspect", "Inspect Feature", inspect_feature).noundo().params(
"feature: id|name → type, definition (the fields timeline.edit's set changes, with their current values), \
inputs: [{label, param, expression, unit}] (named inputs parameters.change can also set), error, warning",
),
CommandSpec::new("select.set", "Select", select_set)
.noundo()
@@ -213,6 +218,52 @@ fn command_profiles(set: &Value, cmd: &str) -> Result<Value> {
Ok(set)
}
/// Fields of a stored feature left out of its definition: bookkeeping timeline.edit recomputes, and
/// the name and suppression, which timeline.rename and timeline.suppress change.
const NOT_EDITABLE: &[&str] = &["id", "type", "name", "suppressed", "param_names", "edge_refs", "edge_names", "face_names"];
fn inspect_feature(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "timeline.inspect";
let id = feature_id(s, p.get("feature"), cmd)?;
let f = s.doc.feature(id).ok_or_else(|| bad(cmd, "feature"))?;
let mut def = serde_json::to_value(f).map_err(|e| EngineError::Other(e.to_string()))?;
let mut omitted = Vec::new();
if let Some(o) = def.as_object_mut() {
for k in NOT_EDITABLE {
o.remove(*k);
}
// Large payloads that timeline.edit cannot change.
let heavy: &[(&str, &str)] = match &f.kind {
FeatureKind::Sketch { .. } => &[("sketch", "the sketch geometry (sketch.inspect; edit it with the sketch commands)")],
FeatureKind::Import { .. } => &[("step", "the imported STEP text"), ("components", "the imported product tree")],
FeatureKind::MeshImport { .. } => &[("meshes", "the imported meshes")],
_ => &[],
};
for (k, why) in heavy {
if o.remove(*k).is_some() {
omitted.push(json!({"field": k, "what": why}));
}
}
}
let inputs: Vec<Value> = f
.kind
.inputs()
.into_iter()
.enumerate()
.map(|(i, (label, e, k))| {
json!({"label": label, "param": f.param_names.get(i).filter(|n| !n.is_empty()), "expression": e, "unit": k.default_unit()})
})
.collect();
let marker = s.doc.marker.unwrap_or(usize::MAX);
let rolled_back = s.doc.features.iter().position(|x| x.id == id).is_some_and(|i| i >= marker);
let r = s.model.result(id);
Ok(json!({
"id": f.id, "name": f.name, "type": f.kind.type_name(), "suppressed": f.suppressed, "rolled_back": rolled_back,
"definition": def, "omitted": omitted, "inputs": inputs,
"error": r.and_then(|r| r.error.clone()), "warning": r.and_then(|r| r.warning.clone()),
}))
}
fn edit_feature(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "timeline.edit";
let id = feature_id(s, p.get("feature"), cmd)?;
+4 -1
View File
@@ -104,7 +104,10 @@ fn generated_profiles_and_press_pull_convert_rotated_occurrence_picks_once() {
// Curved coincident Cut is an existing kernel limit; it must be atomic.
let before = s.doc.to_json();
let error = s.execute("solid.press_pull", &json!({"face":[35,-15,15],"distance":-5})).unwrap_err();
assert!(error.to_string().contains("coincident faces on curved bodies"), "{error}");
let message = error.to_string();
assert!(message.contains("takes planar faces only"), "{error}");
assert!(message.contains("the target has 2 curved faces (cylinder)"), "{error}");
assert!(message.contains("the tool has 2 curved faces (cylinder)"), "{error}");
assert_eq!(s.doc.to_json(), before);
check(&mut s, area * 35.0, hole, 35.0);
35.0
@@ -436,6 +436,54 @@ fn coil_and_loft_to_a_point() {
assert!(s.execute("solid.loft", &json!({"sections": [{"sketch": "Base"}, {"sketch": "Top", "point": "nope"}]})).is_err());
}
/// A 3 mm shelled basin (100 x 60 x 30, open top) with a fall lofted from its whole outline
/// 10 up down to a drain pad (`pad`: corners, 1 up), in either section order. The loft's top
/// edges lie on the outer walls; the cut used to fail for one order of the sections (an
/// `Option::unwrap()` panic inside the kernel, or "shell not oriented and closed").
fn basin_fall(pad: [f64; 4], reverse: bool, op: &str) -> (Session, std::result::Result<Value, String>) {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 100, "width": 60, "height": 30, "corner": [0, 0, 0]}));
run(&mut s, "solid.shell", json!({"faces": [[50, 30, 30]], "thickness": 3}));
run(&mut s, "sketch.create", json!({"plane": {"origin": [0, 0, 10], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}, "name": "Rim"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [100, 60]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "sketch.create", json!({"plane": {"origin": [0, 0, 1], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}, "name": "Pad"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [pad[0], pad[1]], "p1": [pad[2], pad[3]]}));
run(&mut s, "sketch.finish", json!({}));
let mut secs = vec![json!({"sketch": "Rim"}), json!({"sketch": "Pad"})];
if reverse {
secs.reverse();
}
let r = s.execute("solid.loft", &json!({"sections": secs, "operation": op})).map_err(|e| e.to_string());
(s, r)
}
#[test]
fn loft_cut_through_a_shelled_basin_in_either_order() {
// What is left after the cut: the basin less the loft's part of the floor and walls
// (integrated over the height), the same for a pad in any corner.
let want = 41582.028;
for pad in [[80.0, 40.0, 90.0, 50.0], [10.0, 40.0, 20.0, 50.0], [10.0, 10.0, 20.0, 20.0], [80.0, 10.0, 90.0, 20.0]] {
for reverse in [false, true] {
let (mut s, r) = basin_fall(pad, reverse, "cut");
assert!(r.is_ok(), "{pad:?} reverse {reverse}: {r:?}");
let v = volume(&mut s);
assert!((v - want).abs() < 1.0, "{pad:?} reverse {reverse}: {v} vs {want}");
assert_eq!(measure(&mut s)["body_count"], 1);
}
}
// As a new body and joined: a valid solid of positive volume either way.
for op in ["new", "join"] {
let mut vols = Vec::new();
for reverse in [false, true] {
let (mut s, r) = basin_fall([80.0, 40.0, 90.0, 50.0], reverse, op);
assert!(r.is_ok(), "{op} reverse {reverse}: {r:?}");
vols.push(volume(&mut s));
}
assert!(vols[0] > 42948.0 && rel(vols[0], vols[1]) < 1e-6, "{op}: {vols:?}");
}
}
#[test]
fn cylinder_auto_inference_saves_a_profile_anchor_and_replays_both_depth_signs() {
use solvecraft_geom::Vec3;
+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"),
@@ -96,3 +96,90 @@ fn fillet_and_chamfer_corners() {
assert!(solved(&s));
assert!((area(&s) - (a0 - 8.0)).abs() < 1e-6, "{}", area(&s));
}
/// The ids of the constraints driven by the model parameter `name`.
fn driven_by(s: &Session, name: &str) -> Vec<String> {
sk(s).constraints.iter().filter(|c| c.param.as_deref() == Some(name)).map(|c| c.id.clone()).collect()
}
/// The spout of a basin: a vertical riser and a horizontal top, the top's length dimensioned
/// to the corner and the corner's height dimensioned from the origin. Filleting the corner used
/// to drop the height and leave the length on the shortened straight part, so the top came out
/// a fillet radius too long. Like Fusion, the corner now stays as a virtual sharp corner (a point
/// on both lines' extensions) and the dimensions measure to it.
#[test]
fn a_fillet_keeps_the_dimensions_of_its_corner_on_a_virtual_sharp() {
let mut s = new_sketch();
run(&mut s, "sketch.line", json!({"points": [[0, 0], [0, 100], [-80, 100]], "ids": ["riser", "top"]}));
run(&mut s, "sketch.constraint.horizontal_vertical", json!({"line": "riser", "mode": "vertical"}));
run(&mut s, "sketch.constraint.horizontal_vertical", json!({"line": "top", "mode": "horizontal"}));
let height = run(&mut s, "sketch.dimension", json!({"entities": ["origin", "riser.end"], "type": "vertical", "value": 100}));
let height = height["param"].as_str().unwrap().to_string();
let len = run(&mut s, "sketch.dimension", json!({"entities": ["top"], "value": 80}));
let len = len["param"].as_str().unwrap().to_string();
let (height_ids, len_ids) = (driven_by(&s, &height), driven_by(&s, &len));
let f = run(&mut s, "sketch.fillet", json!({"point": "riser.end", "radius": 20}));
assert!(solved(&s), "{f}");
// Both dimensions are kept (same ids) and reported as moved to the virtual corner.
assert_eq!(driven_by(&s, &height), height_ids);
assert_eq!(driven_by(&s, &len), len_ids);
let corner = f["virtual_corners"][0].as_str().unwrap_or_default().to_string();
assert!(!corner.is_empty(), "{f}");
let moved: Vec<String> = f["moved_to_virtual_corner"].as_array().unwrap().iter().filter_map(|v| v.as_str().map(str::to_string)).collect();
for id in height_ids.iter().chain(&len_ids) {
assert!(moved.contains(id), "{id}: {f}");
}
// The virtual corner sits where the corner was, and the part keeps its size.
let k = sk(&s);
let vc = k.point(k.point_index(&corner).unwrap()).unwrap();
assert!(vc.dist(solvecraft_geom::Vec2::new(0.0, 100.0)) < 1e-6, "{vc:?}");
let top_end = k.point(k.resolve_point("top.end").unwrap()).unwrap();
assert!(top_end.dist(solvecraft_geom::Vec2::new(-80.0, 100.0)) < 1e-6, "{top_end:?}");
let (lo, hi) = k.bounds().unwrap();
assert!((hi.x - lo.x - 80.0).abs() < 1e-6 && (hi.y - lo.y - 100.0).abs() < 1e-6, "{lo:?} {hi:?}");
// It is drawn with extension lines from both lines' ends to it.
let vcs = k.virtual_corners();
assert_eq!(vcs.len(), 1, "{vcs:?}");
assert_eq!(vcs[0].1.len(), 2, "{vcs:?}");
assert!(vcs[0].1.iter().all(|(a, b)| a.dist(vc) < 1e-9 && (b.dist(vc) - 20.0).abs() < 1e-6), "{vcs:?}");
// The dimensions still drive the part, measured to the virtual corner.
run(&mut s, "parameters.change", json!({"name": len, "expression": "90 mm"}));
run(&mut s, "parameters.change", json!({"name": height, "expression": "120 mm"}));
assert!(solved(&s));
let (lo, hi) = sk(&s).bounds().unwrap();
assert!((hi.x - lo.x - 90.0).abs() < 1e-6 && (hi.y - lo.y - 120.0).abs() < 1e-6, "{lo:?} {hi:?}");
// It is kept in the design file, and undo and redo take it away and bring it back.
let id = s.active_sketch.unwrap();
let doc = solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap();
assert_eq!(doc.sketch(id).unwrap(), s.doc.sketch(id).unwrap());
run(&mut s, "edit.undo", json!({}));
run(&mut s, "edit.undo", json!({}));
run(&mut s, "edit.undo", json!({}));
assert!(sk(&s).point_index(&corner).is_none());
run(&mut s, "edit.redo", json!({}));
assert!(sk(&s).point_index(&corner).is_some());
// A fillet without anything measuring to its corner leaves no virtual corner behind.
let mut s = new_sketch();
run(&mut s, "sketch.line", json!({"points": [[10, 10], [10, 100], [-80, 100]], "ids": ["riser", "top"]}));
let n = sk(&s).points.len();
let f = run(&mut s, "sketch.fillet", json!({"point": "riser.end", "radius": 20}));
assert_eq!(f["virtual_corners"], json!([]), "{f}");
// Corner gone, two tangent points and the arc's centre added.
assert_eq!(sk(&s).points.len(), n + 2);
}
/// Lines meeting almost straight or almost folded back: a fillet or chamfer with dimensions on
/// the corner either works or refuses with an error, never a panic.
#[test]
fn a_fillet_of_a_nearly_straight_or_folded_corner_with_dimensions_does_not_panic() {
for (x, y) in [(100.0, 0.05), (100.0, -0.05), (100.0, 1e-9), (-100.0, 0.05), (-100.0, 1e-9)] {
let mut s = new_sketch();
run(&mut s, "sketch.line", json!({"points": [[0, 0], [100, 0], [100.0 + x, y]], "ids": ["a", "b"]}));
run(&mut s, "sketch.dimension", json!({"entities": ["a"], "value": 100}));
for r in [0.01, 1.0, 50.0] {
let _ = s.execute("sketch.fillet", &json!({"point": "a.end", "radius": r}));
let _ = s.execute("sketch.chamfer.equal_distance", &json!({"point": "a.end", "distance": r}));
}
assert!(sk(&s).points.iter().all(|p| p.pos.is_finite()));
}
}
+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)"),
+116 -36
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"),
.params(concat!("point: corner shared by two curves (\"l1.end\") | points: [corners] (all rounded alike: one radius, the rest equal) | a, b: two lines or arcs meeting at a corner; radius: expr. A corner that dimensions or constraints measure to stays as a virtual sharp corner on both curves' extensions (`virtual_corners`; the moved ones in `moved_to_virtual_corner`)", created_doc!())),
CommandSpec::new("sketch.chamfer.equal_distance", "Equal Distance Chamfer", chamfer_equal)
.at("SKETCH", "MODIFY")
.icon("sketch_chamfer")
.enabled(in_sketch)
.params("point | a, b: a corner of two lines; distance"),
.params(concat!("point | a, b: a corner of two lines; distance", created_doc!())),
CommandSpec::new("sketch.chamfer.distance_angle", "Distance and Angle Chamfer", chamfer_dist_angle)
.at("SKETCH", "MODIFY")
.icon("sketch_chamfer")
.enabled(in_sketch)
.params("point | a, b: a corner of two lines; distance: along a, angle: deg from a"),
.params(concat!("point | a, b: a corner of two lines; distance: along a, angle: deg from a", created_doc!())),
CommandSpec::new("sketch.chamfer.two_distance", "Two Distance Chamfer", chamfer_two)
.at("SKETCH", "MODIFY")
.icon("sketch_chamfer")
.enabled(in_sketch)
.params("point | a, b: a corner of two lines; distance: along a, distance2: along b"),
.params(concat!("point | a, b: a corner of two lines; distance: along a, distance2: along b", created_doc!())),
CommandSpec::new("sketch.offset", "Offset", offset)
.at("SKETCH", "MODIFY")
.icon("offset")
.key("O")
.enabled(in_sketch)
.params("curves: [ids], distance: number (positive = left of the chain, or toward `side`), side?: [x,y], chain?: bool (default true: the connected chain)"),
.params(concat!("curves: [ids], distance: number (positive = left of the chain, or toward `side`), side?: [x,y], chain?: bool (default true: the connected chain)", created_doc!())),
CommandSpec::new("sketch.offset.break", "Break Offset Association", break_offset)
.enabled(in_sketch)
.params("curve: derived offset curve id (retains geometry and makes its points editable)"),
@@ -50,7 +50,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.icon("trim")
.key("T")
.enabled(in_sketch)
.params("curve: id, at: [x,y] (the piece to remove: between the nearest intersections around it)"),
.params(concat!("curve: id, at: [x,y] (the piece to remove: between the nearest intersections around it)", created_doc!())),
CommandSpec::new("sketch.extend", "Extend", extend)
.at("SKETCH", "MODIFY")
.icon("extend")
@@ -60,7 +60,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.at("SKETCH", "MODIFY")
.icon("break")
.enabled(in_sketch)
.params("curve: id, at: [x,y] (split at intersections) | points: [[x,y]…] (1…1000 positions projected onto the curve; duplicates are merged)"),
.params(concat!("curve: id, at: [x,y] (split at intersections) | points: [[x,y]…] (1…1000 positions projected onto the curve; duplicates are merged)", created_doc!())),
CommandSpec::new("sketch.scale", "Sketch Scale", scale)
.at("SKETCH", "MODIFY")
.icon("scale")
@@ -68,22 +68,22 @@ pub static COMMANDS: &[CommandSpec] = &[
.params("entities: [curve/point ids], base: [x,y] or point ref, factor: number"),
CommandSpec::new("sketch.move", "Move/Copy Sketch Entities", move_copy)
.enabled(in_sketch)
.params("entities: [ids], translate?: [dx,dy], angle?: deg, center?: [x,y], copy?: bool"),
.params(concat!("entities: [ids], translate?: [dx,dy], angle?: deg, center?: [x,y], copy?: bool", created_doc!())),
CommandSpec::new("sketch.mirror", "Mirror", mirror)
.at("SKETCH", "CREATE")
.icon("sketch_mirror")
.enabled(in_sketch)
.params("entities: [curve/point ids], line: mirror line id (symmetry constraints are added)"),
.params(concat!("entities: [curve/point ids], line: mirror line id (symmetry constraints are added)", created_doc!())),
CommandSpec::new("sketch.pattern.circular", "Circular Pattern", pattern_circular)
.at("SKETCH", "CREATE")
.icon("sketch_circ_pattern")
.enabled(in_sketch)
.params("entities: [ids], center: [x,y] or point ref, count: 2…500, angle?: total deg (default 360)"),
.params(concat!("entities: [ids], center: [x,y] or point ref, count: 2…500, angle?: total deg (default 360)", created_doc!())),
CommandSpec::new("sketch.pattern.rectangular", "Rectangular Pattern", pattern_rect)
.at("SKETCH", "CREATE")
.icon("sketch_rect_pattern")
.enabled(in_sketch)
.params("entities: [ids], dir?: [x,y] (default x), count, spacing; count2?, spacing2? (perpendicular direction)"),
.params(concat!("entities: [ids], dir?: [x,y] (default x), count, spacing; count2?, spacing2? (perpendicular direction)", created_doc!())),
];
const TOL: f64 = 1e-7;
@@ -505,8 +505,71 @@ fn reattach(sk: &mut Sketch, l: usize, corner: usize, np: usize) {
}
}
/// What became of a rounded or cut corner's point: kept as a virtual sharp corner (its id),
/// and the constraints and dimensions that now measure to it.
#[derive(Default)]
struct CornerKept {
virtual_corner: Option<String>,
moved: Vec<String>,
}
/// Keep corner point `c` of curves `ca`, `cb` (whose corner ends were just moved to `na`, `nb`)
/// when anything measures to it: a constraint or dimension on the point, the length of either
/// line, or it is the origin or still ends another curve. It stays as a virtual sharp corner,
/// held on both curves' extensions (their intersection), and the line lengths become distances
/// from the far end to it, so the part keeps its size, as in Fusion. Otherwise None: the
/// caller removes the point.
fn keep_corner(sk: &mut Sketch, c: usize, [(ca, na), (cb, nb)]: [(usize, usize); 2]) -> Result<Option<CornerKept>> {
let used = sk.curves.iter().any(|x| uses(x, c));
let mut moved: Vec<String> = sk.constraints.iter().filter(|k| k.kind.refers_to_point(c)).map(|k| k.id.clone()).collect();
for k in sk.constraints.iter_mut() {
let ConstraintKind::Length { l, value } = k.kind else { continue };
let new_end = if l == ca {
na
} else if l == cb {
nb
} else {
continue;
};
let Some(CurveKind::Line { a, b }) = sk.curves.get(l).map(|x| &x.kind) else { continue };
// Same direction as the line, so the dimension keeps its side and text place.
k.kind = if *a == new_end { ConstraintKind::Distance { p: c, q: *b, value } } else { ConstraintKind::Distance { p: *a, q: c, value } };
moved.push(k.id.clone());
}
if moved.is_empty() && !used && c != 0 {
return Ok(None);
}
for cu in [ca, cb] {
let k = ConstraintKind::PointOnCurve { p: c, c: cu };
if !sk.constraints.iter().any(|x| x.kind == k) {
add_c(sk, k)?;
}
}
// A point of its own now: `l1.end` names the line's new end, not the virtual corner.
let virtual_corner = if used || c == 0 {
None
} else {
let id = sk.fresh("corner");
if let Some(pt) = sk.points.get_mut(c) {
pt.id = id.clone();
}
Some(id)
};
Ok(Some(CornerKept { virtual_corner, moved }))
}
/// Points at distances `da` along a and `db` along b from the corner; lines shortened to them.
fn cut_corner(sk: &mut Sketch, la: usize, lb: usize, c: usize, fa: usize, fb: usize, da: f64, db: f64, cmd: &str) -> Result<(usize, usize, Vec2)> {
fn cut_corner(
sk: &mut Sketch,
la: usize,
lb: usize,
c: usize,
fa: usize,
fb: usize,
da: f64,
db: f64,
cmd: &str,
) -> Result<(usize, usize, Vec2, CornerKept)> {
let (pc, pa, pb) = (sk.point(c).unwrap_or_default(), sk.point(fa).unwrap_or_default(), sk.point(fb).unwrap_or_default());
if !(da > 1e-9 && db > 1e-9 && da < pc.dist(pa) - 1e-9 && db < pc.dist(pb) - 1e-9) {
return Err(bad(cmd, "too large for these lines"));
@@ -517,13 +580,14 @@ fn cut_corner(sk: &mut Sketch, la: usize, lb: usize, c: usize, fa: usize, fb: us
let tb = sk.add_point(pc + v * db, None)?;
reattach(sk, la, c, ta);
reattach(sk, lb, c, tb);
let used = sk.curves.iter().any(|x| uses(x, c));
if !used && c != 0 {
let kept = keep_corner(sk, c, [(la, ta), (lb, tb)])?;
let removed = kept.is_none();
if removed {
sk.remove_points(&[c]);
}
// Indices may have shifted when the corner point was removed.
let fix = |i: usize| if !used && c != 0 && i > c { i - 1 } else { i };
Ok((fix(ta), fix(tb), pc))
// Indices shift when the corner point was removed.
let fix = |i: usize| if removed && i > c { i - 1 } else { i };
Ok((fix(ta), fix(tb), pc, kept.unwrap_or_default()))
}
fn fillet(s: &mut Session, p: &Value) -> Result<Value> {
@@ -541,9 +605,10 @@ fn fillet(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(cmd, "radius must be positive"));
}
let pname = doc.new_model_param(&r_expr, "mm");
let (mut arcs, mut cons) = (Vec::new(), Vec::new());
let (mut arcs, mut cons, mut kept) = (Vec::new(), Vec::new(), Vec::new());
for q in &corners {
let (arc, mut c) = fillet_corner(sk, q, r, cmd)?;
let (arc, mut c, k) = fillet_corner(sk, q, r, cmd)?;
kept.push(k);
// One radius for them all: the first carries the dimension, the rest are equal to it.
match arcs.first() {
None => c.push(sk.add_constraint(ConstraintKind::Radius { c: arc, value: r }, Some(pname.clone()))?),
@@ -552,14 +617,27 @@ fn fillet(s: &mut Session, p: &Value) -> Result<Value> {
cons.extend(c);
arcs.push(arc);
}
Ok((ids_of(sk, &arcs), cons, pname))
Ok((ids_of(sk, &arcs), cons, pname, kept_json(kept)))
})?;
Ok(json!({"curves": out.0, "constraints": out.1, "param": out.2, "sketch": info}))
let (vc, moved) = out.3;
Ok(json!({"curves": out.0, "constraints": out.1, "param": out.2, "virtual_corners": vc, "moved_to_virtual_corner": moved, "sketch": info}))
}
/// The virtual corners kept and the constraints moved to them, for a response.
fn kept_json(kept: Vec<CornerKept>) -> (Vec<String>, Vec<String>) {
let vcs = kept.iter().filter_map(|k| k.virtual_corner.clone()).collect();
let mut moved: Vec<String> = Vec::new();
for id in kept.into_iter().flat_map(|k| k.moved) {
if !moved.contains(&id) {
moved.push(id);
}
}
(vcs, moved)
}
/// Round one corner (`point` or `a`, `b`) with an arc of radius `r` tangent to both curves:
/// the arc and the tangent constraints.
fn fillet_corner(sk: &mut Sketch, p: &Value, r: f64, cmd: &str) -> Result<(usize, Vec<String>)> {
/// the arc, the tangent constraints and what became of the corner point.
fn fillet_corner(sk: &mut Sketch, p: &Value, r: f64, cmd: &str) -> Result<(usize, Vec<String>, CornerKept)> {
let (la, lb, c, fa, fb) = match corner(sk, p, cmd) {
Ok(x) => x,
// Not two lines: a corner with an arc.
@@ -578,7 +656,7 @@ fn fillet_corner(sk: &mut Sketch, p: &Value, r: f64, cmd: &str) -> Result<(usize
}
let bis = (u + v).normalized().ok_or_else(|| bad(cmd, "the lines are parallel"))?;
let center = pc + bis * (r / half.sin());
let (ta, tb, _) = cut_corner(sk, la, lb, c, fa, fb, d, d, cmd)?;
let (ta, tb, _, kept) = cut_corner(sk, la, lb, c, fa, fb, d, d, cmd)?;
let (qa, qb) = (sk.point(ta).unwrap_or_default(), sk.point(tb).unwrap_or_default());
let ccw = (qa - center).cross(qb - center) > 0.0;
let arc = if ccw {
@@ -587,13 +665,13 @@ fn fillet_corner(sk: &mut Sketch, p: &Value, r: f64, cmd: &str) -> Result<(usize
sk.add_arc(center, qb, qa, [None, Some(tb), Some(ta)], None)?
};
let cons = vec![add_c(sk, ConstraintKind::Tangent { a: la, b: arc })?, add_c(sk, ConstraintKind::Tangent { a: lb, b: arc })?];
Ok((arc, cons))
Ok((arc, cons, kept))
}
/// Fillet a corner where a line meets an arc, or two arcs meet: the arc of radius `r` touching
/// both, nearest the corner; both curves are cut back to it. None when the corner is not of
/// that kind.
fn round_corner(sk: &mut Sketch, p: &Value, r: f64, cmd: &str) -> Result<Option<(usize, Vec<String>)>> {
fn round_corner(sk: &mut Sketch, p: &Value, r: f64, cmd: &str) -> Result<Option<(usize, Vec<String>, CornerKept)>> {
let ends = |c: usize| match sk.curves.get(c).map(|c| &c.kind) {
Some(CurveKind::Line { a, b }) | Some(CurveKind::Arc { a, b, .. }) => Some((*a, *b)),
_ => None,
@@ -655,14 +733,15 @@ fn round_corner(sk: &mut Sketch, p: &Value, r: f64, cmd: &str) -> Result<Option<
let arc =
if ccw { sk.add_arc(cen, ta, tb, [None, Some(na), Some(nb)], None)? } else { sk.add_arc(cen, tb, ta, [None, Some(nb), Some(na)], None)? };
let cons = vec![add_c(sk, ConstraintKind::Tangent { a: ca, b: arc })?, add_c(sk, ConstraintKind::Tangent { a: cb, b: arc })?];
let used = sk.curves.iter().any(|x| x.kind.uses(q));
if !used && q != 0 {
let arc_id = sk.curves.get(arc).map(|c| c.id.clone()).unwrap_or_default();
sk.remove_points(&[q]);
let arc = sk.curve_index(&arc_id).unwrap_or(arc);
return Ok(Some((arc, cons)));
match keep_corner(sk, q, [(ca, na), (cb, nb)])? {
Some(kept) => Ok(Some((arc, cons, kept))),
None => {
let arc_id = sk.curves.get(arc).map(|c| c.id.clone()).unwrap_or_default();
sk.remove_points(&[q]);
let arc = sk.curve_index(&arc_id).unwrap_or(arc);
Ok(Some((arc, cons, CornerKept::default())))
}
}
Ok(Some((arc, cons)))
}
fn chamfer(s: &mut Session, p: &Value, cmd: &str, dists: impl FnOnce(f64) -> Result<(f64, f64)>) -> Result<Value> {
@@ -672,11 +751,12 @@ fn chamfer(s: &mut Session, p: &Value, cmd: &str, dists: impl FnOnce(f64) -> Res
let (u, v) = ((pa - pc).normalized().unwrap_or(Vec2::X), (pb - pc).normalized().unwrap_or(Vec2::Y));
let corner_angle = u.cross(v).atan2(u.dot(v)).abs();
let (da, db) = dists(corner_angle)?;
let (ta, tb, _) = cut_corner(sk, la, lb, c, fa, fb, da, db, cmd)?;
let (ta, tb, _, kept) = cut_corner(sk, la, lb, c, fa, fb, da, db, cmd)?;
let l = sk.add_line_pts(ta, tb, None)?;
Ok(ids_of(sk, &[l]))
Ok((ids_of(sk, &[l]), kept_json(vec![kept])))
})?;
Ok(json!({"curves": out, "sketch": info}))
let (curves, (vc, moved)) = out;
Ok(json!({"curves": curves, "virtual_corners": vc, "moved_to_virtual_corner": moved, "sketch": info}))
}
fn chamfer_equal(s: &mut Session, p: &Value) -> Result<Value> {
+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() {
+1 -1
View File
@@ -82,7 +82,7 @@ fn classify(e: &EngineError) -> Outcome {
match e {
EngineError::Internal(..) => Outcome::new("panic", msg),
EngineError::BadParams { .. } | EngineError::Disabled(..) | EngineError::UnknownCommand(_) => Outcome::new("rejected", msg),
// A failed boolean is a failure even when it says "not supported" (coincident faces on
// A failed boolean is a failure even when it says "not supported" (the planar fallback on
// curved bodies): those are the cases to fix. Features the kernel doesn't do yet (a
// fillet between curved faces) are limitations.
// Picks the generator got wrong (a centroid off its face).
+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;
+40
View File
@@ -419,6 +419,46 @@ fn sample_edges_chain_and_faces() {
assert!(m.edge_faces.iter().all(|f| !f.is_empty()));
}
/// timeline.inspect returns a feature's definition in the shape timeline.edit's `set` takes, so
/// an agent can read the field names instead of guessing them.
#[test]
fn timeline_inspect_shows_what_timeline_edit_changes() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Base"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 30]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 10, "name": "Slab"}));
run(&mut s, "solid.fillet", json!({"edges": [[20, 0, 10]], "radius": 2, "name": "Round"}));
let i = run(&mut s, "timeline.inspect", json!({"feature": "Slab"}));
assert_eq!(i["type"], "ExtrudeFeature", "{i}");
assert_eq!(i["definition"]["extent"]["distance"], "10", "{i}");
assert_eq!(i["definition"]["operation"], "new_body", "{i}");
assert!(i["definition"].get("type").is_none() && i["definition"].get("param_names").is_none(), "{i}");
assert_eq!(i["inputs"][0]["label"], "Distance", "{i}");
assert_eq!(i["inputs"][0]["unit"], "mm", "{i}");
let d = i["inputs"][0]["param"].as_str().unwrap().to_string();
// The definition's field names are what timeline.edit takes.
run(&mut s, "timeline.edit", json!({"feature": "Slab", "set": {"extent": {"distance": "12"}}}));
let i = run(&mut s, "timeline.inspect", json!({"feature": i["id"]}));
assert_eq!(i["definition"]["extent"]["distance"], "12", "{i}");
assert_eq!(i["inputs"][0]["param"], d.as_str(), "{i}");
let f = run(&mut s, "timeline.inspect", json!({"feature": "Round"}));
assert_eq!(f["definition"]["radius"], "2", "{f}");
run(&mut s, "timeline.edit", json!({"feature": "Round", "set": {"radius": "3"}}));
assert_eq!(run(&mut s, "timeline.inspect", json!({"feature": "Round"}))["definition"]["radius"], "3");
// A sketch leaves its geometry out (sketch.inspect has it) and says so.
let k = run(&mut s, "timeline.inspect", json!({"feature": "Base"}));
assert!(k["definition"].get("sketch").is_none() && k["definition"].get("plane").is_some(), "{k}");
assert_eq!(k["omitted"][0]["field"], "sketch", "{k}");
// Read-only: no undo step; bad input is refused.
let undo = s.undo.len();
run(&mut s, "timeline.inspect", json!({"feature": "Slab"}));
assert_eq!(s.undo.len(), undo);
for bad in [json!({}), json!({"feature": "Nope"}), json!({"feature": [1]}), json!({"feature": 1e300}), json!({"feature": null})] {
assert!(s.execute("timeline.inspect", &bad).is_err(), "{bad}");
}
}
#[test]
fn timeline_edits_reresolve_references() {
let mut s = Session::default();
+79 -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.
@@ -95,6 +97,13 @@ pub fn sketch_lines(sk: &Sketch, plane: &Plane, active: bool, determined: &[bool
out.push((seg.polyline(0.02).iter().map(|p| plane.to_world(*p)).collect(), col, c.construction || c.centerline));
}
}
// Virtual sharp corners (kept by fillets and chamfers): thin extension lines from the
// curves to the corner, like construction geometry.
for (_, ext) in sk.virtual_corners() {
for (a, b) in ext {
out.push((vec![plane.to_world(a), plane.to_world(b)], colors::SKETCH_CONSTRUCTION, true));
}
}
// 3D curves are in world coordinates already.
for w in sk.wires.iter().filter(|w| !w.fit.is_empty() || !sk.view.hide_projected) {
// Curves drawn in a 3D sketch look like the sketch's own; included ones are references.
@@ -119,10 +128,23 @@ pub fn sketch_point_visible(sk: &Sketch, i: usize) -> bool {
}
}
/// The scene for a headless render.
/// The scene for a headless render, cut by the session's section.
pub fn scene(s: &Session, cam: &Camera) -> Scene {
scene_with_section(s, cam, s.section)
}
/// The scene for a headless render, cut by `section` (origin, normal) instead of the session's:
/// the app passes the cut its viewport shows (an open Section Analysis dialog's preview, a
/// sketch's Slice), so model renders match the window.
pub fn scene_with_section(s: &Session, cam: &Camera, section: Option<(Vec3, Vec3)>) -> Scene {
let st = s.world_state();
let mut sc = Scene { background: Some((colors::BG_TOP, colors::BG_BOTTOM)), clip: s.section.map(|(o, n)| (n, n.dot(o))), ..Default::default() };
let section = section.filter(|(o, n)| o.is_finite() && n.is_finite() && n.len() > 1e-9);
let mut sc = Scene {
background: Some((colors::BG_TOP, colors::BG_BOTTOM)),
clip: section.map(|(o, n)| (n, n.dot(o))),
cap: Some(colors::SECTION_CAP),
..Default::default()
};
let b = bounds(s);
sc.radius = (b.diagonal() * 0.5).max(cam.half_height()).max(10.0) + b.center().dist(cam.target);
// Grid on the ground plane (XY with Z up, XZ with Y up).
@@ -181,6 +203,12 @@ pub fn scene(s: &Session, cam: &Camera) -> Scene {
}
sc.lines.push(SceneLine { points: e.clone(), color: colors::EDGE, width: 1.2, on_top: false });
}
// The outline of the body where the section cuts it.
if let Some((o, n)) = section {
for (a, b) in section_segments(&mesh, o, n) {
sc.lines.push(SceneLine { points: vec![a, b], color: colors::EDGE, width: 1.6, on_top: false });
}
}
}
for ss in &st.sketches {
let active = s.active_sketch == Some(ss.feature);
@@ -194,6 +222,28 @@ pub fn scene(s: &Session, cam: &Camera) -> Scene {
sc
}
/// Where the plane through `o` with normal `n` cuts a mesh: one segment per crossed triangle,
/// nudged a hair to the kept side (so the clip leaves them drawn).
pub fn section_segments(m: &solvecraft_geom::Mesh, o: Vec3, n: Vec3) -> Vec<(Vec3, Vec3)> {
let eps = 1e-4 * (1.0 + m.bounds().diagonal());
let mut out = Vec::new();
for t in &m.triangles {
let Some(p) = m.tri(t) else { continue };
let d = p.map(|q| n.dot(q - o));
let mut pts = Vec::with_capacity(2);
for (i, j) in [(0, 1), (1, 2), (2, 0)] {
if (d[i] < 0.0) != (d[j] < 0.0) {
let s = d[i] / (d[i] - d[j]);
pts.push(p[i] + (p[j] - p[i]) * s - n * eps);
}
}
if let [a, b] = pts[..] {
out.push((a, b));
}
}
out
}
/// Is a sketch drawn? The active one is; others unless hidden. Finished sketches are hidden
/// once a feature uses them, like Fusion's default, unless shown one by one.
pub fn sketch_shown(s: &Session, id: u64) -> bool {
@@ -324,4 +374,31 @@ mod tests {
let changed = whole.chunks(4).zip(cut.chunks(4)).filter(|(a, b)| a != b).count();
assert!(changed > 500, "{changed} pixels changed");
}
/// The section's cut face hides what lies inside the body behind it (it is drawn at the
/// plane's depth, not on the far wall like a pane of glass), shows in the cap colour, and
/// the cut's outline is drawn.
#[test]
fn snapshot_section_caps_hide_the_inside() {
let mut s = Session::default();
s.execute("solid.box", &json!({"length": 40, "width": 30, "height": 20})).unwrap();
// Cut away the front half (y < 15), so the iso camera looks into the cut.
s.execute("inspect.section", &json!({"plane": {"origin": [0, 15, 0], "normal": [0, -1, 0]}})).unwrap();
for fov in [0.0, 0.6] {
let mut cam = home_camera(&s);
cam.fov = fov;
let mut sc = scene(&s, &cam);
assert!(!section_segments(&s.world_state().bodies[0].mesh(), Vec3::new(0.0, 15.0, 0.0), -Vec3::Y).is_empty());
// A marker inside the body, behind the cut face.
let red = Rgb(255, 0, 0);
sc.lines.push(SceneLine { points: vec![Vec3::new(5.0, 25.0, 10.0), Vec3::new(35.0, 25.0, 10.0)], color: red, width: 3.0, on_top: false });
let img = solvecraft_render::render(&sc, &cam, 300, 220);
let px = || img.rgba.chunks(4);
let marker = px().filter(|p| p[0] > 200 && p[1] < 60 && p[2] < 60).count();
assert_eq!(marker, 0, "fov {fov}: the inside shows through the cut face");
// The cap: warm (red over blue), unlike the grey-blue body and background.
let cap = px().filter(|p| p[0] as i32 - p[2] as i32 > 20).count();
assert!(cap > 500, "fov {fov}: cap pixels {cap}");
}
}
}
+17 -5
View File
@@ -785,7 +785,8 @@ fn resample(lp: &Loop2, n: usize, angles: &[f64]) -> Loop2 {
Loop2 { segs }
}
/// Ruled loft through two or more planar sections (outer loops only).
/// Ruled loft through two or more planar sections (outer loops only). The sections and the
/// same sections in reverse order give the same body.
pub fn loft(sections: &[(Plane, Loop2)]) -> Result<Body> {
if sections.len() < 2 || sections.len() > 50 {
return Err(KernelError::Invalid("a loft needs 2…50 sections".into()));
@@ -794,10 +795,21 @@ pub fn loft(sections: &[(Plane, Loop2)]) -> Result<Body> {
if n == 0 || n > MAX_SEGS {
return Err(KernelError::Invalid("empty section".into()));
}
// Orient every section counter-clockwise about the first section's normal, then match
// segment counts and starting points to the section with the most segments.
let n0 = sections[0].0.normal();
let oriented: Vec<(Plane, Loop2)> = sections
// A canonical order, so that reversing the sections changes nothing: the one whose
// first-to-last step has a positive largest component. The side faces' parameterisation
// follows the order, and the boolean worked on one and failed on the other (a cut whose
// loft touches the body along an edge: an internal panic, or "shell not oriented and
// closed").
let centre = |(p, l): &(Plane, Loop2)| p.to_world(l.centroid());
let (Some(first), Some(last)) = (sections.first(), sections.last()) else { return Err(KernelError::Invalid("no sections".into())) };
let step = centre(last) - centre(first);
let big = [step.x, step.y, step.z].into_iter().max_by(|a, b| a.abs().total_cmp(&b.abs())).unwrap_or(0.0);
let ordered: Vec<&(Plane, Loop2)> = if big < 0.0 { sections.iter().rev().collect() } else { sections.iter().collect() };
// Orient every section counter-clockwise about the first section's normal (`Body::new`
// turns the solid outward), then match segment counts and starting points to the section
// with the most segments.
let n0 = ordered.first().map(|(p, _)| p.normal()).unwrap_or(Vec3::Z);
let oriented: Vec<(Plane, Loop2)> = ordered
.iter()
.map(|(p, l)| {
let l = if p.normal().dot(n0) >= 0.0 { l.ccw() } else { l.ccw().reversed() };
+222 -30
View File
@@ -2,8 +2,10 @@
//! faces). Each face moves along its outward normal by its own distance: a plane is translated,
//! a cylinder changes radius about its axis. Every vertex and edge goes to where its faces'
//! moved surfaces meet; edges are lines or circular arcs. This is exact when the moved
//! surfaces meet the way the old ones did (prismatic parts, bosses, fillets between walls);
//! anything else is refused, so callers can fall back.
//! surfaces meet the way the old ones did (prismatic parts, bosses, fillets between walls).
//! A corner where more than three planes meet, whose moved planes miss a common point (a
//! floor falling to a drain, a pyramid's apex), becomes several corners joined by short edges.
//! Anything else is refused, so callers can fall back.
use std::collections::HashMap;
@@ -422,6 +424,143 @@ fn span_step(ns: &[Vec3], s: &[f64]) -> Option<Vec3> {
Some(v)
}
/// Most faces round one corner that [`split_corner`] takes on.
const MAX_SPLIT_FACES: usize = 48;
/// The corners in place of a corner where more than three planar faces meet and whose moved
/// planes miss a common point (a hip floor's corner, a pyramid's apex): each new corner with
/// the faces through it. Corners sharing two faces are joined by a new edge on those faces.
/// Only corners whose edges are all convex or all concave: the faces there bound a convex
/// region (the body's side or the outside's), and after the move its corners are the points on
/// three moved planes inside all the others.
fn split_corner(
v: &mt::Vertex,
at: &[usize],
faces: &[mt::Face],
efaces: &HashMap<mt::EdgeID, Vec<usize>>,
old: &[Surf],
new: &[Surf],
size: f64,
) -> Option<Vec<(Vec3, Vec<usize>)>> {
if at.len() < 4 || at.len() > MAX_SPLIT_FACES {
return None;
}
let p = from_p3(v.point());
// (face, outward normal, old offset, moved offset).
let planes: Vec<(usize, Vec3, f64, f64)> = at
.iter()
.map(|i| match (old.get(*i)?, new.get(*i)?) {
(Surf::Plane { n, d }, Surf::Plane { d: d2, .. }) => Some((*i, *n, *d, *d2)),
_ => None,
})
.collect::<Option<_>>()?;
let normal = |fi: usize| planes.iter().find(|x| x.0 == fi).map(|x| x.1);
// Edge kinds round the corner: a face's loop runs counter-clockwise about its outward
// normal, so its inside is to the left; the edge is convex when the face beyond it falls
// away from there.
let mut sign = 0.0f64;
let mut pairs: Vec<(usize, usize)> = Vec::new();
for &(fi, na, ..) in &planes {
for w in faces.get(fi)?.boundaries() {
for e in w.edge_iter() {
if e.front().id() != v.id() && e.back().id() != v.id() {
continue;
}
let fb = efaces.get(&e.id())?.iter().copied().find(|j| *j != fi)?;
pairs.push((fi, fb));
let dir = (from_p3(e.back().point()) - from_p3(e.front().point())).normalized()?;
let s = normal(fb)?.dot(na.cross(dir));
if s.abs() < 1e-9 {
continue;
}
if sign * s < 0.0 {
return None;
}
sign = s.signum();
}
}
}
if sign == 0.0 {
return None;
}
// Convex: the body's side, n·x ≤ d; concave: the outside's, n·x ≥ d.
let side = -sign;
let merge = size * 1e-6;
let mut nodes: Vec<(Vec3, Vec<usize>)> = Vec::new();
for (ia, a) in planes.iter().enumerate() {
for (ib, b) in planes.iter().enumerate().skip(ia + 1) {
for c in planes.iter().skip(ib + 1) {
let den = a.1.dot(b.1.cross(c.1));
if den.abs() < 1e-9 {
continue;
}
let x = (b.1.cross(c.1) * a.3 + c.1.cross(a.1) * b.3 + a.1.cross(b.1) * c.3) * (1.0 / den);
if !x.is_finite() || x.dist(p) > size || planes.iter().any(|q| side * (q.1.dot(x) - q.3) > merge) {
continue;
}
let fs = [a.0, b.0, c.0];
match nodes.iter_mut().find(|(q, _)| q.dist(x) < merge) {
Some((_, nf)) => {
for f in fs {
if !nf.contains(&f) {
nf.push(f);
}
}
}
None => nodes.push((x, fs.to_vec())),
}
}
}
}
// Every face keeps a corner here, and every edge an end.
if planes.iter().any(|q| !nodes.iter().any(|(_, nf)| nf.contains(&q.0)))
|| pairs.iter().any(|(x, y)| !nodes.iter().any(|(_, nf)| nf.contains(x) && nf.contains(y)))
{
return None;
}
// Each corner where its planes meet best (least squares, for planes meeting at one point
// to within the merge distance).
for (q, nf) in nodes.iter_mut() {
let (ns, ss): (Vec<Vec3>, Vec<f64>) = nf.iter().filter_map(|f| planes.iter().find(|x| x.0 == *f)).map(|x| (x.1, x.3 - x.2)).unzip();
*q = p + span_step(&ns, &ss)?;
}
Some(nodes)
}
/// The corners of a split corner (see [`split_corner`]) on face `face`, in order from corner
/// `from` to corner `to`, along the new edges between them.
fn split_path(nodes: &[(Vec3, Vec<usize>)], face: usize, from: usize, to: usize) -> Option<Vec<usize>> {
let joined = |x: usize, y: usize| {
let (Some((_, a)), Some((_, b))) = (nodes.get(x), nodes.get(y)) else { return false };
a.contains(&face) && b.contains(&face) && a.iter().filter(|f| b.contains(f)).count() >= 2
};
// Breadth first; `prev` leads back to `from`.
let mut prev: Vec<Option<usize>> = vec![None; nodes.len()];
let mut queue = std::collections::VecDeque::from([from]);
let mut seen = vec![false; nodes.len()];
*seen.get_mut(from)? = true;
while let Some(x) = queue.pop_front() {
if x == to {
let mut path = vec![to];
let mut cur = to;
while let Some(p) = prev.get(cur).copied().flatten() {
path.push(p);
cur = p;
}
path.reverse();
return Some(path);
}
for y in 0..nodes.len() {
if !seen.get(y).copied().unwrap_or(true) && joined(x, y) {
*seen.get_mut(y)? = true;
*prev.get_mut(y)? = Some(x);
queue.push_back(y);
}
}
}
None
}
/// A line or circular arc through an edge (by samples): `None` for other curves.
/// Maximum straight edges per face checked pairwise for crossings.
const MAX_CROSSING_EDGES: usize = 2000;
@@ -431,7 +570,7 @@ const MAX_CROSSING_EDGES: usize = 2000;
fn crossed_loops(
faces: &[mt::Face],
old: &[Surf],
newpos: &HashMap<mt::VertexID, Vec3>,
pos: &dyn Fn(&mt::Vertex, &mt::Edge) -> Option<Vec3>,
edge_collapsed: &dyn Fn(&mt::Edge) -> bool,
size: f64,
) -> Option<Vec3> {
@@ -443,32 +582,32 @@ fn crossed_loops(
}
let (u, w) = (n.any_perp(), n.cross(n.any_perp()));
let flat = |p: Vec3| (p.dot(u), p.dot(w));
// Straight edges as (vertex ids, old ends, new ends) in the plane.
let mut segs: Vec<([mt::VertexID; 2], [(f64, f64); 2])> = Vec::new();
// Straight edges as (vertex ids, new ends in the plane, the plane's new offset).
let mut segs: Vec<([mt::VertexID; 2], [(f64, f64); 2], f64)> = Vec::new();
for wire in f.absolute_boundaries() {
let (mut area_old, mut area_new, mut straight) = (0.0, 0.0, true);
for e in wire.edge_iter() {
let (v0, v1) = (e.front(), e.back());
let (Some(q0), Some(q1)) = (newpos.get(&v0.id()), newpos.get(&v1.id())) else { return None };
let (o0, o1, n0, n1) = (flat(from_p3(v0.point())), flat(from_p3(v1.point())), flat(*q0), flat(*q1));
let (Some(q0), Some(q1)) = (pos(v0, e), pos(v1, e)) else { return None };
let (o0, o1, n0, n1) = (flat(from_p3(v0.point())), flat(from_p3(v1.point())), flat(q0), flat(q1));
area_old += o0.0 * o1.1 - o1.0 * o0.1;
area_new += n0.0 * n1.1 - n1.0 * n0.1;
if arc_mid(e, size * 1e-6) == Some(None) {
segs.push(([v0.id(), v1.id()], [n0, n1]));
segs.push(([v0.id(), v1.id()], [n0, n1], n.dot(q0)));
} else {
straight = false;
}
}
if straight && area_old.abs() > eps && area_old * area_new <= 0.0 {
return wire.vertex_iter().next().and_then(|v| newpos.get(&v.id()).copied());
return wire.edge_iter().next().and_then(|e| pos(e.front(), e));
}
}
if segs.len() > MAX_CROSSING_EDGES {
continue;
}
let side = |a: (f64, f64), c: (f64, f64), p: (f64, f64)| (c.0 - a.0) * (p.1 - a.1) - (c.1 - a.1) * (p.0 - a.0);
for (i, (ia, [a0, a1])) in segs.iter().enumerate() {
for (ib, [b0, b1]) in segs.iter().skip(i + 1) {
for (i, (ia, [a0, a1], d)) in segs.iter().enumerate() {
for (ib, [b0, b1], _) in segs.iter().skip(i + 1) {
if ia.iter().any(|v| ib.contains(v)) {
continue;
}
@@ -478,8 +617,7 @@ fn crossed_loops(
if ((s0 > tol && s1 < -tol) || (s0 < -tol && s1 > tol)) && ((t0 > tol && t1 < -tol) || (t0 < -tol && t1 > tol)) {
let k = t0 / (t0 - t1);
let (x, y) = (a0.0 + (a1.0 - a0.0) * k, a0.1 + (a1.1 - a0.1) * k);
let d = n.dot(newpos.get(&ia[0]).copied().unwrap_or_default());
return Some(u * x + w * y + n * d);
return Some(u * x + w * y + n * *d);
}
}
}
@@ -618,6 +756,8 @@ pub(crate) fn offset_body(b: &Body, shift: impl Fn(usize, Vec3) -> f64) -> Resul
})
.collect();
let mut newpos: HashMap<mt::VertexID, Vec3> = HashMap::new();
// Corners whose moved faces meet at several points (see `split_corner`).
let mut splits: HashMap<mt::VertexID, Vec<(Vec3, Vec<usize>)>> = HashMap::new();
for v in b.solid.vertex_iter() {
let at = vfaces.get(&v.id()).ok_or_else(|| fail("vertex"))?;
let p = from_p3(v.point());
@@ -672,10 +812,33 @@ pub(crate) fn offset_body(b: &Body, shift: impl Fn(usize, Vec3) -> f64) -> Resul
});
let q = match apex {
Some(q) if n.iter().all(|sf| sf.dist(q).abs() < size * 1e-6) => q,
_ => move_point(p, &o, &s, &n, size * 1e-6).ok_or_else(|| fail("a corner whose faces don't meet the same way after the move"))?,
_ => match move_point(p, &o, &s, &n, size * 1e-6) {
Some(q) => q,
None => {
let nodes = (fs == *at)
.then(|| split_corner(&v, at, &faces, &efaces, &old, &new, size))
.flatten()
.ok_or_else(|| fail("a corner whose faces don't meet the same way after the move"))?;
let q = nodes.first().map(|x| x.0).ok_or_else(|| fail("corner"))?;
splits.insert(v.id(), nodes);
q
}
},
};
newpos.insert(v.id(), q);
}
// Which of a split corner's new corners an edge ends at.
let node_of = |v: mt::VertexID, e: mt::EdgeID| -> Option<usize> {
let fs = efaces.get(&e)?;
splits.get(&v)?.iter().position(|(_, nf)| fs.iter().all(|i| nf.contains(i)))
};
// Where an edge's end goes.
let end_pos = |v: &mt::Vertex, e: &mt::Edge| -> Option<Vec3> {
match splits.get(&v.id()) {
Some(nodes) => nodes.get(node_of(v.id(), e.id())?).map(|x| x.0),
None => newpos.get(&v.id()).copied(),
}
};
// An edge turned round: faces crossed over (a wall thinner than the move), which would
// leave the body inside out.
let edge_collapsed = |e: &mt::Edge| efaces.get(&e.id()).is_some_and(|fs| fs.iter().any(|i| is_collapsed(*i)));
@@ -684,8 +847,8 @@ pub(crate) fn offset_body(b: &Body, shift: impl Fn(usize, Vec3) -> f64) -> Resul
continue;
}
let (f0, f1) = (e.absolute_front(), e.absolute_back());
let (Some(q0), Some(q1)) = (newpos.get(&f0.id()), newpos.get(&f1.id())) else { continue };
let (old_v, new_v) = (from_p3(f1.point()) - from_p3(f0.point()), *q1 - *q0);
let (Some(q0), Some(q1)) = (end_pos(f0, &e), end_pos(f1, &e)) else { continue };
let (old_v, new_v) = (from_p3(f1.point()) - from_p3(f0.point()), q1 - q0);
if old_v.len() > size * 1e-6 && old_v.dot(new_v) < 0.0 {
return Err(fail("not supported yet: the move is larger than a wall or step is thick (faces would cross)"));
}
@@ -693,7 +856,7 @@ pub(crate) fn offset_body(b: &Body, shift: impl Fn(usize, Vec3) -> f64) -> Resul
// A plane's loops crossing after the move: a rim or ledge narrower than the move (the hole
// in a top face sliding past its outer edge) turns part of the face inside out, though no
// edge turns round.
if let Some(at) = crossed_loops(&faces, &old, &newpos, &edge_collapsed, size) {
if let Some(at) = crossed_loops(&faces, &old, &end_pos, &edge_collapsed, size) {
return Err(fail(&format!(
"not supported yet: the move is larger than a rim or ledge near [{:.1}, {:.1}, {:.1}] is wide (a face would turn inside out)",
at.x, at.y, at.z
@@ -725,6 +888,16 @@ pub(crate) fn offset_body(b: &Body, shift: impl Fn(usize, Vec3) -> f64) -> Resul
};
verts.insert(v.id(), nv);
}
let node_verts: HashMap<mt::VertexID, Vec<mt::Vertex>> =
splits.iter().map(|(id, nodes)| (*id, nodes.iter().map(|(q, _)| builder::vertex(p3(*q))).collect())).collect();
let vert_at = |v: &mt::Vertex, e: &mt::Edge| -> Option<&mt::Vertex> {
match node_verts.get(&v.id()) {
Some(nv) => nv.get(node_of(v.id(), e.id())?),
None => verts.get(&v.id()),
}
};
// New edges between a split corner's corners (stored from the lower index).
let mut links: HashMap<(mt::VertexID, usize, usize), mt::Edge> = HashMap::new();
// Old edge → new edge, and whether it runs the other way (merged with its twin).
let mut edges: HashMap<mt::EdgeID, (mt::Edge, bool)> = HashMap::new();
let mut merged: HashMap<(mt::VertexID, mt::VertexID), mt::Edge> = HashMap::new();
@@ -732,7 +905,7 @@ pub(crate) fn offset_body(b: &Body, shift: impl Fn(usize, Vec3) -> f64) -> Resul
if edges.contains_key(&e.id()) {
continue;
}
let (Some(a), Some(c)) = (verts.get(&e.absolute_front().id()), verts.get(&e.absolute_back().id())) else { return Err(fail("edge")) };
let (Some(a), Some(c)) = (vert_at(e.absolute_front(), &e), vert_at(e.absolute_back(), &e)) else { return Err(fail("edge")) };
let touches = edge_collapsed(&e);
if a == c || from_p3(a.point()).dist(from_p3(c.point())) < size * 1e-9 {
if touches {
@@ -796,18 +969,37 @@ pub(crate) fn offset_body(b: &Body, shift: impl Fn(usize, Vec3) -> f64) -> Resul
if is_collapsed(i) {
continue;
}
let wires: Vec<mt::Wire> = f
.absolute_boundaries()
.iter()
.map(|w| {
w.edge_iter()
.filter_map(|e| {
edges.get(&e.id()).map(|(ne, flip)| if (e.front() == e.absolute_front()) != *flip { ne.clone() } else { ne.inverse() })
})
.collect::<Vec<_>>()
.into()
})
.collect();
let mut wires: Vec<mt::Wire> = Vec::new();
for w in f.absolute_boundaries() {
let old_edges: Vec<mt::Edge> = w.edge_iter().cloned().collect();
let mut wire: Vec<mt::Edge> = Vec::new();
for (j, e) in old_edges.iter().enumerate() {
if let Some((ne, flip)) = edges.get(&e.id()) {
wire.push(if (e.front() == e.absolute_front()) != *flip { ne.clone() } else { ne.inverse() });
}
// At a split corner, along the new edges from this edge's end to the next's.
let v = e.back();
let (Some(nodes), Some(next)) = (splits.get(&v.id()), old_edges.get((j + 1) % old_edges.len())) else { continue };
let (Some(from), Some(to)) = (node_of(v.id(), e.id()), node_of(v.id(), next.id())) else { return Err(fail("split corner")) };
let path = split_path(nodes, i, from, to).ok_or_else(|| fail("split corner"))?;
for pair in path.windows(2) {
let (Some(&x), Some(&y)) = (pair.first(), pair.get(1)) else { continue };
let key = (v.id(), x.min(y), x.max(y));
let link = match links.get(&key) {
Some(l) => l.clone(),
None => {
let nv = node_verts.get(&v.id()).ok_or_else(|| fail("split corner"))?;
let (Some(va), Some(vb)) = (nv.get(key.1), nv.get(key.2)) else { return Err(fail("split corner")) };
let l = builder::line(va, vb);
links.insert(key, l.clone());
l
}
};
wire.push(if x < y { link } else { link.inverse() });
}
}
wires.push(wire.into());
}
let (Some(o), Some(n)) = (old.get(i), new.get(i)) else { return Err(fail("face")) };
let surface = match (o.map_to(n), *o, *n) {
(Some(mat), _, _) => mt::Transformed::transformed(&f.surface(), mat),
+6 -3
View File
@@ -259,9 +259,12 @@ fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
let size = a.size().max(b.size());
// The checks mesh both bodies; where they meet in a region much thinner than they are (a
// thin part trimmed by a long one), a step from their size would facet the small faces
// there coarsely enough to fail the membership test on a correct result.
// there coarsely enough to fail the membership test on a correct result. The meshes are
// refined: a round side trimmed to a thin band (a short boss crossing a gently sloping
// floor) is otherwise spanned by triangles cutting through it, which reads as missing
// material.
let tol_m = (size * 5e-4).min(meeting_size(a, b) * 2e-3).max(size * 1e-5);
let meshes = (a.tessellate(tol_m).ok(), b.tessellate(tol_m).ok());
let meshes = (a.tessellate_with(tol_m, true).ok(), b.tessellate_with(tol_m, true).ok());
let (va, vb) = match &meshes {
(Some(ma), Some(mb)) => (ma.measure().volume, mb.measure().volume),
_ => (volume(a), volume(b)),
@@ -270,7 +273,7 @@ fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
// Volume bounds, then membership against the operands (catches misclassified pieces), on
// one mesh of the result. Returns (plausible, volume).
let check = |r: &Body| -> (bool, f64) {
let Ok(mr) = r.tessellate(tol_m) else { return (false, f64::NAN) };
let Ok(mr) = r.tessellate_with(tol_m, true) else { return (false, f64::NAN) };
let v = mr.measure().volume;
let bounds = v > 0.0
&& match op {
+78 -14
View File
@@ -199,16 +199,72 @@ impl Node {
}
}
/// Polygons of a body whose faces are all planar (None otherwise).
fn planar_polys(b: &Body) -> Option<Vec<Poly>> {
/// Why a body's polygons can't be had: in words that finish "the {role} has …" or "the
/// {role}'s …".
#[derive(Debug, PartialEq)]
enum NotPlanar {
/// Faces on curved surfaces, counted by kind.
Curved(std::collections::BTreeMap<&'static str, usize>),
/// Something else (a face that could not be meshed, too many polygons).
Other(String),
}
impl NotPlanar {
fn describe(&self, role: &str) -> String {
match self {
NotPlanar::Curved(kinds) => {
let n: usize = kinds.values().sum();
let list: Vec<&str> = kinds.keys().copied().collect();
format!("the {role} has {n} curved face{} ({})", if n == 1 { "" } else { "s" }, list.join(", "))
}
NotPlanar::Other(m) => format!("the {role}'s {m}"),
}
}
}
/// The plane of each face (from its mesh, which must be flat), or what stands in the way: the
/// curved faces by the kind of surface they lie on.
fn face_planes(b: &Body, faces: &[crate::FaceInfo]) -> std::result::Result<Vec<PlaneEq>, NotPlanar> {
use crate::FaceSurface;
let mut curved: std::collections::BTreeMap<&'static str, usize> = std::collections::BTreeMap::new();
let mut out = Vec::with_capacity(faces.len());
for f in faces {
if f.area <= 0.0 {
return Err(NotPlanar::Other(format!("face {} could not be meshed", f.index)));
}
match f.plane_normal {
Some(n) => out.push(PlaneEq { n, w: n.dot(f.centroid) }),
None => {
let kind = match crate::face_surface(b, f.index) {
Some(FaceSurface::Cylinder { .. }) => "cylinder",
Some(FaceSurface::Cone { .. }) => "cone",
Some(FaceSurface::Sphere { .. }) => "sphere",
Some(FaceSurface::Torus { .. }) => "torus",
Some(FaceSurface::Plane { .. }) => return Err(NotPlanar::Other(format!("face {} is planar but its mesh is not flat", f.index))),
None => "freeform",
};
*curved.entry(kind).or_insert(0) += 1;
}
}
}
if curved.is_empty() { Ok(out) } else { Err(NotPlanar::Curved(curved)) }
}
/// Polygons of a body whose faces are all planar.
fn planar_polys(b: &Body) -> std::result::Result<Vec<Poly>, NotPlanar> {
let tol = (b.size() * 1e-3).max(1e-3);
let faces = b.faces(tol).ok()?;
let mesh = b.tessellate(tol).ok()?;
let planes: Vec<PlaneEq> = faces.iter().map(|f| f.plane_normal.map(|n| PlaneEq { n, w: n.dot(f.centroid) })).collect::<Option<_>>()?;
let unmeshed = |e: KernelError| NotPlanar::Other(format!("faces could not be meshed ({e})"));
let faces = b.faces(tol).map_err(unmeshed)?;
let mesh = b.tessellate(tol).map_err(unmeshed)?;
let planes = face_planes(b, &faces)?;
if mesh.triangles.len() > MAX_POLYS {
return Err(NotPlanar::Other(format!("mesh is too detailed ({} triangles)", mesh.triangles.len())));
}
let bad_mesh = || NotPlanar::Other("mesh is inconsistent".into());
let mut out = Vec::new();
for (t, fi) in mesh.triangles.iter().zip(&mesh.tri_face) {
let [a, bb, c] = mesh.tri(t)?;
let plane = *planes.get(*fi as usize)?;
let [a, bb, c] = mesh.tri(t).ok_or_else(bad_mesh)?;
let plane = *planes.get(*fi as usize).ok_or_else(bad_mesh)?;
// Snap the vertices onto the exact face plane.
let snap = |p: Vec3| p - plane.n * (plane.n.dot(p) - plane.w);
let mut v = vec![snap(a), snap(bb), snap(c)];
@@ -217,7 +273,7 @@ fn planar_polys(b: &Body) -> Option<Vec<Poly>> {
}
out.push(Poly { v, plane });
}
(out.len() <= MAX_POLYS).then_some(out)
Ok(out)
}
/// Polygons of any body: planar faces exactly, curved ones as their triangles (within `tol`).
@@ -305,11 +361,19 @@ pub fn faceted_boolean(a: &Body, b: &Mesh, op: BoolOp, tol: f64) -> Result<Optio
pub fn planar_boolean(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
a.require_brep("a boolean")?;
b.require_brep("a boolean")?;
let (Some(pa), Some(pb)) = (planar_polys(a), planar_polys(b)) else {
if let Some(r) = sliced_planar_boolean(a, b, op) {
return r;
let (pa, pb) = match (planar_polys(a), planar_polys(b)) {
(Ok(pa), Ok(pb)) => (pa, pb),
(ra, rb) => {
if let Some(r) = sliced_planar_boolean(a, b, op) {
return r;
}
let why: Vec<String> =
[(ra.err(), "target"), (rb.err(), "tool")].into_iter().filter_map(|(e, role)| e.map(|e| e.describe(role))).collect();
return Err(KernelError::Failed(format!(
"not supported yet: the exact fallback for where the bodies meet (faces that touch, or cross at a shallow angle) takes planar faces only, and {}",
why.join(" and ")
)));
}
return Err(KernelError::Failed("not supported yet: coincident faces on curved bodies".into()));
};
let size = a.size().max(b.size());
polys_boolean(pa, pb, size, op, size * 1e-9)
@@ -320,7 +384,7 @@ pub fn planar_boolean(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
/// hole): `a` is split across the slab, the middle piece takes the exact boolean, and the
/// pieces outside are joined back on. `None` when no such slab exists.
fn sliced_planar_boolean(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Option<Body>>> {
let pb = planar_polys(b)?;
let pb = planar_polys(b).ok()?;
let size = a.size().max(b.size());
let tol = (a.size() * 1e-3).max(1e-3);
let infos = a.faces(tol).ok()?;
@@ -391,7 +455,7 @@ fn sliced_planar_boolean(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Option
outside.extend(crate::ops::half_space(&middle, &plane, !keep_positive)?);
middle = crate::ops::half_space(&middle, &plane, keep_positive)?.ok_or_else(|| fail("nothing beside the tool"))?;
}
let pm = planar_polys(&middle).ok_or_else(|| fail("the middle piece is not planar"))?;
let pm = planar_polys(&middle).map_err(|e| fail(&e.describe("middle piece")))?;
let r = polys_boolean(pm, pb, size, op, size * 1e-9)?;
if op == BoolOp::Intersect {
return Ok(r);
+235
View File
@@ -290,6 +290,65 @@ fn moving_a_body_with_an_intersection_curve_edge_ends() {
assert!(rel(v0, v1) < 1e-6, "{v0} vs {v1}");
}
/// A trough whose floor falls 1° (or 2° toward a drain, in four faces), and short round pads
/// and bosses crossing the fall. The B-rep results were right, but the plausibility check meshed
/// them without refinement: on a round side trimmed to a thin band by the shallow cut, the plain
/// mesh spans the band with triangles cutting through the cylinder, so the check saw missing
/// material, rejected the result, and the planar fallback refused the round pad.
#[test]
fn round_pads_crossing_a_shallow_fall() {
let hs = |n: Vec3, p: Vec3| {
let n = n.normalized().unwrap();
HalfSpace { n, d: n.dot(p) }
};
let walls = |floor: &[HalfSpace]| {
let mut h = vec![
hs(Vec3::X, Vec3::new(290.0, 0.0, 0.0)),
hs(-Vec3::X, Vec3::new(10.0, 0.0, 0.0)),
hs(Vec3::Y, Vec3::new(0.0, 140.0, 0.0)),
hs(-Vec3::Y, Vec3::new(0.0, 10.0, 0.0)),
hs(Vec3::Z, Vec3::new(0.0, 0.0, 90.0)),
];
h.extend_from_slice(floor);
convex_polyhedron(&h).unwrap()
};
let block = box_solid(Vec3::ZERO, Vec3::new(300.0, 150.0, 80.0)).unwrap();
// One fall along x at 1°: floor z = 8 + t (x − 10).
let t = 1f64.to_radians().tan();
let fall = boolean(&block, &walls(&[hs(Vec3::new(t, 0.0, -1.0), Vec3::new(10.0, 0.0, 8.0))]), BoolOp::Cut).unwrap().unwrap();
let v0 = measure(&fall).unwrap().volume;
for x in [60.0, 100.0] {
// A pad from z = 9 to 12 (its bottom just under the floor, its top 2–3 mm above).
let pad = cylinder(Vec3::new(x, 75.0, 9.0), Vec3::Z, 15.0, 3.0).unwrap();
let u = boolean(&fall, &pad, BoolOp::Union).unwrap().unwrap();
// The part of the pad above the floor: the floor under the disc is planar, so its mean
// height is the height at the centre (where the floor is below z = 9 everywhere).
let floor = |x: f64| 8.0 + t * (x - 10.0);
let above = if floor(x - 15.0) >= 9.0 { PI * 225.0 * (12.0 - floor(x)) } else { f64::NAN };
let v = measure(&u).unwrap().volume;
if above.is_finite() {
assert!(rel(v, v0 + above) < 1e-5, "pad at x {x}: {v} vs {}", v0 + above);
} else {
assert!(v > v0 && v < v0 + PI * 225.0 * 3.0, "pad at x {x}: {v}");
}
}
// Four faces falling 2° toward a drain point, and a pad over a valley edge and a wall.
let t2 = 2f64.to_radians().tan();
let d = Vec3::new(240.0, 110.0, 8.0);
let hopper = walls(&[
hs(Vec3::new(t2, 0.0, -1.0), d),
hs(Vec3::new(-t2, 0.0, -1.0), d),
hs(Vec3::new(0.0, t2, -1.0), d),
hs(Vec3::new(0.0, -t2, -1.0), d),
]);
let trough = boolean(&block, &hopper, BoolOp::Cut).unwrap().unwrap();
let pad = cylinder(Vec3::new(270.0, 70.0, 8.0), Vec3::Z, 25.0, 5.0).unwrap();
let u = boolean(&trough, &pad, BoolOp::Union).unwrap().unwrap();
// Integrated over the disc (outside the wall at x = 290): about 6517 mm³ above the floor.
let gain = measure(&u).unwrap().volume - measure(&trough).unwrap().volume;
assert!((gain - 6517.0).abs() < 10.0, "{gain}");
}
#[test]
fn planar_booleans_with_coincident_faces() {
// L-bracket: a wall flush with three sides of its base.
@@ -317,6 +376,38 @@ fn planar_booleans_with_coincident_faces() {
assert!(rel(measure(&i).unwrap().volume, 250.0) < 1e-9);
}
/// The planar fallback runs after any failed B-rep boolean, not only on coincident faces; when
/// it can't run it says which body has which curved faces (it used to blame "coincident faces
/// on curved bodies" whatever the cause).
#[test]
fn planar_fallback_names_the_curved_faces() {
let slab = box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 10.0)).unwrap();
let pad = cylinder(Vec3::new(20.0, 15.0, 5.0), Vec3::Z, 8.0, 10.0).unwrap();
let e = planar_boolean(&slab, &pad, BoolOp::Union).unwrap_err().to_string();
assert!(e.contains("not supported yet"), "{e}");
assert!(e.contains("the tool has 2 curved faces (cylinder)"), "{e}");
assert!(!e.contains("target"), "{e}");
assert!(!e.contains("coincident faces on curved bodies"), "{e}");
let ball = sphere(Vec3::new(20.0, 15.0, 10.0), 5.0).unwrap();
let e = planar_boolean(&ball, &pad, BoolOp::Cut).unwrap_err().to_string();
assert!(e.contains("the target has 6 curved faces (sphere) and the tool has 2 curved faces (cylinder)"), "{e}");
// All-planar bodies, a face of one at a shallow angle (1°): the fallback takes them.
let slope = Vec3::new(-1f64.to_radians().tan(), 0.0, 1.0).normalized().unwrap();
let wedge = convex_polyhedron(&[
HalfSpace { n: Vec3::X, d: 30.0 },
HalfSpace { n: -Vec3::X, d: -10.0 },
HalfSpace { n: Vec3::Y, d: 20.0 },
HalfSpace { n: -Vec3::Y, d: -10.0 },
HalfSpace { n: -Vec3::Z, d: -5.0 },
HalfSpace { n: slope, d: slope.dot(Vec3::new(10.0, 0.0, 11.0)) },
])
.unwrap();
let u = planar_boolean(&slab, &wedge, BoolOp::Union).unwrap().unwrap();
let above = box_solid(Vec3::new(0.0, 0.0, 10.0), Vec3::new(40.0, 30.0, 20.0)).unwrap();
let expect = 40.0 * 30.0 * 10.0 + measure(&boolean(&wedge, &above, BoolOp::Intersect).unwrap().unwrap()).unwrap().volume;
assert!(rel(measure(&u).unwrap().volume, expect) < 1e-6, "{} vs {expect}", measure(&u).unwrap().volume);
}
#[test]
fn connected_logo_join_preserves_exact_faces_at_edge_contact() {
let mut body = box_solid(Vec3::ZERO, Vec3::new(10.0, 20.0, 10.0)).unwrap();
@@ -1464,6 +1555,49 @@ fn smooth_loft_through_three_sections() {
assert!(v > PI * 64.0 * 30.0 && v < 576.0 * 30.0, "{v}");
}
/// A loft from the basin's whole outline (10 up) down to a drain pad in a corner (1 up), cut
/// from a 3 mm shell: its top edges lie on the outer walls. One order of the sections failed
/// the cut ("shell not oriented and closed", or a panic inside the intersection), the other
/// worked; both orders now build the same body, valid and outward.
#[test]
fn loft_section_order_gives_the_same_body() {
let r = |x0: f64, y0: f64, x1: f64, y1: f64| Loop2::polygon(&[Vec2::new(x0, y0), Vec2::new(x1, y0), Vec2::new(x1, y1), Vec2::new(x0, y1)]);
let at = |z: f64| Plane::new(Vec3::new(0.0, 0.0, z), Vec3::X, Vec3::Y).unwrap();
let big = (at(10.0), r(0.0, 0.0, 100.0, 60.0));
let small = (at(1.0), r(80.0, 40.0, 90.0, 50.0));
let basin = shell(&box_solid(Vec3::ZERO, Vec3::new(100.0, 60.0, 30.0)).unwrap(), &[Vec3::new(50.0, 30.0, 30.0)], 3.0).unwrap();
// The material inside the loft: the floor from 1 to 3 up, the walls from 3 to 10.
let v0 = measure(&basin).unwrap().volume;
let removed = v0 - 41582.028;
let mut volumes = Vec::new();
for secs in [vec![big.clone(), small.clone()], vec![small.clone(), big.clone()]] {
let l = loft(&secs).unwrap();
let m = measure(&l).unwrap();
assert!(m.volume > 0.0, "{}", m.volume);
volumes.push(m.volume);
let cut = boolean(&basin, &l, BoolOp::Cut).unwrap().unwrap();
let v = measure(&cut).unwrap().volume;
assert!((v0 - v - removed).abs() < 0.01 * removed, "{v} vs {}", v0 - removed);
let joined = boolean(&basin, &l, BoolOp::Union).unwrap().unwrap();
assert!(measure(&joined).unwrap().volume > v0);
}
assert!(rel(volumes[0], volumes[1]) < 1e-12, "{volumes:?}");
// Reversed in z too (large below, small above, and sketched on planes facing down).
let down = |z: f64| Plane::new(Vec3::new(0.0, 0.0, z), Vec3::Y, Vec3::X).unwrap();
for secs in [
vec![(at(0.0), r(0.0, 0.0, 100.0, 60.0)), (at(20.0), r(40.0, 20.0, 60.0, 40.0))],
vec![(down(20.0), r(20.0, 40.0, 40.0, 60.0)), (at(0.0), r(0.0, 0.0, 100.0, 60.0))],
] {
// Prismoid: h/6 · (A0 + 4 Amid + A1).
let want = 20.0 / 6.0 * (6000.0 + 4.0 * 60.0 * 40.0 + 400.0);
let back: Vec<(Plane, Loop2)> = secs.iter().rev().cloned().collect();
for b in [loft(&secs).unwrap(), loft(&back).unwrap()] {
let v = measure(&b).unwrap().volume;
assert!(rel(v, want) < 1e-6, "{v} vs {want}");
}
}
}
#[test]
fn tangent_holes_pinch_alike() {
// Holes at ±15 and ±25 touch along a line (x = ±20); ±30 overlaps ±25. The ±25 wall is cut
@@ -1638,3 +1772,104 @@ fn orientation_mesh_reuses_missing_face_check_at_matching_tolerance() {
}
}
}
/// A trough whose floor falls about 1° toward a central drain, the fall starting right at the
/// trough edges: four faces meet at each bottom corner (two walls, two fall facets) and at the
/// drain (four facets). A tab on one end makes the body non-convex. The moved planes of four
/// faces don't meet at one point, so each such corner of the cavity becomes two corners joined
/// by a short edge. Checked against the volume integrated by hand.
#[test]
fn shell_where_four_faces_meet_at_a_corner() {
let (w, l, ht, t) = (100.0, 60.0, 40.0, 2.0);
// Drain depth: the long facets fall 1°.
let h = 30.0 * 1f64.to_radians().tan();
let facet = |n: Vec3, p: Vec3| {
let n = n.normalized().unwrap();
HalfSpace { n, d: n.dot(p) }
};
let facets = [
facet(Vec3::new(0.0, -h, -30.0), Vec3::ZERO),
facet(Vec3::new(0.0, h, -30.0), Vec3::new(0.0, l, 0.0)),
facet(Vec3::new(-h, 0.0, -50.0), Vec3::ZERO),
facet(Vec3::new(h, 0.0, -50.0), Vec3::new(w, 0.0, 0.0)),
];
let mut hs = vec![
HalfSpace { n: -Vec3::X, d: 0.0 },
HalfSpace { n: Vec3::X, d: w },
HalfSpace { n: -Vec3::Y, d: 0.0 },
HalfSpace { n: Vec3::Y, d: l },
HalfSpace { n: Vec3::Z, d: ht },
];
hs.extend(facets);
let trough = convex_polyhedron(&hs).unwrap();
let pyramid = w * l * h / 3.0;
assert!(rel(measure(&trough).unwrap().volume, w * l * ht + pyramid) < 1e-9);
let tab = box_solid(Vec3::new(w, 20.0, 20.0), Vec3::new(w + 20.0, 40.0, ht)).unwrap();
let body = boolean(&trough, &tab, BoolOp::Union).unwrap().unwrap();
let s = shell(&body, &[Vec3::new(50.0, 30.0, ht)], t).unwrap();
// The cavity: the walls in by t, above the floor facets moved in by t (midpoint rule; the
// floor is linear between its creases).
let floor = |x: f64, y: f64| facets.iter().map(|f| (f.d - t - f.n.x * x - f.n.y * y) / f.n.z).fold(f64::NEG_INFINITY, f64::max);
let (nx, ny) = (960, 560);
let (dx, dy) = ((w - 2.0 * t) / nx as f64, (l - 2.0 * t) / ny as f64);
let mut cavity = 0.0;
for i in 0..nx {
for j in 0..ny {
cavity += (ht - floor(t + (i as f64 + 0.5) * dx, t + (j as f64 + 0.5) * dy)) * dx * dy;
}
}
let tab_cavity = 20.0 * (20.0 - 2.0 * t) * (20.0 - t);
let want = w * l * ht + pyramid + 8000.0 - cavity - tab_cavity;
let m = measure(&s).unwrap();
assert!(rel(m.volume, want) < 1e-5, "{} vs {want}", m.volume);
}
/// The same trough cut as a pocket into a slab (four faces meet at each concave corner of the
/// pocket floor), shelled with its top open.
#[test]
fn shell_of_a_pocket_whose_floor_falls_to_a_drain() {
let (px, py, pw, pl, top, depth, t) = (20.0, 15.0, 100.0, 60.0, 50.0, 30.0, 3.0);
let h = 30.0 * 1f64.to_radians().tan();
let z0 = top - depth;
let facet = |n: Vec3, p: Vec3| {
let n = n.normalized().unwrap();
HalfSpace { n, d: n.dot(p) }
};
let facets = [
facet(Vec3::new(0.0, -h, -30.0), Vec3::new(px, py, z0)),
facet(Vec3::new(0.0, h, -30.0), Vec3::new(px, py + pl, z0)),
facet(Vec3::new(-h, 0.0, -50.0), Vec3::new(px, py, z0)),
facet(Vec3::new(h, 0.0, -50.0), Vec3::new(px + pw, py, z0)),
];
let mut hs = vec![
HalfSpace { n: -Vec3::X, d: -px },
HalfSpace { n: Vec3::X, d: px + pw },
HalfSpace { n: -Vec3::Y, d: -py },
HalfSpace { n: Vec3::Y, d: py + pl },
HalfSpace { n: Vec3::Z, d: top + 10.0 },
];
hs.extend(facets);
let pocket = convex_polyhedron(&hs).unwrap();
let slab = box_solid(Vec3::ZERO, Vec3::new(140.0, 90.0, top)).unwrap();
let basin = boolean(&slab, &pocket, BoolOp::Cut).unwrap().unwrap();
let pocket_vol = pw * pl * depth + pw * pl * h / 3.0;
assert!(rel(measure(&basin).unwrap().volume, 140.0 * 90.0 * top - pocket_vol) < 1e-9);
let s = shell(&basin, &[Vec3::new(5.0, 5.0, top)], t).unwrap();
// The cavity's columns: from t up to the top, or up to the pocket floor moved down by t
// inside the pocket's walls moved out by t.
let floor = |x: f64, y: f64| facets.iter().map(|f| (f.d + t - f.n.x * x - f.n.y * y) / f.n.z).fold(f64::NEG_INFINITY, f64::max);
let (nx, ny) = (1340, 840);
let (dx, dy) = ((140.0 - 2.0 * t) / nx as f64, (90.0 - 2.0 * t) / ny as f64);
let mut cavity = 0.0;
for i in 0..nx {
for j in 0..ny {
let (x, y) = (t + (i as f64 + 0.5) * dx, t + (j as f64 + 0.5) * dy);
let inside = x > px - t && x < px + pw + t && y > py - t && y < py + pl + t;
let roof = if inside { floor(x, y) } else { top };
cavity += (roof - t) * dx * dy;
}
}
let want = 140.0 * 90.0 * top - pocket_vol - cavity;
let m = measure(&s).unwrap();
assert!(rel(m.volume, want) < 1e-4, "{} vs {want}", m.volume);
}
+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);
+46
View File
@@ -361,6 +361,10 @@ impl ConstraintKind {
LinearDiameter { .. } => "LinearDiameterDimension",
}
}
/// Does the constraint refer to point `p` (by index)?
pub fn refers_to_point(&self, p: usize) -> bool {
self.indices().0.contains(&p)
}
fn indices(&self) -> (Vec<usize>, Vec<usize>) {
use ConstraintKind::*;
match *self {
@@ -892,6 +896,48 @@ impl Sketch {
out
}
/// Virtual sharp corners (what a fillet or chamfer leaves where dimensions measured to the
/// corner): points used by no curve, held on two or more lines or arcs (their extensions).
/// Each with the extension segments from the corner to the nearest end of those curves, for
/// drawing.
pub fn virtual_corners(&self) -> Vec<(usize, Vec<(Vec2, Vec2)>)> {
let mut out = Vec::new();
for (i, p) in self.points.iter().enumerate().skip(1) {
if self.point_used_by_curve(i) {
continue;
}
let on: Vec<usize> = self
.constraints
.iter()
.filter_map(|c| match c.kind {
ConstraintKind::PointOnCurve { p, c } if p == i => Some(c),
_ => None,
})
.filter(|c| matches!(self.curves.get(*c).map(|c| &c.kind), Some(CurveKind::Line { .. } | CurveKind::Arc { .. })))
.collect();
if on.len() < 2 {
continue;
}
let mut ext = Vec::new();
for c in on {
let Some((a, b)) = self.curves.get(c).and_then(|c| c.kind.ends()) else { continue };
let (Some(pa), Some(pb)) = (self.point(a), self.point(b)) else { continue };
let near = if p.pos.dist(pa) <= p.pos.dist(pb) { pa } else { pb };
// A line only needs one when the corner is beyond its ends.
let inside = matches!(self.curves.get(c).map(|c| &c.kind), Some(CurveKind::Line { .. })) && {
let d = pb - pa;
let t = (p.pos - pa).dot(d) / d.dot(d).max(1e-300);
(0.0..=1.0).contains(&t)
};
if !inside && p.pos.dist(near) > 1e-9 {
ext.push((p.pos, near));
}
}
out.push((i, ext));
}
out
}
/// Is curve `c` held in place: fixed or projected (linked).
pub fn curve_locked(&self, c: usize) -> bool {
self.curves.get(c).is_some_and(|c| c.fixed || c.link.is_some() || self.freeform_offsets.iter().any(|offset| offset.output == c.id))
+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]}),
+7 -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();
@@ -2112,6 +2090,7 @@ fn overlays(app: &SolveApp, painter: &egui::Painter, proj: &Proj) {
let Some(ss) = st.sketch(sid) else { return };
let sk = &ss.sketch;
let locked = sk.locked_points();
let virtual_corners: Vec<usize> = sk.virtual_corners().into_iter().map(|(i, _)| i).collect();
for (i, p) in sk.points.iter().enumerate() {
if !solvecraft_engine::view::sketch_point_visible(sk, i) {
continue;
@@ -2128,6 +2107,9 @@ fn overlays(app: &SolveApp, painter: &egui::Painter, proj: &Proj) {
};
if i == 0 {
painter.circle(sp, 3.5, Color32::from_rgb(240, 200, 60), Stroke::new(1.0, t.body_edge));
} else if virtual_corners.contains(&i) {
// A virtual sharp corner: an open square, it is not on any drawn curve.
painter.rect_stroke(Rect::from_center_size(sp, vec2(6.0, 6.0)), 0.0, Stroke::new(1.2, c), egui::StrokeKind::Middle);
} else {
painter.rect_filled(Rect::from_center_size(sp, vec2(5.0, 5.0)), 0.0, c);
}
@@ -3012,7 +2994,7 @@ mod tests {
let st = s.model.state();
let m = st.bodies[0].mesh();
let (o, n) = (Vec3::new(0.0, 15.0, 0.0), Vec3::new(0.0, -1.0, 0.0));
let segs = section_segments(&m, o, n);
let segs = solvecraft_engine::view::section_segments(&m, o, n);
assert!(segs.len() >= 4, "{segs:?}");
// On the plane (a hair to the kept side) and around the 40 x 20 cut.
let mut len = 0.0;
@@ -3022,7 +3004,7 @@ mod tests {
}
assert!((len - 120.0).abs() < 0.5, "outline length {len}");
// A plane that misses the body cuts nothing.
assert!(section_segments(&m, Vec3::new(0.0, 99.0, 0.0), n).is_empty());
assert!(solvecraft_engine::view::section_segments(&m, Vec3::new(0.0, 99.0, 0.0), n).is_empty());
}
#[test]
@@ -0,0 +1,148 @@
//! Constraint tools accept a selected curve pair as well as tool-first clicks.
use serde_json::json;
use solvecraft_engine::{Sel, sketch::ConstraintKind};
use solvecraft_ui_egui::scenario::Harness;
fn lines() -> Harness {
let mut h = Harness::new();
h.run(&json!([
{"call":"engine.execute","params":{"command":"sketch.create","params":{"plane":"XY"}}},
{"call":"engine.execute","params":{"command":"sketch.line","params":{"points":[[10,10],[40,10]],"infer":false}}},
{"call":"engine.execute","params":{"command":"sketch.line","params":{"points":[[10,30],[40,35]],"infer":false}}},
{"call":"ui.view","params":{"view":"top"}}
]))
.unwrap();
h
}
fn select_lines(h: &mut Harness) {
h.run(&json!([
{"click":{"sketch":[25,10]}},
{"click":{"sketch":[25,32.5]},"shift":true}
]))
.unwrap();
assert_eq!(h.app.session.selection, vec![Sel::SketchCurve { id: "l1".into() }, Sel::SketchCurve { id: "l2".into() }]);
}
fn assert_line_geometry(h: &Harness, command: &str) {
let sk = &h.app.session.model.state().sketches[0].sketch;
let point = |name| sk.point(sk.resolve_point(name).unwrap()).unwrap();
let a = point("l1.start");
let b = point("l2.start");
let u = point("l1.end") - a;
let v = point("l2.end") - b;
// Normalization also rejects a collapsed line; zero-length vectors must not
// accidentally satisfy the parallel/perpendicular checks.
let du = u.normalized().unwrap();
let dv = v.normalized().unwrap();
match command {
"parallel" => assert!(du.cross(dv).abs() < 1e-6),
"perpendicular" => assert!(du.dot(dv).abs() < 1e-6),
"equal" => assert!((u.len() - v.len()).abs() < 1e-6),
"collinear" => {
assert!(du.cross(dv).abs() < 1e-6);
assert!((b - a).cross(du).abs() < 1e-6);
}
_ => panic!("unexpected line constraint: {command}"),
}
}
#[test]
fn selected_lines_apply_constraints_with_one_undo_step() {
for command in ["parallel", "perpendicular", "equal", "collinear"] {
let mut h = lines();
select_lines(&mut h);
let before = h.app.session.doc.clone();
let undo = h.app.session.undo.len();
h.run(&json!([{"start":format!("sketch.constraint.{command}")}])).unwrap();
assert!(!h.app.status.as_ref().is_some_and(|(_, _, error)| *error), "{command}");
let sk = &h.app.session.model.state().sketches[0].sketch;
assert!(
sk.constraints.iter().any(|c| match command {
"parallel" => matches!(c.kind, ConstraintKind::Parallel { .. }),
"perpendicular" => matches!(c.kind, ConstraintKind::Perpendicular { .. }),
"equal" => matches!(c.kind, ConstraintKind::Equal { .. }),
_ => matches!(c.kind, ConstraintKind::Collinear { .. }),
}),
"{command}"
);
assert_line_geometry(&h, command);
let after = h.app.session.doc.clone();
assert_eq!(h.app.session.undo.len(), undo + 1);
assert!(h.app.session.selection.is_empty());
assert!(h.app.tool.is_none());
h.app.session.execute("edit.undo", &json!({})).unwrap();
assert_eq!(serde_json::to_value(&h.app.session.doc).unwrap(), serde_json::to_value(before).unwrap());
h.app.session.execute("edit.redo", &json!({})).unwrap();
assert_eq!(serde_json::to_value(&h.app.session.doc).unwrap(), serde_json::to_value(after).unwrap());
assert_line_geometry(&h, command);
}
}
#[test]
fn selected_circles_apply_constraints() {
for command in ["tangent", "concentric", "equal"] {
let mut h = Harness::new();
h.run(&json!([
{"call":"engine.execute","params":{"command":"sketch.create","params":{"plane":"XY"}}},
{"call":"engine.execute","params":{"command":"sketch.circle.center","params":{"center":[10,10],"radius":5}}},
{"call":"engine.execute","params":{"command":"sketch.circle.center","params":{"center":[25,12],"radius":7}}},
{"call":"ui.view","params":{"view":"top"}},
{"click":{"sketch":[10,15]}},
{"click":{"sketch":[25,19]},"shift":true}
]))
.unwrap();
assert_eq!(h.app.session.selection.len(), 2);
let undo = h.app.session.undo.len();
h.run(&json!([{"start":format!("sketch.constraint.{command}")}])).unwrap();
assert!(!h.app.status.as_ref().is_some_and(|(_, _, error)| *error), "{command}");
assert_eq!(h.app.session.undo.len(), undo + 1, "{command}");
let sk = &h.app.session.model.state().sketches[0].sketch;
assert!(sk.constraints.iter().any(|c| match command {
"tangent" => matches!(c.kind, ConstraintKind::Tangent { .. }),
"concentric" => matches!(c.kind, ConstraintKind::Concentric { .. }),
_ => matches!(c.kind, ConstraintKind::Equal { .. }),
}));
assert!(h.app.session.selection.is_empty());
}
}
#[test]
fn ambiguous_selection_preserves_tool_first_workflow() {
for selection in [
vec![],
vec![Sel::SketchCurve { id: "l1".into() }],
vec![Sel::SketchCurve { id: "l1".into() }, Sel::SketchCurve { id: "l2".into() }, Sel::SketchPoint { id: "origin".into() }],
] {
let mut h = lines();
h.app.session.selection = selection;
let undo = h.app.session.undo.len();
h.run(&json!([{"start":"sketch.constraint.parallel"}])).unwrap();
assert_eq!(h.app.session.undo.len(), undo);
assert!(h.app.tool.as_ref().unwrap().picks.is_empty());
h.run(&json!([{"click":{"sketch":[25,10]}}, {"click":{"sketch":[25,32.5]}}])).unwrap();
assert_eq!(h.app.session.undo.len(), undo + 1);
assert!(!h.app.status.as_ref().is_some_and(|(_, _, error)| *error));
}
}
#[test]
fn rejected_preselection_preserves_document_and_error() {
let mut h = lines();
h.app.session.execute("sketch.constraint.fix", &json!({"entities":["l1","l2"]})).unwrap();
select_lines(&mut h);
let before = serde_json::to_value(&h.app.session.doc).unwrap();
let undo = h.app.session.undo.len();
let selection = h.app.session.selection.clone();
h.run(&json!([{"start":"sketch.constraint.parallel"}])).unwrap();
assert!(h.app.status.as_ref().is_some_and(|(_, _, error)| *error));
assert_eq!(serde_json::to_value(&h.app.session.doc).unwrap(), before);
assert_eq!(h.app.session.undo.len(), undo);
assert_eq!(h.app.session.selection, selection);
// Correct the conflict and retry without reselecting; the old error must disappear.
h.app.session.execute("sketch.constraint.fix", &json!({"entities":["l1","l2"]})).unwrap();
h.run(&json!([{"start":"sketch.constraint.parallel"}])).unwrap();
assert!(!h.app.status.as_ref().is_some_and(|(_, _, error)| *error));
assert!(h.app.session.selection.is_empty());
}
+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);
}
+13 -5
View File
@@ -1,7 +1,8 @@
# Test harnesses
`cargo xtask ci` runs everything a pull request must pass: fmt, clippy with `-D warnings`, the
tests in release mode, `xtask layers`, `xtask assets` and the wasm build check.
`cargo xtask ci` runs everything a pull request must pass: fmt, `xtask assets`,
`xtask licences --check`, `xtask layers`, clippy with `-D warnings`, the tests with the `ci`
profile and the wasm build check.
| Harness | Where | What it catches |
|---|---|---|
@@ -34,7 +35,14 @@ A new file in `crates/ui-egui/tests/scenarios/` is picked up automatically. To r
before it's fixed, add a `{"pending": "why"}` step; its test is then ignored with that reason, and
the fix removes the step.
## Release mode
## CI profile
CI runs the tests with `--release`, because some truck assertions only exist in debug builds
and fail on valid geometry. If a test fails only in debug, say so in the PR.
CI runs the tests with `--profile ci`: light optimisation, line tables, no LTO and debug
assertions off. Some truck debug assertions fail on valid geometry; the `ci` profile disables
them while building faster than `--release`. If a test fails only in a plain debug build, say
so in the PR.
Run `cargo xtask test` to build with this profile and run the test binaries concurrently,
with doc tests alongside. Arguments after `--` go to each test binary, for example
`cargo xtask test -- shell`. To run the test binaries sequentially instead, use
`cargo test --workspace --profile ci`.
+39
View File
@@ -52,6 +52,45 @@ Feature references to faces and edges are stored by name, so an upstream edit th
keeps them on the right face (see [Persistent naming](../dev/naming.md)). When a reference can't
be found, the feature warns instead of silently picking another face.
## Editing a feature from a script
`timeline.inspect {feature}` returns a feature's `definition`: the fields `timeline.edit` changes,
with their current values. `timeline.edit {feature, set}` merges `set` into that definition
(objects are merged field by field, anything else replaces the value), rebuilds the feature and
reports `recomputed` and `restored`. The feature's type cannot change, and sketch geometry is
edited with the sketch commands. Expressions are text (`"30"`, `"30 mm"`, `"width / 2"`,
`"45 deg"`); a bare number is a length in the design's units, or degrees for an angle.
```json
{"command": "timeline.inspect", "params": {"feature": "Slab"}}
→ {"type": "ExtrudeFeature",
"definition": {"sketch": 1, "profiles": {"type": "all"}, "operation": "new_body",
"extent": {"distance": "10", "direction": "positive"}},
"inputs": [{"label": "Distance", "param": "d1", "expression": "10", "unit": "mm"}], …}
{"command": "timeline.edit", "params": {"feature": "Slab", "set": {"extent": {"distance": "12"}}}}
```
`inputs` lists the feature's named inputs. `parameters.change {name: "d1", expression}` sets one
of those directly. Optional fields that are not set do not appear in the definition; they can
still be added through `set`. The fields of the common features:
| Feature | Fields |
|---|---|
| Extrude | `extent`: {`distance`, `direction`: positive\|negative\|symmetric, `distance2`, `start_offset`, `through_all`, `taper`, `to`, `to_offset`, `from`}; `operation`: new_body\|join\|cut\|intersect; `targets`; `profiles`; `sketch` |
| Revolve | `angle`, `angle2`, `symmetric`, `axis`, `to`, `operation`, `targets`, `profiles`, `sketch` |
| Fillet | `radius`, `edges` (a point on each edge), `body`, `style` |
| Chamfer | `distance`, `distance2` or `angle`, `flip`, `edges`, `body` |
| Box | `length`, `width`, `height`, `corner`, `operation` |
| Cylinder | `radius`, `height`, `base`, `axis`, `operation` |
| Hole | `diameter`, `depth`, `position`, `direction`, `hole` (simple, drilled, counterbore, countersink), `thread`, `to` |
| Shell | `thickness`, `faces`, `direction`, `body`, `tangent_chain` |
| Draft | `angle`, `faces`, `neutral`, `pull`, `body` |
| Offset face | `distance`, `faces`, `body` |
| Pattern | `pattern` (its kind and counts/spacing), `features`, `bodies` |
| Mirror | `plane`, `features`, `bodies`, `combine` |
For any other feature, `timeline.inspect` shows the fields.
Press **Delete** on anything selected (bodies, faces, sketches, features, planes, components,
canvases). It runs as one undoable step and lists the features that would go with it or fail.
+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.
+5
View File
@@ -62,6 +62,11 @@ Claude Desktop (`claude_desktop_config.json`), bridged to a running app:
| `open` | `path` | Open a `.solvecraft` design, a STEP file (`.step`/`.stp`) as a new design holding its bodies (an Import base feature), or a 3MF/STL file as mesh bodies. |
| `save` | `path?` | Save the design (to its current file without `path`). |
To change a feature after it is made, read its definition with `execute` `timeline.inspect`
(`{feature}`: id or name) and change fields with `timeline.edit` (`{feature, set}`; e.g.
`{"extent": {"distance": "30"}}`). The fields per feature type are listed in the book's
*Solid features* chapter.
Resources: `solvecraft://design` (inspect JSON with measurements) and `solvecraft://commands`
(the catalog).
+4
View File
@@ -11,6 +11,10 @@ solvecraft-cli fuzz --from 0 --count 1000 --jobs 8 --timeout 90 --out runs/ --re
solvecraft-cli fuzz-one <seed> --minimise # one design, cut down to the steps that matter
```
`--count` seeds are run starting at `--from`. A range that exceeds `u64::MAX` is
rejected before any jobs or output directories are created. The maximum seed
(`18446744073709551615`) is valid with `--count 1`; `--count 0` runs no seeds.
- **Designs** (`crates/engine/src/fuzz.rs`): a seeded generator starts from a box, an extruded
sketch profile closed by an arc (sometimes notched), or a revolved section (partial or full
turns, sometimes touching the axis). It then adds up to eight steps: joins and cuts of
Generated
+27
View File
@@ -0,0 +1,27 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1791594108,
"narHash": "sha256-XK4KceonkwD8LuMAt/vNrz982mXJM5jeHAxMwsUXXAQ=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "6510408d147d8c5e6d76043e899e9251756f6a75",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}
+133
View File
@@ -0,0 +1,133 @@
{
description = "SolveCraft — native app development shell and package for Linux";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
outputs =
{ self, nixpkgs }:
let
systems = [
"x86_64-linux"
"aarch64-linux"
];
forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f nixpkgs.legacyPackages.${system});
# Why this shell exists. winit opens the window by dlopen'ing the Wayland client libraries,
# wgpu renders through the EGL loader (libEGL.so.1), and rfd's file dialogs reach
# xdg-desktop-portal through libdbus-1; a plain `cargo run` under NixOS finds none of them,
# because there is no /usr/lib and nothing puts them on LD_LIBRARY_PATH:
# winit EventLoopError: The wayland library could not be loaded
# eframe: WGPU error: Failed to create surface for any enabled backend: {}
# rfd::backend::xdg_desktop_portal::portal::libdbus: Can't connect to a portal: libdbus-1.so not found
# The Mesa drivers themselves — and the EGL vendor / Vulkan ICD descriptions that name them —
# come from the system in /run/opengl-driver (hardware.graphics.enable), not from here, since
# that is the driver stack the compositor runs on. Verified on an Intel LNL iGPU under
# Wayland: wgpu picks the GL backend through this loader and draws.
# The X11 libraries are the ones the app actually uses on a Wayland desktop: with `DISPLAY`
# set and `SOLVECRAFT_WAYLAND` unset, winit runs through XWayland (file drag and drop exists
# only there). The Wayland ones are for `SOLVECRAFT_WAYLAND=1`, and both are needed under
# `xvfb-run` on a machine with no display at all.
runtimeLibs =
pkgs:
(with pkgs; [
wayland
libxkbcommon
libglvnd
dbus
])
++ (with pkgs; [
# xorg.libX11 and friends are deprecated aliases in current nixpkgs.
libx11
libxcursor
libxi
libxrandr
libxrender
libxext
libxcb
]);
driverLibDir = "/run/opengl-driver/lib";
libraryPath = pkgs: "${pkgs.lib.makeLibraryPath (runtimeLibs pkgs)}:${driverLibDir}";
shellHook = ''
if [ ! -d ${driverLibDir} ]; then
echo "solvecraft: ${driverLibDir} is missing — set hardware.graphics.enable = true so the Mesa drivers and their EGL vendor descriptions exist." >&2
fi
command -v cc >/dev/null || echo "solvecraft: no C linker on PATH, cargo cannot link — use nix develop .#rust" >&2
echo "solvecraft: cargo run --release -p solvecraft -- --sample"
'';
in
{
devShells = forAllSystems (pkgs: {
# The shell to work in. It brings no toolchain of its own, Rust or C: the ones already on
# PATH (a system package, rustup, nixpkgs) keep their build cache and their linker, where a
# second rustc — or mkShell's second gcc, with another glibc behind it — would make every
# `cargo build` produce binaries from somewhere else.
default = pkgs.mkShellNoCC {
name = "solvecraft";
LD_LIBRARY_PATH = libraryPath pkgs;
inherit shellHook;
};
# For a machine with no Rust at all: `nix develop .#rust`. Its own target directory, so
# switching between this toolchain and the system one does not thrash the cache.
rust = pkgs.mkShell {
name = "solvecraft-rust";
packages = [
pkgs.cargo
pkgs.rustc
pkgs.rustfmt
pkgs.clippy
];
LD_LIBRARY_PATH = libraryPath pkgs;
# Under target/, which .gitignore already covers.
CARGO_TARGET_DIR = "target/nix";
shellHook = ''
${shellHook}
echo "solvecraft: $(rustc --version), building into target/nix"
'';
};
});
packages = forAllSystems (pkgs: {
default = pkgs.rustPlatform.buildRustPackage {
pname = "solvecraft";
version = (pkgs.lib.importTOML ./Cargo.toml).workspace.package.version;
src = self;
cargoLock.lockFile = ./Cargo.lock;
# Build and install the app alone: the workspace also holds the CLI, the web app, xtask
# and the crates only the test suites need.
cargoBuildFlags = [
"-p"
"solvecraft"
];
cargoInstallFlags = [
"-p"
"solvecraft"
];
# `cargo xtask ci` is the test gate (it wants a wasm target and a GPU); the sandbox has
# neither.
doCheck = false;
nativeBuildInputs = [ pkgs.makeWrapper ];
buildInputs = runtimeLibs pkgs;
# The app reaches the window through dlopen'd libraries, so the packaged binary needs the
# same LD_LIBRARY_PATH the shell exports.
postFixup = ''
wrapProgram $out/bin/solvecraft --prefix LD_LIBRARY_PATH : "${libraryPath pkgs}"
'';
meta = {
description = "Rust-native parametric 3D CAD app";
homepage = "https://github.com/bherbruck/solvecraft";
license = with pkgs.lib.licenses; [
mit
asl20
];
mainProgram = "solvecraft";
};
};
});
};
}