Sync main into render and preserve GPU CLI help (#5976)

* CLI: --help on each subcommand prints its usage and exits 0 (#4538) (#4545)

* claim: #4538

* CLI: --help on each subcommand prints its usage and exits 0 (#4538)

Every command now answers `--help` and `-h` with its own usage on stdout and
exit 0, the way the top level has since #1828. Before, each subcommand
answered differently: `fuzz --help` started a fuzz run with the default seeds
and ran until it was stopped, `recipe --help` took the flag for a file name,
and `snapshot`, `render`, `oracle`, `run`, `eval`, `script` and `crashes`
answered with an error on stderr and exit 1.

The help is read before the command runs, so `--help` never starts work: no
fuzz run, no MCP server, no file read.

`fuzz` also refuses an argument it doesn't take, as `snapshot` already did: a
flag it doesn't know and a stray word are errors, not arguments to ignore
while a hundred seeds run.

The usage lives in one place now: each command's lines are a constant, and the
top-level usage is composed from them, so the two cannot drift apart. The
top-level usage is unchanged, byte for byte.

# Conflicts:
#	apps/solidcraft-cli/src/main.rs

* Renaming a parameter follows it into sheet-metal and plastic rules and canvases (#4420) (#4423)

* claim: #4420

* Renaming a parameter follows it into sheet-metal and plastic rules and canvases (#4420)

Document::rename_param rewrote other parameters, feature inputs, dimensions,
configurations and joints, but not the expressions of sheet metal and plastic rules or
the plane a canvas lies on, so after a rename the sheet metal and plastic features
using them failed with "unknown parameter" and the canvas left the view. It renames
them there now, and param_users lists them (a rule's field, a canvas's input), so a
parameter they use can't be deleted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Parameters: rules' features in drives and the graph; shared rule field lists (#4420)

A sheet metal or plastic rule that uses a parameter makes the sheet metal (or plastic)
features rebuild when it changes, so parameters.users lists them in drives and
parameters.graph links them. SheetRule::fields/fields_mut and PlasticRule::fields_mut
replace the hand-written copies of the rules' fields in params.rs, eval.rs and
sheet.manage_rules. Suggested by @CamWight.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fmt (#4420)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Mirror and pattern: a pattern or mirror feature as the source (#395) (#3998)

* claim: #395

* Mirror and pattern: a pattern or mirror feature as the source (#395)

A pattern or mirror of features refused another pattern or mirror as a
source ("Pat cannot be patterned or mirrored yet"). Such a source now
stands for what it copied: its own sources are replayed at each of its
transforms moved by each of ours, to any depth up to eight. Copies of
a feature picked directly keep the instance numbers they had, so
existing designs keep their face names; copies made through a nested
pattern or mirror are numbered after them, so one feature reached two
ways never names two copies alike. A pattern or mirror of bodies as
the source is still refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Expression completion: keep longer names visible when the typed word is a unit (#3663) (#3664)

* claim: #3663

* Expression completion: keep longer names listed when the typed word is a unit (#3663)

Typing `rad` into an expression box showed nothing because `rad` is the radians unit and
a word that is itself a known token closed the popup. The popup now stays open on such a
word. A complete name (`width` with `width_extra` defined) opens it unarmed: the longer
names are listed with no row selected, Enter submits what was typed and Tab moves on; Up
or Down picks a row, after which Enter or Tab complete it as before. A unit means
something only after a number, so a unit typed anywhere else (`rad` on the way to
`radius`) is a prefix like any other: the popup is armed and Enter completes `radius`
instead of submitting one radian.

A complete token is extended only by names that start with it (`in` lists `inner`, not
`rounding`), a typed unit offers no other units (`in` does not list `inch`), and a unit
right after a number (`2 in`, the form every dialog starts its sizes in) opens no popup at
all, so nothing sits over a dialog's OK button.

Fixes #3663

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Homero Thompson <vdirienzo@gmail.com>
Co-authored-by: SS-EN <84559909+SS-EN@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Martin Pålsson <martin.palsson@outlook.com>
Co-authored-by: Daniel <daniel.owsianski@gmail.com>
This commit is contained in:
selune
2026-10-24 10:51:48 +02:00
committed by GitHub
parent 5c0e48ed2b
commit d8c2abdb2b
20 changed files with 874 additions and 123 deletions
+6 -3
View File
@@ -6,11 +6,14 @@ use std::sync::Mutex;
use serde_json::{Value, json};
use solidcraft_engine::crash;
const USAGE: &str = "usage: solidcraft-cli crashes [list [--json] | show <id> | replay <id> | dir | clear [<id>]]";
/// The command's usage, from the same lines the top-level usage lists.
fn usage() -> String {
crate::command_usage("crashes")
}
pub fn run(args: &[String]) -> Result<(), String> {
let dir = crash::default_dir().ok_or("no crash folder on this system")?;
let id = || args.get(1).map(String::as_str).ok_or_else(|| USAGE.to_string());
let id = || args.get(1).map(String::as_str).ok_or_else(usage);
match args.first().map(String::as_str) {
None | Some("list") => {
let reports = crash::list(&dir);
@@ -43,7 +46,7 @@ pub fn run(args: &[String]) -> Result<(), String> {
println!("deleted {} report(s)", crash::clear(&dir, args.get(1).map(String::as_str)));
Ok(())
}
_ => Err(USAGE.to_string()),
_ => Err(usage()),
}
}
+10
View File
@@ -16,9 +16,16 @@ fn flag<T: std::str::FromStr>(args: &[String], name: &str, default: T) -> T {
args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).and_then(|v| v.parse().ok()).unwrap_or(default)
}
/// The options `fuzz` takes, and the ones that stand alone.
const OPTIONS: &[&str] = &["--from", "--count", "--jobs", "--timeout", "--steps", "--out", "--report"];
const SWITCHES: &[&str] = &["--minimise"];
/// One seed: a design and a boolean case, as JSON on stdout (progress on stderr).
pub fn one(args: &[String]) -> Result<(), String> {
let seed: u64 = args.first().and_then(|s| s.parse().ok()).ok_or("fuzz-one <seed> [--steps N] [--minimise]")?;
// The seed is the first argument; the rest are options, and an option `fuzz-one` doesn't
// take is refused rather than ignored.
crate::known_args("fuzz-one", &args[1..], &["--steps"], SWITCHES, false)?;
let steps = flag(args, "--steps", 8usize);
let minimise = args.iter().any(|a| a == "--minimise");
// Panics caught inside the kernel: harmless natively, fatal in the web build.
@@ -64,6 +71,9 @@ struct Job {
/// Run seeds `--from`…`--from + --count` in `--jobs` processes, `--timeout` seconds each;
/// write failing scripts to `--out` and a summary to `--report`.
pub fn run(args: &[String]) -> Result<(), String> {
// An option `fuzz` doesn't take is refused before a seed runs: it would otherwise be
// ignored, and the run would go on for minutes with the wrong settings.
crate::known_args("fuzz", args, OPTIONS, SWITCHES, false)?;
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")?;
+133 -61
View File
@@ -46,59 +46,98 @@ mod app_icon;
use solidcraft_engine::Session;
use solidcraft_engine::view::Guides;
const USAGE: &str = "usage:
solidcraft-cli run <script.json|design.solidcraft|part.step> [--in design.solidcraft|part.step] [--out FILE]... [--save FILE] [--quiet] [--results] [--timings] [--profile] [--json-out FILE] [--allow-errors]
solidcraft-cli run --in <design.solidcraft|part.step> [--out FILE]...
(a timeline feature that fails fails the run and nothing is written; --allow-errors writes anyway)
(--timings prints {timings: [{index, command, ms}], total_ms} for a script, with --results its results too;
--profile adds each command's profile: {boolean_ms, tessellate_ms, other_ms}, the kernel's share)
solidcraft-cli eval <script.json|design.solidcraft|part.step> [--json-out FILE] (alias: inspect; or --in FILE)
(--json-out FILE writes the JSON that run (with --results, else the measurement) and eval print to FILE instead of stdout,
as plain UTF-8: a shell's > can re-encode it, e.g. Windows PowerShell adds a byte-order mark)
solidcraft-cli script <design.solidcraft|script.json> [--out script.json]
(the design's history as a command script that eval and run replay)
solidcraft-cli bench-open <design.solidcraft | part.step> [--parse-only] [--save OUT.solidcraft] [--display LEVEL [--edit NAME EXPR]] [--ops script.json]
(--ops: after opening, run each command of the script and undo it, timing the command, the
evaluations it caused and the view it leaves: placing and meshing what changed)
solidcraft-cli bench-agent [--scale F] [--save design.solidcraft] [--script-out script.json]
solidcraft-cli bench-assistant --connect 127.0.0.1:PORT [--token T] --base-url URL --model NAME [--tasks 1,2,5] [--repeat N] [--timeout-s 300] [--json out.json] [--markdown out.md]
solidcraft-cli snapshot <script|design> [--in design.solidcraft|part.step] --out shot.png [--width W] [--height H] [--view NAME | --camera JSON] [--no-grid] [--no-axes]
[--body NAME:opacity=0.3,color=#rrggbb]... [--tool NAME]... [--section z=27]
solidcraft-cli snapshot --in <design.solidcraft|part.step> --out shot.png ...
(as with run, --in starts from a design and the script runs on top of it; an option snapshot doesn't take is an error;
--view: iso, front, back, top, bottom, left, right, or the name of a view saved in the design with view.save;
--camera: {target: [x,y,z], yaw, pitch, distance, fov?} in mm and radians, as the MCP screenshot takes it (docs/mcp.md);
--no-grid and --no-axes leave the ground grid (on Z 0) and the origin's axes out of the picture;
--body: how one body is drawn, as appearance.assign takes it: opacity 0-1 (below 1 is see-through, so what is inside
it shows) and color #rrggbb, either or both, once per body;
--tool: draw this body solid in the tool colour and every other body see-through, so a cut's or a join's tool body
is visible inside the material it meets; a later --body overrides it;
--section: cut the model as inspect.section does, so the inside shows: z=27 (the plane through that world
coordinate, keeping the smaller side), add ,flip to keep the other side, or XY, XZ, YZ, or its own JSON)
solidcraft-cli render <script|design> --out image.png [--setup NAME] [--width W] [--height H] [--transparent] [--overwrite] [--timeout SECONDS]
[--progressive] [--samples N] [--max-depth N] [--seed N] [--exposure EV] [--lighting FILE.json] [--environment FILE.json] [--tone-map reinhard|aces_fitted]
solidcraft-cli exec <command> [json-params]
solidcraft-cli commands
solidcraft-cli recipe <recipe.json>
solidcraft-cli oracle <case-dir>... [--json]
solidcraft-cli fuzz [--from N] [--count N] [--jobs J] [--timeout S] [--steps N] [--minimise] [--out DIR] [--report FILE]
solidcraft-cli mcp [--headless] [--in design.solidcraft|script.json] [--connect 127.0.0.1:PORT [--token T]] [--allow-path DIR]... [--allow-any-path]
[--param-check off|warn|strict]
solidcraft-cli crashes [list [--json] | show <id> | replay <id> | dir | clear [<id>]]
solidcraft-cli licences
solidcraft-cli gpu [--policy auto|hardware|software|cpu]
(the GPU adapters a headless render can open, best first, the one the policy picks and the tier it
lands on: gpu, gpu-software, or the CPU rasterizer (auto with no adapter, or cpu); the policy defaults
to SOLIDCRAFT_GPU, else auto; exits with an error when hardware or software finds no such adapter)
solidcraft-cli icon --out icon.png [--size 256] | --out icon.ico
--debug, with any command: what SolidCraft logs (why a body has no display plan, why a save left its
model cache out), on stderr; RUST_LOG=error|warn|info|debug|trace sets one level for every crate
";
/// One command the CLI takes: the name to type, the usage it prints for `--help`,
/// and whether the top-level usage lists it (an alias answers to another command's name).
struct Command {
name: &'static str,
usage: &'static str,
listed: bool,
}
/// `run`'s lines in the usage.
const USAGE_RUN: &str = " solidcraft-cli run <script.json|design.solidcraft|part.step> [--in design.solidcraft|part.step] [--out FILE]... [--save FILE] [--quiet] [--results] [--timings] [--profile] [--json-out FILE] [--allow-errors]\n solidcraft-cli run --in <design.solidcraft|part.step> [--out FILE]...\n (a timeline feature that fails fails the run and nothing is written; --allow-errors writes anyway)\n (--timings prints {timings: [{index, command, ms}], total_ms} for a script, with --results its results too;\n --profile adds each command's profile: {boolean_ms, tessellate_ms, other_ms}, the kernel's share)\n";
/// `eval`'s lines in the usage.
const USAGE_EVAL: &str = " solidcraft-cli eval <script.json|design.solidcraft|part.step> [--json-out FILE] (alias: inspect; or --in FILE)\n (--json-out FILE writes the JSON that run (with --results, else the measurement) and eval print to FILE instead of stdout,\n as plain UTF-8: a shell's > can re-encode it, e.g. Windows PowerShell adds a byte-order mark)\n";
/// `script`'s lines in the usage.
const USAGE_SCRIPT: &str = " solidcraft-cli script <design.solidcraft|script.json> [--out script.json]\n (the design's history as a command script that eval and run replay)\n";
/// `bench-open`'s lines in the usage.
const USAGE_BENCH_OPEN: &str = " solidcraft-cli bench-open <design.solidcraft | part.step> [--parse-only] [--save OUT.solidcraft] [--display LEVEL [--edit NAME EXPR]] [--ops script.json]\n (--ops: after opening, run each command of the script and undo it, timing the command, the\n evaluations it caused and the view it leaves: placing and meshing what changed)\n";
/// `bench-agent`'s lines in the usage.
const USAGE_BENCH_AGENT: &str = " solidcraft-cli bench-agent [--scale F] [--save design.solidcraft] [--script-out script.json]\n";
/// `bench-assistant`'s lines in the usage.
const USAGE_BENCH_ASSISTANT: &str = " solidcraft-cli bench-assistant --connect 127.0.0.1:PORT [--token T] --base-url URL --model NAME [--tasks 1,2,5] [--repeat N] [--timeout-s 300] [--json out.json] [--markdown out.md]\n";
/// `snapshot`'s lines in the usage.
const USAGE_SNAPSHOT: &str = " solidcraft-cli snapshot <script|design> [--in design.solidcraft|part.step] --out shot.png [--width W] [--height H] [--view NAME | --camera JSON] [--no-grid] [--no-axes]\n [--body NAME:opacity=0.3,color=#rrggbb]... [--tool NAME]... [--section z=27]\n solidcraft-cli snapshot --in <design.solidcraft|part.step> --out shot.png ...\n (as with run, --in starts from a design and the script runs on top of it; an option snapshot doesn't take is an error;\n --view: iso, front, back, top, bottom, left, right, or the name of a view saved in the design with view.save;\n --camera: {target: [x,y,z], yaw, pitch, distance, fov?} in mm and radians, as the MCP screenshot takes it (docs/mcp.md);\n --no-grid and --no-axes leave the ground grid (on Z 0) and the origin's axes out of the picture;\n --body: how one body is drawn, as appearance.assign takes it: opacity 0-1 (below 1 is see-through, so what is inside\n it shows) and color #rrggbb, either or both, once per body;\n --tool: draw this body solid in the tool colour and every other body see-through, so a cut's or a join's tool body\n is visible inside the material it meets; a later --body overrides it;\n --section: cut the model as inspect.section does, so the inside shows: z=27 (the plane through that world\n coordinate, keeping the smaller side), add ,flip to keep the other side, or XY, XZ, YZ, or its own JSON)\n";
/// `render`'s lines in the usage.
const USAGE_RENDER: &str = " solidcraft-cli render <script|design> --out image.png [--setup NAME] [--width W] [--height H] [--transparent] [--overwrite] [--timeout SECONDS]\n [--progressive] [--samples N] [--max-depth N] [--seed N] [--exposure EV] [--lighting FILE.json] [--environment FILE.json] [--tone-map reinhard|aces_fitted]\n";
/// `exec`'s lines in the usage.
const USAGE_EXEC: &str = " solidcraft-cli exec <command> [json-params]\n";
/// `commands`'s lines in the usage.
const USAGE_COMMANDS: &str = " solidcraft-cli commands\n";
/// `recipe`'s lines in the usage.
const USAGE_RECIPE: &str = " solidcraft-cli recipe <recipe.json>\n";
/// `oracle`'s lines in the usage.
const USAGE_ORACLE: &str = " solidcraft-cli oracle <case-dir>... [--json]\n";
/// `fuzz`'s lines in the usage.
const USAGE_FUZZ: &str =
" solidcraft-cli fuzz [--from N] [--count N] [--jobs J] [--timeout S] [--steps N] [--minimise] [--out DIR] [--report FILE]\n";
/// `mcp`'s lines in the usage.
const USAGE_MCP: &str = " solidcraft-cli mcp [--headless] [--in design.solidcraft|script.json] [--connect 127.0.0.1:PORT [--token T]] [--allow-path DIR]... [--allow-any-path]\n [--param-check off|warn|strict]\n";
/// `crashes`'s lines in the usage.
const USAGE_CRASHES: &str = " solidcraft-cli crashes [list [--json] | show <id> | replay <id> | dir | clear [<id>]]\n";
/// `licences`'s lines in the usage.
const USAGE_LICENCES: &str = " solidcraft-cli licences\n";
/// `gpu`'s lines in the usage.
const USAGE_GPU: &str = " solidcraft-cli gpu [--policy auto|hardware|software|cpu]\n (the GPU adapters a headless render can open, best first, the one the policy picks and the tier it\n lands on: gpu, gpu-software, or the CPU rasterizer (auto with no adapter, or cpu); the policy defaults\n to SOLIDCRAFT_GPU, else auto; exits with an error when hardware or software finds no such adapter)\n";
/// `icon`'s lines in the usage.
const USAGE_ICON: &str = " solidcraft-cli icon --out icon.png [--size 256] | --out icon.ico\n";
/// `fuzz-one`'s lines in the usage: a seed and the flags of one fuzz run.
const USAGE_FUZZ_ONE: &str = " solidcraft-cli fuzz-one <seed> [--steps N] [--minimise]\n";
/// `bench-edit`'s lines in the usage: the parameter a script changes.
const USAGE_BENCH_EDIT: &str = " solidcraft-cli bench-edit <script.json|recipe.json> <param> <expr>\n";
/// `save-stress`'s lines in the usage: the file saved over and over.
const USAGE_SAVE_STRESS: &str = " solidcraft-cli save-stress <path> <rounds>\n";
/// The flags every command takes, at the end of the top-level usage.
const USAGE_DEBUG: &str = " --debug, with any command: what SolidCraft logs (why a body has no display plan, why a save left its\n model cache out), on stderr; RUST_LOG=error|warn|info|debug|trace sets one level for every crate\n";
/// The commands the CLI takes, in the order the top-level usage lists them.
const COMMANDS: &[Command] = &[
Command { name: "run", usage: USAGE_RUN, listed: true },
Command { name: "eval", usage: USAGE_EVAL, listed: true },
Command { name: "inspect", usage: USAGE_EVAL, listed: false },
Command { name: "script", usage: USAGE_SCRIPT, listed: true },
Command { name: "bench-open", usage: USAGE_BENCH_OPEN, listed: true },
Command { name: "bench-agent", usage: USAGE_BENCH_AGENT, listed: true },
Command { name: "bench-assistant", usage: USAGE_BENCH_ASSISTANT, listed: true },
Command { name: "snapshot", usage: USAGE_SNAPSHOT, listed: true },
Command { name: "render", usage: USAGE_RENDER, listed: true },
Command { name: "exec", usage: USAGE_EXEC, listed: true },
Command { name: "commands", usage: USAGE_COMMANDS, listed: true },
Command { name: "recipe", usage: USAGE_RECIPE, listed: true },
Command { name: "oracle", usage: USAGE_ORACLE, listed: true },
Command { name: "fuzz", usage: USAGE_FUZZ, listed: true },
Command { name: "fuzz-one", usage: USAGE_FUZZ_ONE, listed: false },
Command { name: "mcp", usage: USAGE_MCP, listed: true },
Command { name: "crashes", usage: USAGE_CRASHES, listed: true },
Command { name: "licences", usage: USAGE_LICENCES, listed: true },
Command { name: "licenses", usage: USAGE_LICENCES, listed: false },
Command { name: "gpu", usage: USAGE_GPU, listed: true },
Command { name: "icon", usage: USAGE_ICON, listed: true },
Command { name: "bench-edit", usage: USAGE_BENCH_EDIT, listed: false },
Command { name: "save-stress", usage: USAGE_SAVE_STRESS, listed: false },
];
fn main() -> ExitCode {
let mut args: Vec<String> = std::env::args().skip(1).collect();
logger::init(&mut args);
let r = match args.first().map(String::as_str) {
// Asked-for help is not an error: the command's usage on stdout, exit 0. It is read
// before the command runs, so `--help` never starts a run (a fuzz run, a server).
Some(cmd) if wants_help(&args[1..]) && is_command(cmd) => {
use std::io::Write as _;
let _ = std::io::stdout().lock().write_all(command_usage(cmd).as_bytes());
Ok(())
}
Some("run") => cmd_run(&args[1..]),
Some("eval" | "inspect") => cmd_eval(&args[1..]),
Some("script") => cmd_script(&args[1..]),
@@ -119,7 +158,7 @@ fn main() -> ExitCode {
}
})
}
Some("recipe") => args.get(1).ok_or_else(|| USAGE.to_string()).and_then(|p| {
Some("recipe") => args.get(1).ok_or_else(usage).and_then(|p| {
let v = read_json(p)?;
recipe::to_script(&v).map(|s| println!("{}", pretty(&s)))
}),
@@ -151,10 +190,10 @@ fn main() -> ExitCode {
// Asked-for help is not an error: usage on stdout, exit 0.
Some("--help" | "-h" | "help") => {
use std::io::Write as _;
let _ = std::io::stdout().lock().write_all(USAGE.as_bytes());
let _ = std::io::stdout().lock().write_all(usage().as_bytes());
Ok(())
}
_ => Err(USAGE.to_string()),
_ => Err(usage()),
};
match r {
Ok(()) => ExitCode::SUCCESS,
@@ -169,6 +208,35 @@ fn pretty(v: &Value) -> String {
serde_json::to_string_pretty(v).unwrap_or_default()
}
/// The whole usage: every command's lines under one `usage:` line. The lines come from the
/// same place a command's own `--help` prints, so the two cannot drift apart.
fn usage() -> String {
let mut s = String::from("usage:\n");
for c in COMMANDS.iter().filter(|c| c.listed) {
s.push_str(c.usage);
}
s.push_str(USAGE_DEBUG);
s
}
/// A command's own usage: its lines under a `usage:` line.
fn command_usage(cmd: &str) -> String {
match COMMANDS.iter().find(|c| c.name == cmd) {
Some(c) => format!("usage:\n{}", c.usage),
None => usage(),
}
}
/// Whether the name is a command the CLI takes.
fn is_command(cmd: &str) -> bool {
COMMANDS.iter().any(|c| c.name == cmd)
}
/// Whether the arguments ask for help.
fn wants_help(args: &[String]) -> bool {
args.iter().any(|a| a == "--help" || a == "-h")
}
pub fn read_json(path: &str) -> Result<Value, String> {
// Through the engine's file access, which refuses anything that is not a regular file and
// reads no more than the cap: a character device or a FIFO would otherwise never end.
@@ -274,7 +342,7 @@ fn cmd_run(args: &[String]) -> Result<(), String> {
let base = flag(args, "--in");
let path = positional(args);
if path.is_none() && base.is_none() {
return Err(USAGE.to_string());
return Err(usage());
}
let want_results = args.iter().any(|a| a == "--results");
// `--profile` times each command and splits out its kernel time; it implies `--timings`.
@@ -334,13 +402,14 @@ fn open(base: Option<&str>, path: Option<&String>) -> Result<Session, String> {
}
(Some(b), None) => load(b),
(None, Some(p)) => load(p),
(None, None) => Err(USAGE.to_string()),
(None, None) => Err(usage()),
}
}
/// Refuses an option `cmd` doesn't take, and one missing its value: a typo, or another
/// command's option, would otherwise be ignored without a word.
fn known_flags(cmd: &str, args: &[String], valued: &[&str], switches: &[&str]) -> Result<(), String> {
/// command's option, would otherwise be ignored without a word. `positionals` says whether the
/// command takes a word of its own (a file, a seed): `fuzz` takes none, and would start a run.
fn known_args(cmd: &str, args: &[String], valued: &[&str], switches: &[&str], positionals: bool) -> Result<(), String> {
let mut i = 0;
while let Some(a) = args.get(i) {
if valued.contains(&a.as_str()) {
@@ -348,7 +417,9 @@ fn known_flags(cmd: &str, args: &[String], valued: &[&str], switches: &[&str]) -
Some(v) if !v.starts_with("--") => i += 2,
_ => return Err(format!("{cmd}: {a} needs a value")),
}
} else if a.starts_with("--") && !switches.contains(&a.as_str()) {
} else if switches.contains(&a.as_str()) {
i += 1;
} else if a.starts_with("--") || !positionals {
return Err(format!("{cmd} doesn't take {a} (it takes {})", [valued, switches].concat().join(", ")));
} else {
i += 1;
@@ -372,7 +443,7 @@ fn timeline_ok(s: &Session) -> Result<(), String> {
fn cmd_eval(args: &[String]) -> Result<(), String> {
let json_out = json_out(args)?;
let path = flag(args, "--in").or_else(|| positional(args).map(String::as_str)).ok_or_else(|| USAGE.to_string())?;
let path = flag(args, "--in").or_else(|| positional(args).map(String::as_str)).ok_or_else(usage)?;
let mut s = load(path)?;
let m = s.execute("inspect.measure", &json!({})).map_err(|e| e.to_string())?;
let d = s.execute("document.inspect", &json!({})).map_err(|e| e.to_string())?;
@@ -390,7 +461,7 @@ fn cmd_eval(args: &[String]) -> Result<(), String> {
/// A design's history as a command script (`timeline.export_script`): printed, or written to
/// `--out`; what the script leaves out goes to stderr.
fn cmd_script(args: &[String]) -> Result<(), String> {
let path = positional(args).ok_or_else(|| USAGE.to_string())?;
let path = positional(args).ok_or_else(usage)?;
let s = &mut load(path)?;
let out = flag(args, "--out");
let r = s.execute("timeline.export_script", &json!({"path": out})).map_err(|e| e.to_string())?;
@@ -451,14 +522,15 @@ fn install_renderer() {
fn cmd_snapshot(args: &[String]) -> Result<(), String> {
install_renderer();
known_flags(
known_args(
"snapshot",
args,
&["--in", "--out", "--width", "--height", "--view", "--camera", "--body", "--tool", "--section"],
&["--no-grid", "--no-axes"],
true,
)?;
if positional(args).is_none() && flag(args, "--in").is_none() {
return Err(USAGE.to_string());
return Err(usage());
}
let out = flag(args, "--out").ok_or("snapshot needs --out FILE.png")?;
let size = |name: &str, default: u32| match flag(args, name) {
@@ -542,7 +614,7 @@ fn ico(images: &[(usize, Vec<u8>)]) -> Result<Vec<u8>, String> {
fn cmd_exec(args: &[String]) -> Result<(), String> {
install_renderer();
let id = args.first().ok_or_else(|| USAGE.to_string())?;
let id = args.first().ok_or_else(usage)?;
let p: Value = match args.get(1) {
Some(t) => serde_json::from_str(t).map_err(|e| format!("params: {e}"))?,
None => json!({}),
+137 -1
View File
@@ -1,6 +1,84 @@
//! An explicit help request prints usage to stdout and succeeds; an unknown command still fails.
use std::process::Command;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
/// Every command the CLI takes, with the name its own usage names: `inspect` is `eval`, and
/// `licenses` is `licences`.
const COMMANDS: &[(&str, &str)] = &[
("run", "run"),
("eval", "eval"),
("inspect", "eval"),
("script", "script"),
("bench-open", "bench-open"),
("bench-agent", "bench-agent"),
("snapshot", "snapshot"),
("render", "render"),
("exec", "exec"),
("commands", "commands"),
("recipe", "recipe"),
("oracle", "oracle"),
("fuzz", "fuzz"),
("fuzz-one", "fuzz-one"),
("mcp", "mcp"),
("crashes", "crashes"),
("licences", "licences"),
("licenses", "licences"),
("gpu", "gpu"),
("icon", "icon"),
("bench-edit", "bench-edit"),
("save-stress", "save-stress"),
];
/// The commands the usage lists: the aliases answer to another command's name.
const LISTED: &[&str] = &[
"run",
"eval",
"script",
"bench-open",
"bench-agent",
"snapshot",
"render",
"exec",
"commands",
"recipe",
"oracle",
"fuzz",
"mcp",
"crashes",
"licences",
"gpu",
"icon",
];
/// The command names a text mentions after `solidcraft-cli `, each one only once.
fn mentioned(text: &str) -> Vec<&str> {
let mut names: Vec<&str> = text.split("solidcraft-cli ").skip(1).filter_map(|rest| rest.split_whitespace().next()).collect();
names.sort_unstable();
names.dedup();
names
}
/// Runs the binary and waits at most `limit` for it to finish. `None` means it was still
/// running and was killed: the fuzz run `fuzz --help` used to start goes on for minutes.
fn run_within(limit: Duration, args: &[&str]) -> Option<(Option<i32>, String, String)> {
let mut child = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).args(args).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
let started = Instant::now();
loop {
match child.try_wait().unwrap() {
Some(status) => {
let out = child.wait_with_output().unwrap();
return Some((status.code(), String::from_utf8_lossy(&out.stdout).into_owned(), String::from_utf8_lossy(&out.stderr).into_owned()));
}
None if started.elapsed() >= limit => {
let _ = child.kill();
let _ = child.wait();
return None;
}
None => std::thread::sleep(Duration::from_millis(25)),
}
}
}
#[test]
fn help_prints_usage_and_succeeds() {
@@ -21,3 +99,61 @@ fn unknown_command_still_fails_with_usage() {
assert!(output.stdout.is_empty());
assert!(String::from_utf8_lossy(&output.stderr).starts_with("error: usage:"));
}
#[test]
fn every_command_prints_its_own_usage() {
for (cmd, named) in COMMANDS {
for flag in ["--help", "-h"] {
let output = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).args([*cmd, flag]).output().unwrap();
assert!(output.status.success(), "{cmd} {flag}: {}", String::from_utf8_lossy(&output.stderr));
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.starts_with("usage:"), "{cmd} {flag}: {stdout}");
assert_eq!(mentioned(&stdout), vec![*named], "{cmd} {flag} printed another command's usage: {stdout}");
assert!(output.stderr.is_empty(), "{cmd} {flag}: {}", String::from_utf8_lossy(&output.stderr));
}
}
}
#[test]
fn the_top_level_usage_still_lists_every_command() {
let output = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).arg("--help").output().unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
for cmd in LISTED {
assert!(stdout.contains(&format!("solidcraft-cli {cmd}")), "{cmd}: {stdout}");
}
}
#[test]
fn fuzz_help_does_not_start_a_fuzz_run() {
let Some((code, stdout, stderr)) = run_within(Duration::from_secs(20), &["fuzz", "--help"]) else {
panic!("`fuzz --help` kept running: it started a fuzz run instead of printing the usage");
};
assert_eq!(code, Some(0), "{stderr}");
assert!(stdout.starts_with("usage:"), "{stdout}");
assert_eq!(mentioned(&stdout), vec!["fuzz"], "{stdout}");
assert!(stderr.is_empty(), "{stderr}");
}
#[test]
fn fuzz_refuses_an_argument_it_does_not_take() {
for arg in ["--not-a-flag", "stray"] {
let Some((code, _stdout, stderr)) = run_within(Duration::from_secs(20), &["fuzz", arg]) else {
panic!("`fuzz {arg}` kept running: it started a fuzz run instead of refusing the argument");
};
assert_ne!(code, Some(0), "`fuzz {arg}` succeeded");
assert!(stderr.contains(arg), "`fuzz {arg}`: {stderr}");
}
}
#[test]
fn render_help_keeps_the_registered_gpu_request_controls() {
for args in [vec!["--help"], vec!["render", "--help"]] {
let output = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).args(&args).output().unwrap();
assert!(output.status.success(), "{args:?}: {}", String::from_utf8_lossy(&output.stderr));
assert!(output.stderr.is_empty());
let stdout = String::from_utf8_lossy(&output.stdout);
for flag in ["--progressive", "--samples", "--max-depth", "--seed", "--exposure", "--lighting", "--environment", "--tone-map"] {
assert!(stdout.contains(flag), "{args:?} lost {flag}: {stdout}");
}
}
}
+2 -1
View File
@@ -2400,5 +2400,6 @@
"update.up_to_date": "SolidCraft is up to date.",
"update.update_now": "Update now",
"update.you_have": "You have {version}.",
"↑ ↓ choose · Enter / Tab complete · Esc dismiss": "↑ ↓ choose · Enter / Tab complete · Esc dismiss"
"↑ ↓ choose · Enter / Tab complete · Esc dismiss": "↑ ↓ choose · Enter / Tab complete · Esc dismiss",
"↑ ↓ choose · Enter applies what you typed · Esc dismiss": "↑ ↓ choose · Enter applies what you typed · Esc dismiss"
}
+2 -1
View File
@@ -2400,5 +2400,6 @@
"update.up_to_date": "SolidCraft は最新です。",
"update.update_now": "今すぐ更新",
"update.you_have": "現在のバージョンは {version} です。",
"↑ ↓ choose · Enter / Tab complete · Esc dismiss": "↑ ↓ 選択 · Enter / Tab 補完 · Esc 閉じる"
"↑ ↓ choose · Enter / Tab complete · Esc dismiss": "↑ ↓ 選択 · Enter / Tab 補完 · Esc 閉じる",
"↑ ↓ choose · Enter applies what you typed · Esc dismiss": "↑ ↓ 選択 · Enter 入力どおりに適用 · Esc 閉じる"
}
+2 -1
View File
@@ -2400,5 +2400,6 @@
"update.up_to_date": "SolidCraft 已是最新版本。",
"update.update_now": "立即更新",
"update.you_have": "当前版本为 {version}。",
"↑ ↓ choose · Enter / Tab complete · Esc dismiss": "↑ ↓ 选择 · Enter / Tab 补全 · Esc 关闭"
"↑ ↓ choose · Enter / Tab complete · Esc dismiss": "↑ ↓ 选择 · Enter / Tab 补全 · Esc 关闭",
"↑ ↓ choose · Enter applies what you typed · Esc dismiss": "↑ ↓ 选择 · Enter 按原样应用 · Esc 关闭"
}
+117 -18
View File
@@ -1245,7 +1245,7 @@ fn chain_fingerprint(
fp.hash(&mut h);
serde_json::to_string(&doc.sheet).unwrap_or_default().hash(&mut h);
for r in &doc.sheet.rules {
for e in [&r.thickness, &r.k_factor, &r.bend_radius, &r.relief_width, &r.relief_depth, &r.corner_relief, &r.hem_gap, &r.gap] {
for (_, e, _) in r.fields() {
for n in expr::references(e) {
vals.get(&n).map(|v| v.v.to_bits()).hash(&mut h);
}
@@ -1284,12 +1284,7 @@ fn context_digest(doc: &Document, vals: &BTreeMap<String, Value>) -> u64 {
serde_json::to_string(&doc.plastic).unwrap_or_default().hash(&mut h);
serde_json::to_string(&doc.components).unwrap_or_default().hash(&mut h);
serde_json::to_string(&doc.body_components).unwrap_or_default().hash(&mut h);
for e in doc
.sheet
.rules
.iter()
.flat_map(|r| [&r.thickness, &r.k_factor, &r.bend_radius, &r.relief_width, &r.relief_depth, &r.corner_relief, &r.hem_gap, &r.gap])
{
for e in doc.sheet.rules.iter().flat_map(|r| r.fields().map(|(_, e, _)| e)) {
for n in expr::references(e) {
vals.get(&n).map(|v| v.v.to_bits()).hash(&mut h);
}
@@ -1980,6 +1975,13 @@ impl Model {
}
_ => Vec::new(),
};
// Copies made through a pattern or mirror among the sources come after (#395).
let mats = match &f.kind {
FeatureKind::Pattern { features, bodies, .. } | FeatureKind::Mirror { features, bodies, .. } if bodies.is_empty() => {
copy_instances(doc, &vals, &state, &crate::plain(features), &mats).unwrap_or(mats)
}
_ => mats,
};
next.stamp(&state, f, key, mats);
for b in &mut next.bodies {
if !b.transferred {
@@ -5518,6 +5520,108 @@ fn copy_misses(st: &ModelState, f: &Feature, targets: &[String], tools: &[Traced
})
}
/// The deepest a pattern or mirror reaches through patterns and mirrors of features (#395).
const MAX_NESTING: usize = 8;
/// The feature a pattern or mirror names as a source. A sketch and its extrude often share a
/// name; the feature that makes geometry wins.
fn source_feature<'a>(doc: &'a Document, name: &str) -> Result<&'a Feature> {
doc.features
.iter()
.find(|x| x.name == name && !matches!(x.kind, FeatureKind::Sketch { .. } | FeatureKind::ConstructionPlane { .. }))
.or_else(|| doc.find_feature(name))
.ok_or_else(|| DocError::Unknown(format!("feature `{name}`")))
}
/// What a pattern or mirror of features copies, when it is the source of another one (#395): its
/// own sources, and the transforms of its copies. `None` for any other feature, and for a pattern
/// or mirror of bodies (its copies are bodies, which a pattern or mirror of features can't take).
fn copied_features(doc: &Document, vals: &BTreeMap<String, Value>, st: &ModelState, src: &Feature) -> Result<Option<(Vec<String>, Vec<Mat>)>> {
match &src.kind {
FeatureKind::Pattern { features, pattern, bodies } if bodies.is_empty() => {
Ok(Some((crate::plain(features), pattern_transforms(doc, vals, st, src.component, pattern)?)))
}
FeatureKind::Mirror { features, plane, bodies, .. } if bodies.is_empty() => {
let pl = doc.resolve_plane_in(vals, plane, 0, Some(st))?;
Ok(Some((crate::plain(features), vec![mirror_matrix(&pl)])))
}
_ => Ok(None),
}
}
/// The copies a pattern or mirror of `features` at `mats` makes, source by source: the source
/// feature, the transforms of its copies, and the instance number of its first copy less one.
/// A source that is itself a pattern or mirror of features (#395) stands for what it copied: its
/// own sources, at each of its transforms moved by each of ours. Those copies are numbered after
/// `mats`' own (which keep the numbers they always had), each nested source in a block of its
/// own, so no two copies of one feature share an instance. Suppressed sources copy nothing.
fn copy_plan<'a>(
doc: &'a Document,
vals: &BTreeMap<String, Value>,
st: &ModelState,
features: &[String],
mats: &[Mat],
) -> Result<Vec<(&'a Feature, Vec<Mat>, usize)>> {
#[allow(clippy::too_many_arguments)]
fn walk<'a>(
doc: &'a Document,
vals: &BTreeMap<String, Value>,
st: &ModelState,
features: &[String],
mats: &[Mat],
first: usize,
next: &mut usize,
depth: usize,
out: &mut Vec<(&'a Feature, Vec<Mat>, usize)>,
) -> Result<()> {
for name in features {
if doc.find_feature(name).is_some_and(|x| x.suppressed) {
continue;
}
let src = source_feature(doc, name)?;
let Some((inner, inner_mats)) = copied_features(doc, vals, st, src)? else {
out.push((src, mats.to_vec(), first));
continue;
};
if depth >= MAX_NESTING {
return Err(DocError::Invalid(format!("patterns and mirrors of patterns and mirrors nest at most {MAX_NESTING} deep")));
}
let moved: Vec<Mat> = mats.iter().flat_map(|m| inner_mats.iter().map(move |mi| crate::mat_mul(m, mi))).collect();
if moved.len() > MAX_INSTANCES || next.saturating_add(moved.len()) > MAX_INSTANCES * MAX_NESTING {
return Err(DocError::Invalid("too many pattern instances".into()));
}
let block = *next;
*next += moved.len();
walk(doc, vals, st, &inner, &moved, block, next, depth + 1, out)?;
}
Ok(())
}
let mut next = mats.len();
let mut out = Vec::new();
walk(doc, vals, st, features, mats, 0, &mut next, 0, &mut out)?;
Ok(out)
}
/// The transforms of a pattern's or mirror's copies in their instance order (instance `k + 1`
/// is the `k`-th): `mats`, then those of the copies made through patterns and mirrors among
/// `features` ([`copy_plan`]). Naming finds a copy's faces through them.
fn copy_instances(doc: &Document, vals: &BTreeMap<String, Value>, st: &ModelState, features: &[String], mats: &[Mat]) -> Result<Vec<Mat>> {
let plan = copy_plan(doc, vals, st, features, mats)?;
let len = plan.iter().map(|(_, ms, first)| first + ms.len()).max().unwrap_or(0).max(mats.len());
let mut all = vec![crate::IDENTITY; len];
for (slot, m) in all.iter_mut().zip(mats) {
*slot = *m;
}
for (_, ms, first) in &plan {
for (k, m) in ms.iter().enumerate() {
if let Some(slot) = all.get_mut(first + k) {
*slot = *m;
}
}
}
Ok(all)
}
/// Apply transformed copies of the tools of `features` (patterns, mirrors).
fn replay(
doc: &Document,
@@ -5542,14 +5646,9 @@ fn replay(
});
}
let (mut missed, mut total) = (0usize, 0usize);
for name in features.iter().filter(|n| !suppressed.contains(n)) {
// A sketch and its extrude often share a name; the feature that makes geometry wins.
let src = doc
.features
.iter()
.find(|x| &x.name == name && !matches!(x.kind, FeatureKind::Sketch { .. } | FeatureKind::ConstructionPlane { .. }))
.or_else(|| doc.find_feature(name))
.ok_or_else(|| DocError::Unknown(format!("feature `{name}`")))?;
let kept: Vec<String> = features.iter().filter(|n| !suppressed.contains(n)).cloned().collect();
let plan = copy_plan(doc, vals, st, &kept, mats)?;
for (src, mats, first) in &plan {
// A fillet or chamfer repeats at the copied edges.
if let FeatureKind::Fillet { edges, radius, .. } | FeatureKind::Chamfer { edges, distance: radius, .. } = &src.kind {
let r = val(vals, radius, Kind::Length)?;
@@ -5606,7 +5705,7 @@ fn replay(
copies.extend(
hole_tools(vals, st, &at, d, diameter, &depth, hole)?
.into_iter()
.map(|t| Traced { lineage: t.lineage.map(|l| l.copied(src.id, k + 1)), body: t.body }),
.map(|t| Traced { lineage: t.lineage.map(|l| l.copied(src.id, first + k + 1)), body: t.body }),
);
}
instances.push(start..copies.len());
@@ -5618,7 +5717,7 @@ fn replay(
for body in more::rib_tools(vals, st, src, Some(m))? {
// Each copy's faces are named as the rib's, with the instance.
let t = Traced::unnamed(body);
copies.push(Traced { lineage: t.lineage.map(|l| l.copied(src.id, k + 1)), body: t.body });
copies.push(Traced { lineage: t.lineage.map(|l| l.copied(src.id, first + k + 1)), body: t.body });
}
}
} else {
@@ -5627,7 +5726,7 @@ fn replay(
for (k, m) in mats.iter().enumerate() {
let start = copies.len();
for t in &tools {
copies.push(t.copied(kernel::transform_matrix(&t.body, *m)?, src.id, k + 1));
copies.push(t.copied(kernel::transform_matrix(&t.body, *m)?, src.id, first + k + 1));
}
instances.push(start..copies.len());
}
+55
View File
@@ -570,6 +570,10 @@ pub enum ParamUser {
/// A joint's offset, angle or value, or a motion link's gear, rack or screw length (`input`
/// says which; a link is named by the joint it drives).
Joint { joint: u64, name: String, input: String },
/// A sheet metal or plastic rule's value (`kind` says which, `field` which value).
Rule { kind: String, name: String, field: String },
/// The plane a canvas lies on (`input` is its label, e.g. `Offset`).
Canvas { canvas: u64, name: String, input: String },
}
impl ParamUser {
@@ -583,6 +587,8 @@ impl ParamUser {
None => format!("configuration column {column}"),
},
ParamUser::Joint { name, input, .. } => format!("joint {name} ({input})"),
ParamUser::Rule { kind, name, field } => format!("{kind} rule {name} ({field})"),
ParamUser::Canvas { name, input, .. } => format!("canvas {name} ({input})"),
}
}
}
@@ -715,6 +721,24 @@ impl Document {
out.push(ParamUser::Joint { joint, name: jname, input });
}
}
let uses = |e: &str| expr::references(e).iter().any(|r| r == name);
for r in &self.sheet.rules {
for (field, _, _) in r.fields().into_iter().filter(|(_, e, _)| uses(e)) {
out.push(ParamUser::Rule { kind: "sheet metal".into(), name: r.name.clone(), field: field.into() });
}
}
for r in &self.plastic.rules {
for (field, _, _) in r.fields().into_iter().filter(|(_, e, _)| uses(e)) {
out.push(ParamUser::Rule { kind: "plastic".into(), name: r.name.clone(), field: field.into() });
}
}
for c in &self.canvases {
c.plane.walk_exprs(&mut |(label, e, _, _)| {
if uses(e) {
out.push(ParamUser::Canvas { canvas: c.id, name: c.name.clone(), input: label.into() });
}
});
}
out
}
@@ -833,6 +857,24 @@ impl Document {
n += 1;
}
}
// Sheet metal and plastic rules and canvas planes hold expressions too (#4420).
let rules = self.sheet.rules.iter_mut().flat_map(|r| r.fields_mut()).chain(self.plastic.rules.iter_mut().flat_map(|r| r.fields_mut()));
for (_, e) in rules {
let r = expr::rename_reference(e, old, new);
if r != *e {
*e = r;
n += 1;
}
}
for c in &mut self.canvases {
c.plane.walk_exprs_mut(&mut |(_, e, _, _)| {
let r = expr::rename_reference(e, old, new);
if r != *e {
*e = r.into();
n += 1;
}
});
}
if let Some(c) = self.param_comments.remove(old) {
self.param_comments.insert(new.to_string(), c);
}
@@ -870,6 +912,13 @@ impl Document {
names.extend(sketch.text_exprs().into_iter().flat_map(|(_, e)| expr::references(e)));
names.extend(sketch.patterns.iter().filter_map(|p| p.count_expression.as_deref()).flat_map(expr::references));
}
// Sheet metal and plastic features rebuild when their rules' parameters change (#4420).
if f.kind.is_sheet() {
names.extend(self.sheet.rules.iter().flat_map(|r| r.fields()).flat_map(|(_, e, _)| expr::references(e)));
}
if f.kind.is_plastic() {
names.extend(self.plastic.rules.iter().flat_map(|r| r.fields()).flat_map(|(_, e, _)| expr::references(e)));
}
out.extend(names.into_iter().map(|n| (format!("Feature:{}", f.name), n)));
}
out
@@ -890,9 +939,15 @@ impl Document {
break;
}
}
// A rule that uses it changes every sheet metal (or plastic) feature: the evaluation
// hashes the rules into their fingerprint.
let uses = |e: &String| expr::references(e).iter().any(|r| affected.contains(r));
let sheet = self.sheet.rules.iter().any(|r| r.fields().iter().any(|(_, e, _)| uses(e)));
let plastic = self.plastic.rules.iter().any(|r| r.fields().iter().any(|(_, e, _)| uses(e)));
let mut out = Vec::new();
for f in &self.features {
let mut hit = f.param_names.iter().any(|n| affected.contains(n));
hit |= (sheet && f.kind.is_sheet()) || (plastic && f.kind.is_plastic());
hit |= f.kind.expressions().iter().any(|e| expr::references(e).iter().any(|r| affected.contains(r)));
if let FeatureKind::Sketch { sketch, .. } = &f.kind {
hit |= sketch.constraints.iter().any(|c| c.param.as_ref().is_some_and(|p| affected.contains(p)));
+16
View File
@@ -88,6 +88,22 @@ impl PlasticRule {
_ => return None,
})
}
/// The expressions to change, by field name, in the order of [`PlasticRule::fields`].
pub fn fields_mut(&mut self) -> [(&'static str, &mut String); 10] {
[
("thickness", &mut self.thickness),
("nominal_radius", &mut self.nominal_radius),
("clearance", &mut self.clearance),
("knife_edge", &mut self.knife_edge),
("reveal_height", &mut self.reveal_height),
("thickness_variation", &mut self.thickness_variation),
("draft", &mut self.draft),
("max_thickness", &mut self.max_thickness),
("min_thickness", &mut self.min_thickness),
("min_draft", &mut self.min_draft),
]
}
}
/// The design's own plastic rules, the active one and the rules assigned to bodies.
+31
View File
@@ -261,6 +261,37 @@ pub struct SheetRule {
pub gap: String,
}
impl SheetRule {
/// The expressions, by field name (as commands name them), with the kind each evaluates to.
/// (`corner_relief_shape` is a choice, not an expression.)
pub fn fields(&self) -> [(&'static str, &String, Kind); 8] {
[
("thickness", &self.thickness, Kind::Length),
("k_factor", &self.k_factor, Kind::Unitless),
("bend_radius", &self.bend_radius, Kind::Length),
("relief_width", &self.relief_width, Kind::Length),
("relief_depth", &self.relief_depth, Kind::Length),
("corner_relief", &self.corner_relief, Kind::Length),
("hem_gap", &self.hem_gap, Kind::Length),
("gap", &self.gap, Kind::Length),
]
}
/// The expressions to change, by field name, in the order of [`SheetRule::fields`].
pub fn fields_mut(&mut self) -> [(&'static str, &mut String); 8] {
[
("thickness", &mut self.thickness),
("k_factor", &mut self.k_factor),
("bend_radius", &mut self.bend_radius),
("relief_width", &mut self.relief_width),
("relief_depth", &mut self.relief_depth),
("corner_relief", &mut self.corner_relief),
("hem_gap", &mut self.hem_gap),
("gap", &mut self.gap),
]
}
}
fn rule_gap() -> String {
"Thickness".into()
}
+59
View File
@@ -195,6 +195,65 @@ fn deleting_a_used_parameter_lists_its_users() {
assert!(g["edges"].as_array().unwrap().iter().any(|e| e[0] == "Feature:Base" && e[1] == dim.as_str()), "{g}");
}
/// #4420: sheet metal and plastic rules and the plane of a canvas hold expressions too. Renaming a
/// parameter they use renames it there, and they count as its users (so it can't be deleted).
#[test]
fn rules_and_canvases_follow_a_renamed_parameter() {
let mut s = Session::default();
for (name, expression) in [("gauge", "2 mm"), ("lift", "15 mm"), ("taper", "3 deg")] {
run(&mut s, "parameters.add", json!({"name": name, "expression": expression}));
}
let mut png = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR".to_vec();
png.extend([0, 0, 0, 4, 0, 0, 0, 2, 8, 6, 0, 0, 0, 0, 0, 0, 0]);
run(&mut s, "canvas.insert", json!({"data": solidcraft_doc::canvas::base64_encode(&png), "plane": "XY", "offset": "lift"}));
run(&mut s, "sheet.manage_rules", json!({"name": "Steel", "thickness": "gauge", "active": true}));
run(&mut s, "solid.box", json!({"length": 60, "width": 40, "height": 2, "body_name": "Tray"}));
run(&mut s, "sheet.convert", json!({"body": "Tray", "face": [30, 20, 2], "rule": "Steel"}));
run(&mut s, "plastic.manage_rules", json!({"name": "Housing", "draft": "taper", "clearance": "gauge / 10"}));
let users = |s: &mut Session, name: &str| -> Vec<String> {
let u = run(s, "parameters.users", json!({"name": name}));
u["users"].as_array().unwrap().iter().map(|x| x["type"].as_str().unwrap_or_default().to_string()).collect()
};
assert_eq!(users(&mut s, "gauge"), ["rule", "rule"], "the sheet metal thickness and the plastic clearance");
assert_eq!(users(&mut s, "lift"), ["canvas"]);
let e = err(&mut s, "parameters.delete", json!({"name": "taper"}));
assert!(e.contains("plastic rule Housing (draft)"), "{e}");
// The sheet metal features rebuild when a rule's parameter changes, so they are what it drives.
let u = run(&mut s, "parameters.users", json!({"name": "gauge"}));
assert_eq!(
u["drives"].as_array().map(|d| d.iter().map(|x| x["name"].clone()).collect::<Vec<_>>()),
Some(vec![json!("ConvertToSheetMetal1")]),
"{u}"
);
let g = run(&mut s, "parameters.graph", json!({}));
assert!(g["edges"].as_array().unwrap().iter().any(|e| e[0] == "Feature:ConvertToSheetMetal1" && e[1] == "gauge"), "{g}");
assert!(!g["edges"].as_array().unwrap().iter().any(|e| e[0] == "Feature:Box1" && e[1] == "gauge"), "{g}");
// Every field of the shared list follows: `gap` was set through `sheet.manage_rules` too.
run(&mut s, "parameters.add", json!({"name": "seam", "expression": "0.5 mm"}));
run(&mut s, "sheet.manage_rules", json!({"name": "Steel", "gap": "seam"}));
run(&mut s, "parameters.rename", json!({"name": "seam", "new_name": "joint_gap"}));
assert_eq!(s.doc.sheet.rules[0].gap, "joint_gap");
for (old, new) in [("gauge", "sheet_t"), ("lift", "raise"), ("taper", "draft_angle")] {
run(&mut s, "parameters.rename", json!({"name": old, "new_name": new}));
}
let failing: Vec<String> = s.model.results.iter().filter_map(|r| r.error.clone()).collect();
assert!(failing.is_empty(), "{failing:?}");
assert_eq!(s.doc.sheet.rules[0].thickness, "sheet_t");
let housing = s.doc.plastic.rules.iter().find(|r| r.name == "Housing").unwrap();
assert_eq!((housing.draft.as_str(), housing.clearance.as_str()), ("draft_angle", "sheet_t / 10"));
assert!(run(&mut s, "plastic.rules", json!({}))["rules"].as_array().unwrap().iter().any(|r| r["name"] == "Housing" && !r["values"].is_null()));
match &s.doc.canvases[0].plane {
solidcraft_doc::PlaneRef::Offset { distance, .. } => assert_eq!(distance.as_ref(), "raise"),
other => panic!("{other:?}"),
}
assert_eq!(users(&mut s, "raise"), ["canvas"]);
// Undo takes the old names back.
for _ in 0..3 {
run(&mut s, "edit.undo", json!({}));
}
assert_eq!(s.doc.sheet.rules[0].thickness, "gauge");
}
#[test]
fn favorites_comments_and_completion() {
let mut s = Session::default();
+96
View File
@@ -707,3 +707,99 @@ fn patterns_and_mirrors_of_a_rib() {
check(&s, &[ribs[0], ribs[2]]);
assert!(rel(volume(&mut s), before + 2.0 * rib) < 1e-6, "two ribs: {} vs {}", volume(&mut s), before + 2.0 * rib);
}
/// #395: a pattern or mirror of features takes a pattern or mirror of features as a source: it
/// copies what that one made (its copies, not its source). The issue's design: a 40 × 30 × 20
/// box with a hole `H` at (−15, −10) and `Pat`, two more holes along x at 10 mm steps.
#[test]
fn patterns_and_mirrors_of_patterns_and_mirrors() {
use solidcraft_geom::Vec3;
let hole = PI * 1.5 * 1.5 * 20.0;
let design = || {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 20, "corner": [-20, -15, 0], "name": "Box"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Spot"}));
run(&mut s, "sketch.circle.center", json!({"center": [-15, -10], "radius": 1.5}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "Spot", "through_all": true, "operation": "cut", "name": "H"}));
run(&mut s, "solid.pattern.rectangular", json!({"features": ["H"], "dir1": [1, 0, 0], "count1": 3, "spacing1": 10, "name": "Pat"}));
s
};
let across_xz = json!({"origin": [0, 0, 0], "normal": [0, 1, 0]});
let across_yz = json!({"origin": [0, 0, 0], "normal": [1, 0, 0]});
// Each case: the steps after `design`, the holes there must be, and the spots that must
// stay material.
type Case = (Vec<(&'static str, Value)>, Vec<[f64; 2]>, Vec<[f64; 2]>);
let cases: Vec<(&str, Case)> = vec![
// The issue's step: the mirror of `Pat` is its two copies mirrored.
(
"mirror of a pattern",
(vec![("solid.mirror", json!({"features": ["Pat"], "plane": across_xz}))], vec![[-5.0, 10.0], [5.0, 10.0]], vec![[-15.0, 10.0]]),
),
// With its source too: the six holes the issue expects.
(
"mirror of a hole and its pattern",
(
vec![("solid.mirror", json!({"features": ["H", "Pat"], "plane": across_xz}))],
vec![[-15.0, 10.0], [-5.0, 10.0], [5.0, 10.0]],
vec![[15.0, 10.0]],
),
),
(
"pattern of a pattern",
(
vec![("solid.pattern.rectangular", json!({"features": ["Pat"], "dir1": [0, 1, 0], "count1": 2, "spacing1": 20}))],
vec![[-5.0, 10.0], [5.0, 10.0]],
vec![[-15.0, 10.0]],
),
),
(
"mirror of a mirror",
(
vec![
("solid.mirror", json!({"features": ["H"], "plane": across_yz, "name": "Mir"})),
("solid.mirror", json!({"features": ["Mir"], "plane": across_xz})),
],
vec![[15.0, -10.0], [15.0, 10.0]],
vec![[-15.0, 10.0]],
),
),
(
"pattern of a mirror",
(
vec![
("solid.mirror", json!({"features": ["H"], "plane": across_yz, "name": "Mir"})),
("solid.pattern.rectangular", json!({"features": ["Mir"], "dir1": [0, 1, 0], "count1": 2, "spacing1": 20})),
],
vec![[15.0, -10.0], [15.0, 10.0]],
vec![[-15.0, 10.0]],
),
),
];
for (what, (steps, holes, material)) in cases {
let mut s = design();
let before = volume(&mut s);
for (id, p) in steps {
run(&mut s, id, p);
}
for r in &s.model.results {
assert!(r.error.is_none(), "{what}: {}: {:?}", r.name, r.error);
}
let st = s.world_state();
assert_eq!(st.bodies.len(), 1, "{what}");
let body = &st.bodies[0].body;
for [x, y] in &holes {
assert!(!body.contains(Vec3::new(*x, *y, 10.0)), "{what}: a hole at ({x}, {y})");
assert!(body.contains(Vec3::new(*x, *y + 2.5, 10.0)), "{what}: material beside ({x}, {y})");
}
for [x, y] in &material {
assert!(body.contains(Vec3::new(*x, *y, 10.0)), "{what}: no hole at ({x}, {y})");
}
// The listed holes the steps added (in the last two cases `Mir` made the first of them).
let added = holes.len();
// Measured on the body's mesh, like the other tests here: to 1e-5 of the whole body.
let after = volume(&mut s);
let want = before - added as f64 * hole;
assert!(rel(after, want) < 1e-5, "{what}: {after} mm³, {added} more holes than {before} = {want}");
}
}
+1 -10
View File
@@ -405,16 +405,7 @@ fn rules(s: &mut Session, p: &Value) -> Result<Value> {
}
let mut rule =
s.doc.sheet.rules.iter().find(|r| r.name == name).cloned().unwrap_or_else(|| SheetRule { name: name.clone(), ..s.doc.sheet_rule(None) });
for (k, slot) in [
("thickness", &mut rule.thickness),
("k_factor", &mut rule.k_factor),
("bend_radius", &mut rule.bend_radius),
("relief_width", &mut rule.relief_width),
("relief_depth", &mut rule.relief_depth),
("corner_relief", &mut rule.corner_relief),
("hem_gap", &mut rule.hem_gap),
("gap", &mut rule.gap),
] {
for (k, slot) in rule.fields_mut() {
if let Some(e) = expr(p, k) {
*slot = e.into();
}
+30
View File
@@ -872,6 +872,36 @@ mod tests {
}
}
/// Typing `rad` into a new dimension's value lists `radius` although `rad` is itself the
/// radians unit; the popup is unarmed, so Down then Enter completes the name and the
/// dimension takes it (#3663).
#[test]
fn a_name_that_extends_a_unit_is_offered_and_completes() {
cancel();
let mut h = crate::scenario::Harness::new();
h.app.run("parameters.add", json!({"name":"radius","expression":"10 mm"})).unwrap();
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
h.app.run("sketch.line", json!({"points":[[10,10],[40,10]],"infer":false})).unwrap();
h.call("ui.view", json!({"view":"top"}));
h.step(&json!({"start":"sketch.dimension"})).unwrap();
h.step(&json!({"click":{"sketch":[25,10]}})).unwrap();
assert!(editing(), "{:?}", h.app.status);
h.step(&json!({"text":"rad"})).unwrap();
h.frames(2);
assert!(crate::params_dialog::suggestions(&h.ctx, "rad").iter().any(|s| s.text == "radius"));
assert_eq!(crate::expression_complete::popup_state(&h.ctx), Some((1, true)), "a unit with no number before it is a prefix: armed");
h.step(&json!({"key":"Enter"})).unwrap();
assert!(editing(), "Enter completed the name, the editor stays open: {:?}", h.app.status);
assert_eq!(EDIT.with(|e| e.borrow().as_ref().map(|e| e.expr.clone())), Some("radius".to_string()));
h.step(&json!({"key":"Enter"})).unwrap();
assert!(!editing(), "{:?}", h.app.status);
let sk = h.app.session.doc.sketch(h.app.session.active_sketch.unwrap()).unwrap();
let dim = sk.constraints.iter().find_map(|c| c.param.as_deref()).expect("the dimension was added");
assert_eq!(h.app.session.doc.param(dim).map(|p| p.expr.as_str()), Some("radius"));
h.step(&json!({"key":"Escape"})).unwrap();
cancel();
}
#[test]
fn placement_previews_radial_and_angular_dimensions_and_invalidates_on_document_change() {
cancel();
+126 -12
View File
@@ -3,7 +3,7 @@
use crate::ui_kit;
use egui::Key;
use crate::params_dialog::{suggestions_for, word_before};
use crate::params_dialog::{exact_token, suggestions_for, unit_token, word_before};
use crate::theme::Tokens;
#[derive(Clone)]
@@ -19,6 +19,13 @@ struct Popup {
explicit: bool,
/// Where each suggestion was drawn, for a press on one (see [`complete`]).
rows: Vec<egui::Rect>,
/// A row is selected, so Enter and Tab complete it. A popup opened on a word that is
/// itself a complete name (`width`) starts unarmed: it only shows the longer names, Enter
/// and Tab reach the editor as if it were not there, and an arrow key arms it (#3663). A
/// unit means something only after a number, where no popup opens, so a unit typed anywhere
/// else (`rad` on the way to `radius`) is a prefix like any other and the popup is armed.
/// A press on a row inserts it whether or not the list is armed.
armed: bool,
}
fn popup_id() -> egui::Id {
@@ -38,6 +45,7 @@ pub(crate) fn begin_frame(ctx: &egui::Context) {
});
popup.explicit = true;
popup.dismissed = false;
popup.armed = true;
ctx.data_mut(|d| d.insert_temp(popup_id(), popup.clone()));
}
let escape_blurred = ctx.input(|i| i.key_pressed(Key::Escape)) && ctx.memory(|m| m.focused().is_none() && m.had_focus_last_frame(popup.editor));
@@ -65,8 +73,12 @@ pub(crate) fn begin_frame(ctx: &egui::Context) {
&& *modifiers == egui::Modifiers::NONE
{
match key {
Key::ArrowDown if !popup.armed => (popup.armed, popup.selected) = (true, 0),
Key::ArrowUp if !popup.armed => (popup.armed, popup.selected) = (true, popup.count - 1),
Key::ArrowDown => popup.selected = (popup.selected + 1) % popup.count,
Key::ArrowUp => popup.selected = (popup.selected + popup.count - 1) % popup.count,
// Unarmed, the typed word is complete: Enter submits it and Tab moves on.
Key::Enter | Key::Tab if !popup.armed => continue,
Key::Enter | Key::Tab => popup.accept = true,
Key::Escape => {
popup.dismissed = true;
@@ -128,6 +140,9 @@ pub(crate) fn complete(ui: &mut egui::Ui, resp: &egui::Response, text: &mut Stri
// Dismissed suggestions stay closed until this box is typed in: another control rewriting
// its text (a slider dragged while the box keeps the focus) or a moved caret is no reason.
let dismissed = previous.as_ref().is_some_and(|p| p.dismissed) && !resp.changed();
// A word that is already a complete name opens unarmed: Enter submits it as typed. A unit
// without a number before it (`rad`, half-way to `radius`) is a prefix, so it stays armed.
let armed = !exact_token(&ctx, &word) || unit_token(&ctx, &word);
let mut popup = previous.filter(|p| p.word == word && p.cursor == cursor).unwrap_or(Popup {
editor: resp.id,
frame: 0,
@@ -139,12 +154,20 @@ pub(crate) fn complete(ui: &mut egui::Ui, resp: &egui::Response, text: &mut Stri
dismissed,
explicit: false,
rows: Vec::new(),
armed,
});
// Never over a control being dragged: the drag dismisses them.
if ctx.dragged_id().is_some_and(|id| id != resp.id) {
popup.dismissed = true;
}
let list = suggestions_for(&ctx, &popup.word, popup.explicit);
// A unit right after a number (`2 in`, `10 mm`, the form every dialog starts its sizes in)
// is a unit, not the start of a name: no popup there, so nothing sits over the OK button.
let number_before = text.chars().take(start).filter(|c| !c.is_whitespace()).last().is_some_and(|c| c.is_ascii_digit() || c == '.' || c == ')');
let list = if !popup.explicit && number_before && unit_token(&ctx, &popup.word) {
Vec::new()
} else {
suggestions_for(&ctx, &popup.word, popup.explicit)
};
popup.frame = ctx.cumulative_frame_nr();
popup.count = list.len();
popup.selected = popup.selected.min(list.len().saturating_sub(1));
@@ -156,7 +179,7 @@ pub(crate) fn complete(ui: &mut egui::Ui, resp: &egui::Response, text: &mut Stri
}
let mut pick = match pressed_row {
Some(row) => list.get(row).map(|s| s.text.clone()),
None if popup.accept => list.get(popup.selected).map(|s| s.text.clone()),
None if popup.accept && popup.armed => list.get(popup.selected).map(|s| s.text.clone()),
None => None,
};
popup.accept = false;
@@ -185,9 +208,14 @@ pub(crate) fn complete(ui: &mut egui::Ui, resp: &egui::Response, text: &mut Stri
&ctx,
list_id,
resp.rect,
popup.selected,
popup.armed.then_some(popup.selected),
&options,
crate::i18n::tr_label("↑ ↓ choose · Enter / Tab complete · Esc dismiss"),
// Unarmed, the typed word is complete and Enter submits it; an arrow key arms the list.
crate::i18n::tr_label(if popup.armed {
"↑ ↓ choose · Enter / Tab complete · Esc dismiss"
} else {
"↑ ↓ choose · Enter applies what you typed · Esc dismiss"
}),
) && pick.is_none()
&& !released_after_press
{
@@ -212,6 +240,12 @@ pub(crate) fn complete(ui: &mut egui::Ui, resp: &egui::Response, text: &mut Stri
true
}
/// The open popup for tests: (number of rows, armed).
#[cfg(test)]
pub(crate) fn popup_state(ctx: &egui::Context) -> Option<(usize, bool)> {
ctx.data(|d| d.get_temp::<Popup>(popup_id())).filter(|p| p.count > 0 && !p.dismissed).map(|p| (p.count, p.armed))
}
#[cfg(test)]
mod tests {
use super::*;
@@ -232,7 +266,7 @@ mod tests {
fn new(text: &str) -> Self {
let ctx = crate::fonts::test_context();
let mut app = SolveApp::new(solidcraft_engine::Session::default(), Default::default());
for (name, value) in [("width", 40), ("width_extra", 50), ("wing", 20), ("wire", 10)] {
for (name, value) in [("width", 40), ("width_extra", 50), ("wing", 20), ("wire", 10), ("radius", 10)] {
app.run("parameters.add", json!({"name":name,"expression":format!("{value} mm")})).unwrap();
}
params_dialog::publish(&mut app, &ctx);
@@ -288,6 +322,11 @@ mod tests {
fn selected(&self) -> usize {
self.ctx.data(|d| d.get_temp::<Popup>(popup_id())).unwrap().selected
}
/// (rows, armed) of the open popup.
fn popup(&self) -> Option<(usize, bool)> {
popup_state(&self.ctx)
}
}
#[test]
@@ -387,17 +426,92 @@ mod tests {
assert_eq!(e.text, "1234");
}
/// A word that is itself a complete name submits on Enter and lets Tab move on, whether or
/// not longer names share its prefix. Those names stay listed, unarmed, so `width` shows
/// `width_extra` instead of nothing (#3663).
#[test]
fn exact_tokens_submit_without_completing_longer_names() {
for text in ["width", "2 mm"] {
let mut e = Editor::new(text);
e.key(Key::Enter);
assert_eq!(e.text, text);
assert!(!e.accepted);
assert!(e.leaked.contains(&Key::Enter));
for (text, rows) in [("width", true), ("2 mm", false)] {
for key in [Key::Enter, Key::Tab] {
let mut e = Editor::new(text);
let popup = e.popup();
assert_eq!(popup.is_some(), rows, "{text:?}: {popup:?}");
if let Some((count, armed)) = popup {
assert!(count > 0 && !armed, "{text:?}: {popup:?}");
}
e.key(key);
assert_eq!(e.text, text);
assert!(!e.accepted);
assert!(e.leaked.contains(&key), "{text:?} {key:?}");
}
}
}
/// From the unarmed popup an arrow key picks a row (Down the first, Up the last); Enter or
/// Tab then complete it as usual.
#[test]
fn arrows_arm_the_popup_on_an_exact_token() {
let mut e = Editor::new("width");
assert!(suggestions(&e.ctx, "width").iter().any(|s| s.text == "width_extra"));
assert_eq!(e.popup().map(|(_, armed)| armed), Some(false));
e.key(Key::ArrowDown);
assert_eq!(e.popup().map(|(_, armed)| armed), Some(true));
assert_eq!(e.selected(), 0);
assert!(e.leaked.is_empty());
e.key(Key::Enter);
assert_eq!(e.text, "width_extra");
assert!(e.accepted && e.leaked.is_empty());
let mut e = Editor::new("2 * width");
assert_eq!(e.popup().map(|(_, armed)| armed), Some(false));
e.key(Key::ArrowUp);
let (count, armed) = e.popup().unwrap();
assert!(armed);
assert_eq!(e.selected(), count - 1);
e.key(Key::Tab);
assert!(e.accepted && e.text.starts_with("2 * "));
assert_ne!(e.text, "2 * width");
}
/// A unit right after a number is a unit: `10 mm` opens no popup even with a parameter
/// `mm_wall` defined (a dialog starting on `2 in` otherwise listed names over its OK button),
/// and Enter submits `10 mm`. After an operator the same word is a prefix again: `2 * mm`
/// lists `mm_wall`, armed, so Enter completes it.
#[test]
fn a_unit_after_a_number_opens_no_popup() {
let mut e = Editor::new("10 mm");
assert!(e.popup().is_none());
e.app.run("parameters.add", json!({"name":"mm_wall","expression":"3 mm"})).unwrap();
e.frame(Vec::new());
assert!(e.popup().is_none(), "{:?}", e.popup());
assert!(suggestions(&e.ctx, "mm").iter().any(|s| s.text == "mm_wall"));
e.key(Key::Enter);
assert_eq!(e.text, "10 mm");
assert!(!e.accepted && e.leaked.contains(&Key::Enter));
let mut e = Editor::new("2 * mm");
e.app.run("parameters.add", json!({"name":"mm_wall","expression":"3 mm"})).unwrap();
e.frame(Vec::new());
assert_eq!(e.popup(), Some((1, true)));
e.key(Key::Enter);
assert_eq!(e.text, "2 * mm_wall");
}
/// Typing a name letter by letter passes through words that happen to be units: `ra` lists
/// `radius` selected, and `rad` must keep it selected, so Enter completes the name instead
/// of submitting one radian into a length (found testing #3663).
#[test]
fn a_unit_typed_on_the_way_to_a_name_stays_armed() {
let mut e = Editor::new("ra");
assert_eq!(e.popup().map(|(_, armed)| armed), Some(true));
e.frame(vec![egui::Event::Text("d".into())]);
assert_eq!(e.text, "rad");
assert_eq!(e.popup(), Some((1, true)));
e.key(Key::Enter);
assert_eq!(e.text, "radius");
assert!(e.accepted && e.leaked.is_empty());
}
#[test]
fn typing_and_enter_in_one_batch_does_not_accept_stale_prefix() {
let mut e = Editor::new("wi");
+37 -6
View File
@@ -752,19 +752,33 @@ pub(crate) fn suggestions_for(ctx: &egui::Context, word: &str, explicit: bool) -
return Vec::new();
}
let all = ctx.data(|d| d.get_temp::<Cache>(cache_id())).map(|c| c.all).unwrap_or_default();
// A complete token is ready to submit, even when longer names share its prefix.
if !explicit && all.iter().any(|s| s.text == word) {
return Vec::new();
}
// A complete token stays out of its own list; longer names that start with it are still
// offered (passively: see `expression_complete`, #3663), but only those: `in` lists `inner`,
// not every name with `in` somewhere in it. A typed unit is complete in every sense, so only
// parameters, functions and constants extend it: `in` does not offer `inch`.
let exact = !explicit && all.iter().any(|s| s.text == word);
let w = word.to_ascii_lowercase();
let offered = |s: &&Suggestion| explicit || s.text.trim_end_matches('(') != word;
let offered = |s: &&Suggestion| (explicit || s.text.trim_end_matches('(') != word) && !(exact && s.kind == "unit");
let starts = |s: &&Suggestion| s.text.to_ascii_lowercase().starts_with(&w);
// A parameter is also offered for a word anywhere in its name (`side` finds
// `cab_side_thickness`, #2757), after the names that start with it.
let within = |s: &&Suggestion| s.kind == "parameter" && !starts(s) && s.text.to_ascii_lowercase().contains(&w);
let within = |s: &&Suggestion| !exact && s.kind == "parameter" && !starts(s) && s.text.to_ascii_lowercase().contains(&w);
all.iter().filter(|s| starts(s) && offered(s)).chain(all.iter().filter(|s| within(s) && offered(s))).take(8).cloned().collect()
}
/// Is `word` itself one of the known tokens (a parameter or dimension name, a constant or a
/// unit)? Such a word is ready to submit as typed; the popup then only offers longer names.
pub(crate) fn exact_token(ctx: &egui::Context, word: &str) -> bool {
let all = ctx.data(|d| d.get_temp::<Cache>(cache_id())).map(|c| c.all).unwrap_or_default();
all.iter().any(|s| s.text == word)
}
/// Is `word` a unit (`mm`, `in`, `deg`, ...)?
pub(crate) fn unit_token(ctx: &egui::Context, word: &str) -> bool {
let all = ctx.data(|d| d.get_temp::<Cache>(cache_id())).map(|c| c.all).unwrap_or_default();
all.iter().any(|s| s.kind == "unit" && s.text == word)
}
/// Offer keyboard and pointer completions under a focused expression box.
/// Returns true when a suggestion was accepted; callers must not submit that frame.
pub fn complete(ui: &mut egui::Ui, resp: &egui::Response, text: &mut String) -> bool {
@@ -977,9 +991,26 @@ mod tests {
let ctx = crate::fonts::test_context();
let mut app = SolveApp::new(solidcraft_engine::Session::default(), Default::default());
let _ = app.run("parameters.add", json!({"name": "width", "expression": "40 mm"}));
let _ = app.run("parameters.add", json!({"name": "radius", "expression": "10 mm"}));
let _ = app.run("parameters.add", json!({"name": "rounding", "expression": "3 mm"}));
publish(&mut app, &ctx);
let s = suggestions(&ctx, "wi");
assert_eq!(s.first().map(|x| x.text.as_str()), Some("width"));
// A name that extends a unit is still offered at the unit's own prefix (#3663); the
// prefix itself is a complete token, so the caller knows Enter should submit it.
assert!(suggestions(&ctx, "rad").iter().any(|x| x.text == "radius"));
assert!(!suggestions(&ctx, "rad").iter().any(|x| x.text == "rad"));
// A typed unit is not extended by other units: `in` offers no `inch` (a popup there sat
// over dialog buttons); a prefix that is not itself a unit still does. A complete token
// is extended only by names that start with it: `in` does not list `rounding` (a fillet
// dialog opening on `2 in` showed it over the OK button), while `und` still does (#2757).
assert!(suggestions(&ctx, "in").is_empty() && suggestions(&ctx, "mm").is_empty());
assert!(suggestions(&ctx, "und").iter().any(|x| x.text == "rounding"));
assert!(unit_token(&ctx, "in") && unit_token(&ctx, "rad") && !unit_token(&ctx, "radius") && !unit_token(&ctx, "i"));
let m: Vec<String> = suggestions(&ctx, "m").into_iter().map(|x| x.kind).collect();
assert!(!m.is_empty() && m.iter().all(|k| k == "function"), "{m:?}");
assert!(suggestions(&ctx, "i").iter().any(|x| x.text == "inch"));
assert!(exact_token(&ctx, "rad") && exact_token(&ctx, "width") && !exact_token(&ctx, "radi") && !exact_token(&ctx, "wi"));
assert!(suggestions(&ctx, "ata").iter().any(|x| x.text == "atan2("));
assert!(suggestions(&ctx, "1").is_empty());
// The dialog's rows come from the engine.
+7 -6
View File
@@ -376,15 +376,16 @@ pub(crate) struct PopupOption<'a> {
pub color: egui::Color32,
}
/// Focus-neutral suggestion popup. The owner handles navigation and completion semantics. Its
/// rows take no focus, and where they were drawn is kept under `id.with("rows")`, so an owner
/// can act on a press before the click lands (#3257).
/// Focus-neutral suggestion popup. The owner handles navigation and completion semantics;
/// `selected` is the highlighted row, or `None` when no row is picked yet (#3663). Its rows
/// take no focus, and where they were drawn is kept under `id.with("rows")`, so an owner can
/// act on a press before the click lands (#3257).
/// Tokens: space_2, form_label_width, control_height, text_dim, scope control/font tokens.
pub(crate) fn popup_list(
ctx: &egui::Context,
id: egui::Id,
anchor: egui::Rect,
selected: usize,
selected: Option<usize>,
options: &[PopupOption<'_>],
hint: &str,
) -> Option<usize> {
@@ -404,7 +405,7 @@ pub(crate) fn popup_list(
for (index, option) in options.iter().enumerate() {
let (rect, response) = ui.allocate_exact_size(vec2(ui.available_width(), t.control_height), egui::Sense::CLICK);
rows.push(rect);
let picked = index == selected;
let picked = selected == Some(index);
if picked || response.hovered() {
ui.painter().rect_filled(rect, t.control_radius, if picked { t.selected_bg } else { t.hover });
}
@@ -438,7 +439,7 @@ pub(crate) fn popup_list(
}
}
super::divider(ui, "completion_detail");
if let Some(option) = options.get(selected) {
if let Some(option) = selected.and_then(|index| options.get(index)) {
super::text(ui, option.detail, super::Text::Note("completion_detail"));
}
super::text(ui, hint, super::Text::Note("completion_hint"));
+2 -2
View File
@@ -1043,7 +1043,7 @@ const ELEMENTS: &[Element] = &[
ui.ctx(),
ui.id().with("popup"),
anchor,
0,
Some(0),
&[PopupOption { kind: "parameter", text: "Width", detail: "10 mm", color: Tokens::get().text }],
"Choose",
);
@@ -1325,7 +1325,7 @@ fn popup_suggestions_and_selectable_lists_keep_click_selection() {
ui.ctx(),
egui::Id::new("suggestions"),
egui::Rect::from_min_size(egui::pos2(10.0, 10.0), vec2(220.0, 24.0)),
0,
Some(0),
&[
PopupOption { kind: "parameter", text: "width", detail: "Parameter · 10 mm", color: Tokens::get().text },
PopupOption { kind: "parameter", text: "height", detail: "Parameter · 20 mm", color: Tokens::get().text },
+5 -1
View File
@@ -25,7 +25,11 @@ editable. In expression fields, type a parameter or dimension name for autocompl
or press **Ctrl+Space** to open suggestions, including in an empty field.
**Up/Down** selects an autocomplete suggestion,
**Enter/Tab** accepts it, and **Escape** dismisses the suggestions. Accepting a suggestion
does not also submit the parameter.
does not also submit the parameter. When what you typed is already a complete name
(`width`), longer names such as `width_extra` stay listed without a selection: **Enter**
applies what you typed, and **Up/Down** picks one of them first. A unit right after a number
(`10 mm`) is left alone; anywhere else a unit is a prefix like any other, so `rad` followed by
**Enter** gives `radius`.
Expressions are unit-aware: `2 * width + 5 mm`, `angle / 2`, `sqrt(area)`, `1 in + 3 mm`, using
`+ - * / ^`, parentheses, `sin cos tan asin acos atan atan2 sqrt abs min max floor ceil round mod