mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-03 02:25:55 +00:00
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:
@@ -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()),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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!({}),
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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 閉じる"
|
||||
}
|
||||
|
||||
@@ -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
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user