74 Commits
Author SHA1 Message Date
aiongg 5654240729 refactor(cli): parse cadcraft-cli arguments with clap, following RFC-0012 (#2268)
* test(cli): pin today's command-line behaviour

The later commits change these tests on purpose, so each behaviour change shows in their diff.

* refactor(cli): split main.rs into modules, move only

- output: outln!, stdout state, atomic write, export helpers, view/window helpers
- args: USAGE, option validation, framing options, MCP argument parsing and its tests
- info, convert, sample, mcp: one subcommand each
- perf: synthetic drawing, Rng, perf subcommands
- run: run subcommand and {last} handle chaining
- main: crate docs, mod lines, install_io, open, main, cli, unit tests

No behaviour change.

* refactor(cli): typed errors and run(args, out, err)

cadcraft-cli is now a library plus a thin binary. `cadcraft_cli::run(args, out, err)
-> i32` is the whole program; main.rs only passes `args_os()` and the process
streams. Handlers write through a `Streams` wrapper (output.rs) instead of the
process streams; it keeps the closed-stdout semantics (drop later output, carry
on, a closed pipe is not an error, other write errors are reported once and make
the exit code 1). `mcp` still speaks MCP on the real stdin/stdout.

Errors are typed (thiserror): CliError (error.rs) wraps EngineError, DocError,
IoError, io::Error and the per-module WindowError, ArgsError, RunError,
OutputError and PerfError. `run` prints `error: {e}` and exits 1.

The usage text and the "unknown option/argument" messages stay as one
transitional CliError::Usage(String), printed without the `error: ` prefix.
USAGE stays a const; step 4 (clap) removes both. The engine's IoHooks take
`Result<_, String>`, so the three `.map_err(|e| e.to_string())` in install_io
remain. The hooks are set-once (OnceLock), so installing them on every `run`
call is harmless.

tests/it/usage.rs calls `run` in-process with Vec<u8> writers; the headless mcp
case runs through the binary (it reads the real stdin) and a non-Unicode
argument test is new.

Behaviour changes (old -> new):
- Every error line other than the usage-class ones gains the prefix `error: `
  (e.g. `--view needs ...` -> `error: --view needs ...`, `unknown command
  `x`` -> `error: unknown command `x``, `invalid parameters for `open`: ...`
  -> `error: invalid parameters ...`, `unsupported file type` likewise).
- `cadcraft-cli: cannot connect to HOST:PORT: e` -> `error: cannot connect to
  HOST:PORT: e` (the old prefix is replaced).
- A non-Unicode argument: panic, exit 101 -> `error: argument "..." is not
  valid Unicode`, exit 1.
- Unchanged: usage text, `X: unknown option --y`, `unknown option --y`,
  `mcp: unknown argument ...` (no prefix); warnings and `wrote ...` lines;
  the `cadcraft-cli: can't write to stdout: e` report; exit codes.

* refactor(cli): parse arguments with clap

Replace the hand-written parser (USAGE, validate_options, OptionSpec,
framing_option, parse_mcp_args, CliError::Usage) with clap derive types
in args.rs: a Cli parser and one Args struct per subcommand, with the
framing flags (--window, --view) in a FramingArgs struct flattened into
convert and run. USAGE's framing and --cmd chaining text moves into the
subcommands' and flags' doc comments and run's long help, so --help
carries it. `commands` moves from an inline closure to commands.rs.
`command()` exposes the clap definition for man pages and completions.

clap reads the arguments as OsStrings. Help and --version go to stdout
with exit 0; usage errors are clap's, on stderr with exit 2. A bare
--help or -h before `--` shows the subcommand's help even after a
mistake on the line (`run --script --help`). run opens its drawings
(FILEs, --sample, --metric) and runs its steps (--script, --script-file,
--cmd) in command-line order, from ArgMatches::indices_of. Values stay
strings checked by the handlers, with their messages and exit 1
(--window, --view, --cmd JSON, sample names, perf counts). The last of a
repeated flag wins (args_override_self on every subcommand but mcp).

Behaviour changes:

Help and version
- --help, -h, help: the USAGE page -> clap's help, first line
  "cadcraft-cli: headless CADCraft".
- bare cadcraft-cli: USAGE on stderr, exit 1 -> clap's help on stderr,
  exit 2.
- <subcommand> --help / -h, help <subcommand>: "unknown option --help"
  (exit 1), the USAGE page (convert, help <subcommand>) or a file named
  -h (info -h, run -h) -> that subcommand's help, stdout, exit 0.
- <subcommand> --version: "unknown option --version", exit 1 -> clap's
  unexpected argument, exit 2.

Usage errors (exit 1 -> exit 2, clap's messages)
- Unknown subcommand, empty subcommand, unknown top-level flag: USAGE ->
  "error: unrecognized subcommand 'foo'" / "error: unexpected argument
  '--bogus' found".
- Unknown flag on any subcommand: "info: unknown option --bogus",
  "unknown option --bogus", "mcp: unknown argument --bogus" or USAGE
  (convert) -> "error: unexpected argument '--bogus' found".
- A short flag where a file is expected (info -x): opened as a file ->
  unexpected argument; `--` before it keeps it a file name.
- A value flag at the end or followed by a flag (--window, --view,
  --script, --script-file, --cmd, --save, --export, --connect): "error:
  --script needs text" and the like, or the next flag taken as the
  value (--window --view, --script --sample, --cmd --sample,
  --script-file --sample) -> "error: a value is required for '--script
  <TEXT>' but none was supplied". --connect followed by an unknown flag
  (--connect --foo, --connect -v) names that flag instead (clap reports
  the unknown argument, not the missing value).
- mcp --connect with an empty value: "error: --connect needs HOST:PORT
  ..." -> "error: invalid value ' ' for '--connect <HOST:PORT>':
  expected HOST:PORT (...)"; --connect=-v likewise.
- mcp --connect twice: "error: --connect given more than once" ->
  "error: the argument '--connect <HOST:PORT>' cannot be used multiple
  times".
- mcp --lazy / --launch without --connect: "error: --lazy and --launch
  need --connect HOST:PORT" -> "error: the following required arguments
  were not provided: --connect <HOST:PORT>".
- Missing positionals (info, convert with 0 or 1 files, sample with 0
  or 1 arguments): USAGE -> "error: the following required arguments
  were not provided".
- Extra positionals: convert with three files (USAGE), and info,
  commands, perf, sample, which ignored the extra argument -> "error:
  unexpected argument 'extra' found".
- perf -5: opened as a file -> unexpected argument '-5'.
- A non-Unicode argument where text is expected: "error: argument ... is
  not valid Unicode", exit 1 -> "error: invalid UTF-8 was detected in
  one or more arguments", exit 2.

Accepted where they were errors
- --x=v is a spelling of --x v for every value flag (--window, --view,
  --script, --script-file, --cmd, --save, --export, --connect); before
  it was an unknown option or USAGE. A value starting with - needs the
  = form (--cmd=-x).
- commands --: "unknown option --" -> `--` ends the options (all
  commands listed).
- run --view extents --view current: "use only one of --window and
  --view" -> the last wins, like every other repeat.

Other
- run --sample --sample opens one sample drawing (was two): repeated
  flags are last-wins.
- run checks --window/--view values before opening any drawing, so a
  bad framing value is reported ahead of a missing file.
- `-h` after a mistake shows the long help (`--help`): the retry asks
  for --help.

Unchanged: --window with --view, framing without an image output,
--view's case-insensitivity, all value messages (--window numbers,
--view values, --cmd JSON, {last}, sample names, --script-file read
errors), multiple FILEs, --sample with --metric or a FILE, last-wins
for --save, --export, --window, --script/--cmd order, and `\n` in
--script.

clap forced: exit 2 for usage errors, its message wording, and the
unknown-flag-over-missing-value precedence above.

* refactor(cli): reject repeated flags, long version (photocraft#7071 decisions)

Behaviour changes, old -> new:
- --save, --export, --window, --view, --sample, --metric, --lazy, --launch given twice:
  last one won -> usage error, exit 2 ("the argument ... cannot be used multiple times").
  --connect was already rejected. --script, --script-file, --cmd and run's FILEs still repeat.
- --version / -V: "cadcraft-cli <version>" -> "cadcraft-cli <version> (<sha>)", sha from
  CADCRAFT_BUILD_SHA or "dev", as the desktop app prints it.
- "cadcraft-cli version" is an unrecognized-subcommand usage error (as before; now tested).
- A value starting with "-" needs the = form for every value flag (as before, tested).
  --window -10,-10,10,10 stays a usage error ("unexpected argument -1"); write
  --window=-10,-10,10,10. clap's allow_negative_numbers does not accept a comma list, and
  allow_hyphen_values would let --window swallow the next flag, so neither is set. The
  --window help text names the = form.

* refactor(cli): typed values and flag relations

Step 6 of #2228: values are parsed into their types and flag relations
are checked at parse time, so a bad value or combination is a usage
error (exit 2) naming the flag, before any work starts. The handlers
receive typed values only. Runtime failures (files, the engine, a
`{last}` with no earlier handle) stay exit 1.

- --window parses to a Bounds2 (parse_window plus cadcraft_io's
  check_window); its WindowError is the value parser's error.
- --view is a ValueEnum (extents, current), case-insensitive; the long
  help lists what each value does.
- --window conflicts_with --view. In run, --window/--view require
  --export (an ArgGroup "framing" with requires). A framing flag with
  an output that isn't an image (convert's OUT, run's --export) is a
  UsageError turned into a clap error by into_clap (ArgumentConflict).
- sample's name is a ValueEnum (bracket, floorplan); `floor` stays a
  hidden alias.
- perf's N|FILE is one typed positional: a value that parses as a
  count is one, clamped to 1..=20000000 as before (so `perf 0` still
  times one entity); anything else is a drawing.
- --cmd 'id {json}' parses to an id and a serde_json::Value (default
  {}); `{last}` is still filled in when the command runs.
- Paths are PathBufs (info FILE, convert IN/OUT, run FILEs,
  --script-file, --save, --export, sample OUT, perf FILE). The engine
  and io crates take file names as text, so a path that isn't valid
  Unicode is refused by the handler, naming the file (exit 1).
- Deleted: check_framed_output, the --view match, ArgsError,
  CliError::Window/Args/UnknownSample, RunError::CmdJson,
  PerfError::BadCount.
- run's FILEs, --sample and --metric still combine (each opens another
  drawing, the last is current): neither photocraft#7071 nor RFC-0012 requires
  otherwise.

Behaviour changes (old -> new):
- --window with a bad value (wrong count, not a number, NaN/inf,
  reversed corners, beyond 1e12): exit 1 `error: --window ...` ->
  exit 2 `error: invalid value 'V' for '--window <x0,y0,x1,y1>':
  <reason>`. The reasons no longer repeat the flag or the value.
- --view with a bad value: exit 1 `error: --view needs `extents` or
  `current`` -> exit 2 `error: invalid value 'V' for '--view
  <extents|current>'` plus clap's possible-values line.
- --window with --view: exit 1 `error: use only one of --window and
  --view` -> exit 2 `error: the argument '--window <x0,y0,x1,y1>'
  cannot be used with '--view <extents|current>'`.
- run --window/--view without --export: exit 1 `error: --window/--view
  need an image output ...` -> exit 2 `error: the following required
  arguments were not provided: --export <OUT.png>`.
- A framing flag with a non-image output: exit 1 `error:
  --window/--view only apply to image output (png, svg, pdf), not
  `OUT`` -> exit 2 `error: --window only applies to image output (png,
  svg, pdf), not `OUT`` (or --view: the message names the flag given).
- sample with an unknown name: exit 1 `error: unknown sample `X`
  (bracket, floorplan)` -> exit 2 `error: invalid value 'X' for
  '<bracket|floorplan>'` plus the possible values. sample's help lists
  the possible values.
- --cmd with JSON that doesn't parse: exit 1 `error: --cmd ID:
  <serde error>`, after the earlier steps ran -> exit 2 `error: invalid
  value 'V' for '--cmd <id {json}>': <serde error>`, before anything
  runs.
- --cmd with an empty id (`--cmd=` or `--cmd ' {}'`): exit 1 `error:
  unknown command `` ` -> exit 2 `... expected a command id, then
  optionally its JSON parameters`.
- An empty path argument (info "", sample bracket "", perf "", ...):
  exit 1 from the engine or io -> exit 2 `error: a value is required
  for '<FILE>' but none was supplied` (clap's PathBuf parser).
- A path that isn't valid Unicode: exit 2 `error: invalid UTF-8 was
  detected ...` -> exit 1 `error: PATH: the file name is not valid
  Unicode` (text values that aren't Unicode stay exit 2). A non-Unicode
  --save or --export is refused before any drawing opens.
- perf's help gives the count range.

Tests: usage.rs rows above moved to exit 2 with clap's messages; new
rows for --view with a non-image output, run's framing with a dxf
--export, an empty --cmd id, bad --cmd JSON after a good step, a
`{last}` with no earlier handle, --window beyond 1e12, case-sensitive
sample names and empty paths. The non-Unicode test covers both a path
(exit 1) and text (exit 2). Unit tests: the --window parser tests moved
to args.rs; new ones for --cmd values and perf targets; framing_options
checks the clap error kinds.

* refactor(cadcraft): move launch-argument parsing into launch_args

Moves the hand-written parser (the argument loop, control_port_from, control_flag and their tests) from main.rs into launch_args.rs, which returns LaunchArgs. No behaviour change.

* refactor(cadcraft): parse launch flags with clap (RFC-0012)

Replaces the hand-written loop in launch_args.rs with clap derive types, as photocraft's launch_args.rs does. --help is generated from the doc comments and lists the other CADCRAFT_* variables.

Behaviour changes (old -> new):
- An unknown flag (--sampel) was opened as a file; it is now a usage error on stderr, exit 2, before the window opens. A file starting with "-" goes after "--".
- A repeated --control or --sample was accepted (last wins); it is now exit 2.
- A bad port (--control abc, 70000, -1) was logged and the control channel left off; it is now exit 2. The same for CADCRAFT_CONTROL_PORT, and the error names the variable when the value came from it.
- A bare --control (no value) kept CADCRAFT_CONTROL_PORT's port with a warning; it is now exit 2 ("a value is required"). A following flag is never taken as the value.
- An empty or blank CADCRAFT_CONTROL_PORT was logged as a bad port; it is now unset.
- --control=7979 is accepted (the equals form was opened as a file before).
- --version / -V and --help stop parsing at once and exit 0; version text is unchanged ("cadcraft <version> (<sha>)").
- -psn_* (Finder process serial number) is dropped instead of opened as a file.
- A file name that is not valid Unicode used to panic in std::env::args(); it is now read from args_os and reported with a log warning, not opened, because CadApp::open_path takes text.

Copied from photocraft's crates/automation/src/args.rs: blank_env_is_unset and name_env_vars unchanged (name_env_vars gets a local use for ContextKind/ContextValue); control_port and NotAPort unchanged. allow_negative_numbers is set on --control as in photocraft, so -1 is a bad value rather than a missing one.

* feat(cli): shell completions and man pages

Adds examples/man-and-completions.rs, which writes cadcraft-cli's bash, zsh, fish, elvish and
PowerShell completions (clap_complete) and man pages (clap_mangen: cadcraft-cli.1 and one per
subcommand) into a directory. clap_complete and clap_mangen are dev-dependencies, so the binary
is unchanged.

packaging/linux/package.sh builds the example in its own cargo run, runs it (through $EMULATOR
for cross builds; warns and leaves the files out without one) and installs the man pages and the
bash, zsh and fish completions into the staged tree. nfpm.yaml already ships the whole
usr/share tree, so it needs no change.

CADCraft has no FreeBSD packaging, so there is no FreeBSD script to update. macOS and Windows
packaging are untouched.

tests/man_and_completions.rs checks the generated file names.

* ci(cli): check the command-line code with ast-grep (RFC-0012)

Adds ast-grep rules from RFC-0012, run by craft-common-actions'
reusable ast-grep workflow (Linux, no Rust build, path-filtered):

- clap-error-message: a string literal, format!, .to_string() or
  SCREAMING_CASE const passed to Command::error / Error::raw
- clap-error-message-variable: the same message built into a variable
  first, in the same function
- string-error: Result<_, String>, Err(format!(..)), Err(".."),
  Err(x.to_string())
- args-os: std::env::args(), which panics on non-Unicode arguments

Scan paths: apps/cadcraft-cli/src, apps/cadcraft/src/launch_args.rs,
apps/cadcraft/src/main.rs. Rule tests run first in CI.

Source: photocraft#9750 at d16686cd. Only the scan paths and the
workflow path filters differ from it.

Fixes: none needed. The rule tests pass (4 passed) and the scan finds
nothing in the scanned paths.

* docs(cli): cadcraft-cli page, following RFC-0012

docs/cli.md: --help is the reference; exit codes, streams, usage
errors, values starting with -, paths, completions and man pages; the
environment variables (none for cadcraft-cli, CADCRAFT_CONTROL_PORT for
the desktop app's --control); and the rules not followed yet, with the
reason. The README links it.

* feat(cli): refuse an output that replaces an input

RFC-0012 rule 1.6: nothing destructive by default.

Behaviour change: `convert a.dxf a.dxf`, `run a.dxf … --save a.dxf` and
`run a.dxf … --export a.dxf` used to overwrite the input. They are now a
usage error (exit 2, `error: …` on stderr, nothing opened or written)
unless `--in-place` is given. "Same file" is file identity (same-file
crate), so `dir/./a.dxf` and a symlink to the input count. An output
that does not exist, or a failed identity check, is not refused; the
open or write reports the real error. Replacing an existing file that
is not an input is still allowed.

Scope: `--save` and `--export` only. A save that a --cmd or script runs
(for example `saveas`) is the engine's and is not checked.

Every write the CLI makes (convert OUT, --save, --export, sample OUT) resolves
an existing output first, so writing through a symlink updates the file it
points to and the link stays a link; the atomic rename used to replace the
link with a regular file, leaving the target (with --in-place, the input)
unchanged.

* feat(cli): --compact for JSON output

RFC-0012 rule 1.3: info and commands pretty-print their JSON by
default; --compact prints it on one line. run prints one JSON line per
--cmd result (JSON lines, one result per step), as its help and the
README document, so it takes no --compact. mcp's stdout is the MCP
protocol and is unchanged.

Behaviour change: info and commands accept --compact (it was an
unknown flag, exit 2). Their default output is unchanged.

docs/cli.md says which subcommand prints which form, and lists perf's
text table (no --json) and saves a --cmd or script runs (not checked
by --in-place) under "Not followed yet".

* fix(cadcraft): one Switch parser for boolean environment switches

CADCRAFT_VSYNC, CADCRAFT_SYSTEM_CURSOR, CADCRAFT_WAYLAND and
CADCRAFT_NO_NATIVE_MENU each had their own idea of "on". They now share
cadcraft_ui_egui::switch::Switch: 1/on/true/yes is On, 0/off/false/no is
Off (case- and space-insensitive), unset or blank is Default, and any
other value is Default and logged with the variable name. The ENVIRONMENT
help text says the same for all four.

Behaviour changes (old -> new):
- CADCRAFT_WAYLAND=0 counted as on (any value did); now only On keeps
  native Wayland.
- CADCRAFT_NO_NATIVE_MENU=0 counted as on (any value did); now only On
  keeps the menus inside the window.
- CADCRAFT_SYSTEM_CURSOR treated every value except ""/0/false/off as on;
  now only the On spellings do, and an unknown value is logged.
- CADCRAFT_VSYNC is unchanged except that an unknown value is logged.

* fix(cli): keep paths as paths until a file is opened

A path is now text only where text is needed.

- cadcraft: FileNames.unreadable is Vec<PathBuf>, logged with {:?}
  (escaped bytes) instead of a lossy rendering.
- cadcraft-cli: unicode() is called only inside open(), the one place
  that sends a path to the engine as JSON. convert, sample and run
  --save/--export pass the output as &Path. write_atomic, export,
  export_framed and OutputError::Write take or hold a Path/PathBuf;
  errors use .display(), and the `wrote ...` line uses .display().
- Dropped the dead find_subcommand lookup in launch_args (LaunchArgs has
  no subcommands).
- docs/cli.md: non-Unicode names are refused only where a drawing is
  opened.

Behaviour change: an output name that is not valid Unicode used to be
refused (exit 1); it is now written under that exact name. Opening such
a drawing is still refused.

Lossy strings that remain (all feed APIs that take text and only look at
the extension, never at the path the bytes are written to):
- cadcraft_io::write / write_framed (name picks the format) in sample.rs
  and output.rs::export_framed;
- cadcraft_io::is_image in convert.rs, run.rs and args.rs (framing flag
  check);
- cadcraft_engine::cmd::file::drops_unknown in export_framed.
These go away when cadcraft_io and the engine take impl AsRef<Path>
(the A1 follow-up). A non-Unicode extension is not a known format either
way, so it is rejected as an unsupported type, as before.

* refactor(cadcraft): read the environment once into Env

The desktop app reads its CADCRAFT_* variables once at startup into
apps/cadcraft/src/env.rs `Env` and passes the values on; no library crate
reads a CADCRAFT_* variable at run time any more.

- One `VARS` table (name, help line) feeds both the reader and the
  Environment section of `cadcraft --help`. A test checks that every
  variable the reader asks for is in the table, that every table entry
  sets a field, and that the help lists every entry.
- vsync goes to `gpu::surface_config`, the system cursor to
  `CadApp::set_system_cursor_default` (Options reset and Reset Palettes go
  back to it), FONTALT/FONTFALLBACK to the `cadcraft_fonts::ttf` setters
  (re-exported as `cadcraft_engine::fonts`), the settings directory to the
  new `cadcraft_engine::settings_dir::set`, and to `Workspace::load`.
- The three copies of the settings-directory rule (main.rs, workspace.rs,
  the engine's plotter folder) are one: `settings_dir::platform`.
- `Switch::from_env` is gone; `Switch::parse` stays.
- The child-process tests for CADCRAFT_CONTROL_PORT are replaced by tests
  that inject the variable lookup (`blank_env_is_unset`) or point
  `--control`'s fallback at PATH.

cadcraft-cli: plotter configurations matter to its output (`run --cmd plot`
or a script's PLOT with a user device, and the same through `mcp`), and the
font variables change how convert/run/sample draw text. So, following
RFC-0012 rule 1.7, the variables become fallbacks of flags declared with
`env =`: --config-dir (CADCRAFT_CONFIG_DIR), --fontalt (CADCRAFT_FONTALT)
and --fontfallback (CADCRAFT_FONTFALLBACK) on convert, run, mcp and sample.

Behaviour changes:
- CADCRAFT_CONFIG_DIR set but empty counts as unset everywhere: the
  workspace file and the plotter folder use the platform folder (was:
  workspace persistence and the plotter folder off). Logs and autosave
  already treated it so.
- The workspace file and the plotter folder ignore a relative
  XDG_CONFIG_HOME, as logs and autosave did (was: used it).
- A blank CADCRAFT_LOCALE means the system's languages (was: [""]).
- cadcraft-cli: the three variables apply only to convert, run, mcp and
  sample, as flag fallbacks (was: read by the engine and fonts in every
  subcommand, perf included). A font variable that isn't valid Unicode is
  a usage error naming the flag (was: ignored).

* refactor(io): one Format enum for file extensions

`cadcraft_doc::Format` (re-exported as `cadcraft_io::Format`) is the one
place that maps a file name's extension to a format: Dxf, Dwg, Dwt, Svg,
Svgz, Png, Pdf. `from_path` is ASCII case-insensitive; no extension (or a
trailing dot) is Dxf; an unknown extension is None. `READ` and `WRITE`
list the formats `read` and `write` accept. It lives in cadcraft-doc
because the engine depends on cadcraft-io only for tests.

Each place used to match on its own lower-cased extension string and now
matches on Format, with the same results:
- io::read: svg/svgz -> SVG reader, dxf/none -> DXF reader, other ->
  try DXF, else Unsupported(ext).
- io::write_with: dxf/none -> DXF, dwg -> DWG bridge, other with binary
  -> error, rest -> write_framed.
- io::write_framed: dwt -> DWG; dxf/none/dwg -> write_with; svg, png,
  pdf -> image writers; other -> Unsupported(ext).
- io::is_image: svg, png, pdf (not svgz, which is read only).
- engine file.rs is_dxf, drops_unknown (dwg, dwt), is_drawing_file
  (dxf, dwg, dwt, none).

Behaviour change (help text only): the CLI help lists the formats from
Format::READ / Format::WRITE instead of hand-written lists. `info` and
`convert IN` now name svg and svgz as readable, and `convert`, `sample`,
`run --save` and `run --export` name dwt (and `sample` pdf) as writable.
A test checks each file argument's help names every format of its list.

* refactor: one version string for the app and the CLI

cadcraft_engine::build_version() builds "<version> (<commit>)" once; the
desktop app's --version, its start-up log line and cadcraft-cli --version
all use it. No change in output.

* refactor(cli): share the environment helpers with the CLI

Move blank_env_is_unset and name_env_vars from the desktop app into
cadcraft_mcp::args and apply them in the CLI parser.

Behaviour changes (old -> new):
- Empty or blank CADCRAFT_FONTALT / CADCRAFT_FONTFALLBACK: reached clap as
  Some("") -> counts as unset.
- A bad value from an environment variable: the error named the flag ->
  names the variable.
- CADCRAFT_CONFIG_DIR is unchanged: empty still means no settings folder.
- Desktop app: unchanged; it uses the shared helpers.

* refactor: pass paths, not text, through io, engine and open_path

cadcraft_io::{read, write, write_with, write_framed, is_image}, the engine's
drops_unknown/is_drawing_file/read_text_file and CadApp::open_path take
paths (impl AsRef<Path> / &Path). The desktop launch arguments and the CLI
no longer convert paths to text to call them.

Behaviour changes (old -> new):
- A dropped file whose name is not valid Unicode opened with a "can't
  read" error naming a mangled path; it now opens natively. (Its path is
  not remembered for QSAVE, which then asks where to save.)
- A launch argument that is not valid Unicode was logged and skipped; it
  now opens like any other.
- A .dwt export builds the .dwg name as a path, not formatted text.
- On the web, a non-Unicode path passed to open_path reports "the path
  is not valid Unicode" instead of a lossy name.
- Opening errors print path.display().

FileNames and LaunchArgs::file_names are removed; the launch tests assert
the parsed PathBufs. New io test: non-Unicode names pick the format by
extension.

* docs(cli): CADCRAFT_CONFIG_DIR is the one flag fallback where empty isn't unset

* fix(engine): refuse a drawing path that isn't valid Unicode

read_file opened such a path with no saved path, so QSAVE could not save back to it.
It now returns an open error naming the file before reading it. The ui-egui opener
already reports read_file errors like any other failed open.

* fix(cli): report a failed write of help or version

A write error on help or version output (other than a closed pipe) now exits 1 and
tries to say so on stderr; it was ignored and exited with the clap code. A closed pipe
keeps the clap exit code.

* fix(cli): --connect needs HOST:PORT

--connect example.com was accepted and failed later. It is now a usage error (exit 2)
unless the value is a non-empty host (or a bracketed IPv6 address) and a u16 port.
The host is not resolved.

* docs(cli): run prints no line for a command with no result

* refactor(cli): explicit imports and smaller functions for the stricter lints

Lints: clippy::wildcard_imports, too_many_arguments, cognitive_complexity (30).

Replaced the non-test `use super::*;` in args, commands, convert, info,
output, perf and run with explicit imports; lib.rs keeps only the imports
its non-test code uses, and its test module imports the rest itself. The
`super::*` in the test modules stays.

A clippy run with all three lints and threshold 30 reports nothing in
apps/cadcraft-cli or in code this branch added, so no function needed
splitting.
2026-10-27 21:28:53 -04:00
aiongg d3ae8a6af0 Side panels dock in left and right dock columns (#2158)
* Pin craft-libs at craft-libs#312's head (4dc543c) for the dock-column move

* Side panels dock in left and right dock columns

Tool Sets sits in a left dock column and Layers above Properties in a right
one, at the widths of the old side frames. Palettes dock only into those
columns: a window edge without a dock makes one, and neither the drawing nor
the top and bottom edges take a drop. A palette opened with no recorded place
goes into its dock group (External References below Properties). Iconic
columns show CADCraft's own code-drawn icons.

* Workspace file: dock layout version; split-frame layouts reset to the default

The workspace file saves dockingVersion next to the panel layout. A layout
saved without it, or with an older one, is dropped with its hidden panels on
load; the other workspace settings are kept.

* Floating panels open in their own OS windows on the desktop

- craft-ui's native-windows feature is on for the desktop app (not the web build)
  and for the UI crate's tests.
- While the Save changes prompt, Drawing Recovery or another egui modal is up,
  the panels' OS windows take no input.
- Keys, text and clipboard events typed in a panel's OS window go into the main
  window's next input, so shortcuts, the command line and the canvas get them.
- Files dropped on a panel's OS window open like files dropped on the main window.

* Floating windows stay below the macOS title row

A drag in that row moves the whole app window, so a floating panel drawn over it
could not be moved or used. The dock area keeps floating windows, flyouts and
drag ghosts below it.

* Opening a floating panel from a menu or command brings its window to the front

* Workspace saves are spaced at least 500 ms apart, so dragging a floating window doesn't write the file every frame

* Pin craft-libs at #312's head b6d35e28

* Pin craft-libs at #313's head cdc67131 (result-rectangle drop preview)

* The title row is craft-window's title bar

Its empty part moves the window and follows the macOS double-click setting,
and does nothing in full screen. On macOS the window's movable flag follows
the pointer, so AppKit's title-band drag leaves the window still under the
row's controls and under floating panels. Floating panels may therefore go
over the title row again.

craft-window is pinned at craft-libs#316's head, which is not stacked on the
craft-ui pin.

* Pin craft-libs at 189b4ffe (#313 and #316 merged)

* macOS menu choices and their shortcuts run at once

A menu choice is no window event, so the app ran it only at its next frame
for another reason: File ▸ Close (Cmd+W) on an edited drawing showed
"Save changes?" and dimmed the floating palettes a second or two late. The
menu's event handler now wakes the app.

* Pin craft-libs at pin-round3 d24e4bcc

* Cargo.lock and the native-window test helper after the egui 0.37 merge

* Pin craft-libs at #334 (4638095): the title bar's empty area takes no keyboard focus

* Pin craft-libs at main 271594c0

* A modifier the main window changes while panel keys are forwarded is the one put back
2026-10-25 22:23:01 -04:00
aiongg 2a2d4e2582 macOS Edit menu keys edit a focused text field (#2139)
* macOS Edit menu keys edit a focused text field

The menu bar takes ⌘C, ⌘X, ⌘V, ⌘A, ⌘Z and ⇧⌘Z before any window sees them,
so they ran Copy, Paste or Undo on the drawing while the user was typing in a
field. While a text field has the keyboard, in the main window or a floating
panel's own window, the menu now hands the field its key instead.

craft-libs is pinned at #318's head for typing_window and send_keys.

* Edit menu items a text field takes stay enabled while one has the keyboard

Edit ▸ Paste follows the drawing clipboard, so with nothing copied in the
drawing it was greyed out even while a text field could paste.
2026-10-25 21:58:11 -04:00
Dylan M. Taylor 2f82ecdbf1 feat: startup update prompt modal and direct self-updating (#2141)
* feat: startup update prompt modal and direct self-updating

Ref: storytold/craft-libs#319

* build: pin craft-update to craft-libs main (b9ba47f) and refresh the lock

* refactor(updates): drive the flow with craft-update's Updater

Results are read independent of the prompt, a dropped worker is an error, failures return to the prompt, and no update starts over unsaved work. A manual check during the startup check is honored.
2026-10-25 14:52:54 -04:00
d6e712db41 CLI --save, convert and sample write through a staged file so a failed write keeps the previous drawing (#912) (#982)
* CLI --save, convert and sample write through a staged file so a failed write keeps the previous drawing (#912)

* cli: write through the engine's atomic_write instead of a second copy

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

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 12:25:51 -04:00
aiongg 0a14fc7d3a macOS menu choices and their shortcuts run at once (#2140)
* macOS menu choices and their shortcuts run at once

A menu choice is no window event, so the app ran it only at its next frame
for another reason: File ▸ Close (Cmd+W) on an edited drawing showed
"Save changes?" and dimmed the floating palettes a second or two late. The
menu's event handler now wakes the app.

* cargo fmt after merging main
2026-10-25 12:07:15 -04:00
skylar taylor-barrick f2b35e957e Migrate CADCraft to egui 0.37 (#2119)
* egui037: migrate CADCraft UI and preserve font notices

* M0: clarify screenshot cancellation after issue

* Document migration UI contracts and regression assertions
2026-10-25 10:48:48 -04:00
aiongg b901656669 Panels: use craft-ui's shared docking (#2093)
* Pin craft-libs at 420d21a

* Pin craft-libs at 16fb3d4

* Use craft-ui's shared docking workspace and ui.dock commands

Replace the local Workspace, its validation, apply, visibility and the
ui.dock parser with craft_ui::docking::Workspace and DockOperation.
CADCraft keeps its panels, the drawing view's rules, the default layout
and the workspace file. Add tests for loading a v1 workspace file and for
dragging a panel group by its bar.

* Pin craft-libs at 6e7f285

* Docking: reveal panels through craft-ui's set_visible, which reopens groups and places new panels itself

* Docking: palette switches regroup a panel whose recorded anchor is hidden instead of opening it alone
2026-10-24 20:23:39 -04:00
63394e872d chore: resolve SonarCloud maintainability issues (#2025)
* chore: resolve SonarCloud maintainability issues

* style: format with rustfmt

* fix(engine): reference DocState instead of DrawingState

* fix: restore cmd wildcard imports and remove unused Arc import

* Unit tests for the extracted mleader alignment steps and spacemouse filter

* test: fix assertions flagged in review and apply rustfmt

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

* test: initialise drift filter in struct literal for clippy

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

---------

Co-authored-by: Dylan Taylor <dylan@craftmaker.dev>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 04:34:49 -04:00
Nicholas GowerandBrandon Thomas 5cfe1a4149 appload: load a .lsp file from a path or the file picker (#1903)
* appload: load a .lsp file from a path or the file picker (#1820)

APPLOAD had no user interface: typed or chosen from Tools > Load Application it
started a command-line prompt instead of a file browser, and the JSON form did
not say which commands the file defined.

- The desktop's APPLOAD opens an rfd file browser filtered to .lsp, starting in
  the folder the last routine came from (kept in the preferences as lispDir).
  A cancelled picker is a no-op; the web (no native dialog) keeps the engine's
  name prompt.
- The JSON form `appload {path}` loads to the end, never opens a dialog and now
  returns `commands`, the `c:` commands the file defined; a non-.lsp path is
  refused and unreadable or unparsable files return an ordinary error.
- Tests: a synthetic .lsp in a temp folder loads by path and its command runs;
  the picker path, its cancelled answer and the remembered folder; refused,
  missing and broken files.

* appload: load a picked file interactively; keep path whitespace; web hint

Reviewer follow-ups on #1820:

- A picked file now loads through the interactive APPLOAD command (started
  and answered with the picked path, as a typed name is), so a routine it
  starts that calls getpoint or another get… waits for the user instead of
  receiving nil. JSON calls keep the blocking run.
- A JSON `path` is read as given: only a path of blanks is refused, so a
  relative name with leading or trailing spaces is not trimmed into another
  file.
- The web has no file system, so APPLOAD no longer asks for a file name it
  cannot read: it says to drop a .lsp file on the window, which loads the
  file's contents. The host declares this through Services::no_file_system.

Tests: a picked file whose load asks for a point waits for it; a relative
path in a folder whose name starts with a space loads; on a host without a
file system APPLOAD gives the drop hint instead of prompting.

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-24 01:57:06 -04:00
fde62a39ab MEASUREINIT: choose the units of new drawings, kept in the profile (#1792)
* MEASUREINIT: choose the units of new drawings, kept in the profile

NEW, QNEW and the first drawing at startup were always imperial unless a
template or {"metric": true} was given, and nothing kept a metric choice
across restarts. MEASUREINIT (0 imperial, the default; 1 metric) is now a
profile variable: NEW without `metric` or a template follows it, the app's
startup drawing follows it, and Options > Files shows it as "Units of new
drawings". QNEWTEMPLATE and an explicit `metric` still win.

Part of #1182.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dxn7PD9WzP4ET2LU7BRLDK

* contributors: add Richard Sauer (aussieboer61)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dxn7PD9WzP4ET2LU7BRLDK

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-24 01:54:51 -04:00
Nicholas Gower 1c7d3f0fae CLI: a --cmd can act on a handle an earlier --cmd returned ({last}) (#1871)
* cli: act on a handle an earlier --cmd returned ({last}) (#1663)

A run's --cmd steps were independent: mview answered {"viewports":["100"]}
but viewport.set could not use that handle, so it failed with "no viewport
given".

The batch runner now remembers the last object handle a --cmd returned (the
strings in its result that name an entity of the active drawing, so counts and
names are not mistaken for handles). A parameter value of exactly {last} in a
later --cmd, at any depth, is replaced with it; every other value is passed
through unchanged. With no handle yet the run fails with a clear error instead
of passing a bad handle to the command.

Fixes #1663

* cli: keep {last} to the drawing and handle fields that returned it

Read handles only from result handle fields, never arbitrary strings: a group named like another object's handle is a name, not a reference. Clear the remembered handle when new, open, document.switch (or a script running one) makes another drawing current, and keep a handle the new drawing's own command returns.
2026-10-24 01:50:40 -04:00
Nicholas Gower b9d59c64ab web: report the open drawing in the viewport title (#1869)
* web: report the open drawing in the viewport title

* docs: document the functions touched by #1717

* web: write the browser document title directly

eframe's web runner ignores egui::ViewportCommand::Title, so the tab and
an embedding page kept showing CADCraft when a drawing was opened,
renamed or became dirty. Write the title through web_sys instead, and
route the change decision through a writer function so the changed-title
path is covered by a native test.
2026-10-24 01:50:29 -04:00
aiongg 9cf115e808 CI: one test binary per crate, nextest, line tables only (#1989)
* Build: line tables only in dev, no debug info for dependencies

* Tests: one integration-test binary per crate

Each crate's tests/*.rs becomes a module of tests/it/main.rs. Files that
install process-wide state (the file io hooks, user aliases, SAVETIME and
SAVEFILEPATH, user linetype and pattern files, registered fonts) stay their
own test binaries.

* xtask ci: nextest, tests without examples, one wasm check call
2026-10-23 13:34:26 -04:00
Dylan M. Taylor 78decf80f7 web: allow browser zoom in the viewport meta (SonarCloud Web:S7926) (#1974)
user-scalable=no stops people with low vision from zooming the page. The
canvas keeps its own pinch/pan handling: eframe already prevent_default()s
touch and gesture events on the canvas, so dropping the flag only restores
browser zoom (and accessibility zoom) outside of the app's gestures.
2026-10-23 11:16:34 -04:00
skylartaylor 48b4165591 docs: document security limits and regression guarantees 2026-10-23 01:59:55 +02:00
Dylan M. Taylorandskylartaylor ca578a8fb7 security: authenticate the local control server with a per-session token (CWE-306) (#1844)
* security: authenticate the local control server with a per-session token (CWE-306)

The optional `--control PORT` server listens on loopback TCP and dispatched any
JSON request without authenticating the peer. Loopback TCP does not distinguish
operating-system users, so another local account could connect and drive the
desktop user's command and file authority (for example reading files through
native Lisp, or create/truncate writes).

Authenticate the peer with an unguessable per-session token:

- New `cadcraft_mcp::control_token` module: `issue(port)` generates a 256-bit
  token and writes it to `cadcraft-control-<port>.token` in the per-user runtime
  directory with owner-only permissions (0600 on Unix; per-user LOCALAPPDATA on
  Windows). `read(port)` reads it; `matches` compares in constant time.
- The control server issues the token at startup and requires every request to
  carry a matching `token` field; requests without one get an `unauthorized`
  reply and the connection is closed before dispatch.
- The MCP bridge (`Remote`) reads the token from the owner-only file — which
  needs the desktop user's own filesystem access — and sends it on every
  request, so the bridge keeps working unchanged for the legitimate user. A
  different local user cannot read the token and cannot authenticate.

Updates `docs/control-protocol.md`. New tests cover token issue/read/compare,
file permissions, and that the server refuses unauthenticated requests while
accepting authenticated ones.

Addresses finding row 48.

* fix: recover control authentication across server restarts

---------

Co-authored-by: skylartaylor <26588250+skylartaylor@users.noreply.github.com>
2026-10-22 17:35:18 -04:00
Dylan M. Taylor acd109d97c Use chrono instead of hand-rolled UTC conversion (#1789)
* Use chrono for UTC/calendar conversion instead of hand-rolled copies

Replaces the three civil_from_days copies (logging timestamp, Drawing
Recovery time, parity checklist date) with chrono as a workspace
dependency. Output formats are unchanged and pinned by the existing tests.
AGENTS.md now says time goes through chrono.

Fixes #1785

* Never panic on a clock outside chrono's range; keep 5-digit years unprefixed

chrono's From<SystemTime> unwraps, so a clock past year 262143 panicked
inside the logger. timestamp and xtask's date now use the fallible
DateTime::from_timestamp and read such a clock as the epoch, as the old
saturating code did. xtask's date also clamps a pre-epoch clock to
1970-01-01 again. Years past 9999 are written with {:04} like before,
because %Y would add a '+'. Tests cover all three cases.

* Don't build an unrepresentable SystemTime in the far-future test

UNIX_EPOCH + Duration::from_secs(u64::MAX / 2) overflows Windows'
SystemTime and panicked before timestamp() ran, failing the Windows
test job. Build it with checked_add and only assert where it exists.

* logging/autosave/parity: keep extended-year dates, no epoch fallback

Applies the stricter no-epoch-fallback rule (from wordcraft#3427 / filmcraft#2662,
superseding the earlier epoch-or-last-valid suggestion) so all sibling PRs agree:
a timestamp the old code converted past chrono's year 262143 range must keep its real
proleptic-Gregorian date, not collapse to 1970-01-01. Each site uses the 400-year
Gregorian cycle (146_097 days) with chrono as the only calendar logic: chrono converts
the day within one cycle, whole cycles carry in the year (i128, saturated into i64).
This also removes the panic FredApps flagged — `DateTime::<Utc>::from(SystemTime)` could
panic past chrono's range; `from_num_days_from_ce_opt` within one cycle never does.
Verified the cycle result equals the old formula across a wide range and at the extremes.

- apps/cadcraft/src/logging.rs `timestamp`: cycle `civil_from_days(u64)`; pre-1970 epoch
  clamp and ms/time-of-day format kept. Pins: +10_000_000_000_000 s -> 318857-05-20T17:46:40.000Z
  (was 1970-...); checked_add u64::MAX/2 s -> 292277026596-12-04T15:30:07.000Z (was epoch).
- crates/ui-egui/src/autosave.rs `utc`: cycle converter. Pins: utc(10_000_000_000_000)
  = "318857-05-20 17:46"; utc(u64::MAX) = "584554051223-11-09 07:00" (was "1970-01-01 00:00").
- xtask/src/parity.rs `date`: cycle converter; pre-1970 epoch clamp kept. Pins:
  day(115_740_740) = "318857-05-20"; checked_add u64::MAX/2 s -> "292277026596-12-04" (was epoch).

* Don't build a year-318857 SystemTime unchecked in the logging test

Windows' SystemTime only reaches about year 30828, so
UNIX_EPOCH + Duration::from_secs(10_000_000_000_000) panicked before
timestamp() ran and failed the Windows test job. Build it with
checked_add and assert where the platform can hold it. The year-10000
case fits everywhere and stays unconditional.

* Name the 400-year range extension as the one exception to the time rule

The rule said never to hand-roll calendar math, but the range fix
(shifting by whole 400-year Gregorian cycles so dates past chrono's year
range keep their old output) is cycle arithmetic, so the rule and the code
disagreed. The rule now names that exception: the library still computes
month, day and time, and only whole cycles are carried in the year.

* Say the library computes month and day; time-of-day is plain arithmetic

Narrows the range-extension wording: the date library computes the
calendar fields, and splitting seconds into hours, minutes and seconds
is ordinary arithmetic, which is what the code does.
2026-10-22 14:09:16 -04:00
oracola91 4d826ed91f Control server: wait after a failed accept instead of retrying at once
The accepting thread dropped failed accepts with incoming().flatten()
and asked again at once. When accepting keeps failing, for example while
the process has no file descriptors left, that loop keeps a core busy
until it recovers.

Wait 100 ms after a failed accept. The loop goes through a small helper
so the wait can be tested without running out of file descriptors.
2026-10-20 14:49:58 +03:00
Gert Pellin - GPE 471478868d cadcraft-cli: --help / -h / help exit 0 and print the usage to stdout (#1526)
A help request is not a failure: --version already goes to stdout with
exit 0, and now --help, -h and help do the same. Bare cadcraft-cli
keeps reporting on stderr with exit 1, like git.

Fixes #1525
2026-10-19 02:33:15 -04:00
Marco Arnulfo aba903dbb3 CLI: convert to an image warns about the entities it can't draw (#1212)
* CLI: convert to an image warns about the entities it can't draw (fixes #1208)

* CLI: the undrawn-entities warning lists its counts like the skipped-records one
2026-10-19 02:31:06 -04:00
aiongg 972ae7888c Close control connections on the first line that is not a request; bound idle time, writes and queued requests
A line that is not a JSON object with a string method, including an HTTP
request line, gets one error reply and the connection closes before
anything after it is read. Connections time out after 10 minutes idle or
30 seconds blocked on a write, and at most 64 requests wait for the app;
more are answered busy.
2026-10-16 16:07:25 +00:00
a2b6c9ac4a Animation effects follow the operating system's setting; View ▸ Accessibility ▸ Animation Effects (#759)
With Windows set to show no animations (Settings ▸ Accessibility ▸ Visual
effects ▸ Animation effects), dialogs and menus still faded in and wheel
input glided. All of that motion is egui's default behaviour, and nothing
asked the operating system.

- crates/ui-egui/src/animation.rs: AnimationPref (System, On, Off). Off sets
  egui's animation_time to 0 and scroll_animation to none (window, menu,
  tooltip and popup fades, collapsing sections, scroll bars, scroll-to
  glides), hands each wheel notch to scroll areas whole, and lets
  Cmd/Ctrl+wheel zoom reach its zoom in one step.
- System, the default, follows the OS live: UISettings::AnimationsEnabled on
  Windows, NSWorkspace's Reduce motion on macOS, the XDG Desktop Portal's
  reduced-motion (then GNOME's enable-animations) on Linux and the BSDs,
  prefers-reduced-motion on the web.
- View ▸ Accessibility ▸ Animation Effects ▸ Use System Setting / On / Off,
  the same choice in Options ▸ Display, and `ui.animations`, `options` and
  `ui.set` {"animations"}; `ui.inspect` reports whether animations show.

Drafting behaviour is untouched: tracking's acquisition pause, the
time-sensitive right-click, previews, the caret blink, realtime pan and
zoom. Plain wheel zoom already moved a whole notch per frame (#692).

macOS and Linux are compile-checked (clippy -D warnings for
aarch64-apple-darwin and x86_64-unknown-linux-gnu), not run.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-13 05:34:56 -04:00
8166e4a809 AutoLISP: load and run .lsp routines (fixes #758) (#855)
* AutoLISP: load and run .lsp routines (#758)

CADCraft runs AutoLISP routines. `.lsp` files load with APPLOAD, (load)
or by dropping them on the window, and at startup from the support folder
(acad.lsp once per session or per drawing with ACADLSPASDOC, acaddoc.lsp
per drawing, then S::STARTUP; never from a drawing's own folder).
(defun c:NAME …) defines commands, (autoload) defers loading to first
use, and (expression) and !variable work at the command line and at
prompts. docs/autolisp.md lists what is supported.

The interpreter (crates/engine/src/lisp) reads source with its own
stack and evaluates on an explicit-stack machine, so a routine can stop
for input (getpoint, pause in command) and carry on later on one thread,
deep recursion is an error rather than a crash, and long loops run a
slice per frame (Esc stops them). It follows AutoLISP: 32-bit wrapping
integers, dynamic scope, *error*, vl-catch-all-apply, AutoLISP's error
wording. About 200 built-ins cover the language, command (with pause and
commands left open), entity lists (entget/entmake/entmod/entdel/entlast/
entnext/handent, through the DXF reader and writer's groups, exposed by
cadcraft-io and handed to the engine through a hook), ssget with modes
and filters, tables, system variables, get… input with initget, and
units. A routine is one undo step; each drawing has its own symbols.

Also: keywords typed as _KEYWORD match at prompts; TEXT run by a
routine's command takes one line; DIMENSION records carry their
measurement (42); Prompt::raw shows a get… prompt as written; the
interface asks Session::prompting() (a command's or a routine's prompt)
where it asked whether a command runs, so clicks, right-click and Delete
work at a routine's prompts; getcorner and based get… prompts drag a
rubber band.

Tests: the language, errors, startup files, interactive input, hostile
input (lisp/tests.rs); the #648 corpus, 18/18, when CADCRAFT_LISP_CORPUS
points at it (tests/lisp_corpus.rs); drop, frames and canvas clicks in
the app (ui-egui/tests/lisp_files.rs).

* AutoLISP: bound memory, drop nested values iteratively, guard the per-frame tick

- Cons cells drop with a work list over both car and cdr (and function
  bodies), so a list nested a million deep in its car no longer overflows
  the stack when it goes away.
- Memory caps so a runaway routine fails with a LISP error instead of an
  uncatchable out-of-memory abort: at most 5,000,000 live cons cells
  (counted in Value::cons / Drop, checked every step; cells the drawing's
  variables already held at the run's start don't count against it), no
  string a built-in makes longer than 8 MiB (strcat checks before joining,
  printed text is cut), and a 2 GiB per-run budget for new strings,
  printed text and files read.
- Session::lisp_tick runs under catch_unwind, and so does every slice of
  the LISP machine: a panic ends the routine with "internal error", puts
  the drawing back and leaves the session usable.
- Regression tests for each (deep car nesting, nested functions, the cell
  cap, string length and budget, a panicking built-in in a blocking run
  and in a ticked one).

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

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 05:33:57 -04:00
dc92f38772 DXF: count the records the reader can't place; info reports them, convert warns (part of #865) (#872)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 05:31:58 -04:00
25e4900fcd Recent files: the Start page and File ▸ Open Recent list the drawings opened last (fixes #858) (#864)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 04:44:44 -04:00
Marco Arnulfo 3f709bed2e Desktop: a bare --control keeps the CADCRAFT_CONTROL_PORT port (fixes #844) (#848)
`--control` as the last argument read `args.next()` as None and reset the
port taken from CADCRAFT_CONTROL_PORT, without logging anything, so
`CADCRAFT_CONTROL_PORT=47321 cadcraft --control` started no control
channel. A bare `--control` now keeps the variable's port and logs a
warning either way; a port given on the command line still wins. The
precedence is documented in docs/control-protocol.md.
2026-10-13 04:37:52 -04:00
Books 7fcdc6e3bc Active viewport highlight: selection on the GPU, hover cached apart, kept inside a clipped outline (#770)
* Active viewport: redraw while panning and zooming inside it, without rebuilding the layout (#688)

Inside an active MSPACE viewport, middle-drag, trackpad pan and wheel or
pinch zoom change the viewport's view through set_view_quiet, which makes
no new revision. The layout's display list, which draws model space
through each viewport, was cached on (drawing, revision, zoom band), so it
kept showing the old view until a command such as ZOOM rebuilt it: the
viewport froze, or appeared to ignore the zoom.

The active viewport's contents are now drawn apart from the layout's list,
the way model space itself is drawn. They are built once for an area
around the viewport (half its larger side of margin all round), then moved
and scaled to the current view on the GPU and drawn under the layout's own
objects. They are rebuilt when the drawing changes, when the zoom has
moved 2x from the view they were built for, when the view leaves the built
area, and once a zoom comes to rest, so dash lengths and curve detail
follow the viewport's scale again. A pan step takes about 1 ms with 70,000
polylines, where rebuilding the layout takes about 140 ms per frame.

Rectangular viewports are clipped with the scissor rectangle. A clipped
viewport's outline is drawn into a stencil buffer as a triangle fan that
flips a bit (even-odd, as the outline clips on the CPU), and its contents
draw only where the bit is set; the native window now asks eframe for an
8-bit stencil buffer, and every canvas pipeline names its format. Where
there is no stencil buffer (the CPU canvas, the web runner), a clipped
viewport's contents stay in the layout's list, whose cache now follows the
viewport's view.

Render: Options::skip_viewport leaves a viewport's contents out of a
layout, and viewport_content builds them for any area. Viewport contents
are built on all cores, like model space (130 ms to 37 ms for 70,000
polylines).

* Active viewport highlight: selection on the GPU, hover cached apart, kept inside a clipped outline

Inside an active MSPACE viewport the selection highlight was drawn as
dashed egui shapes on the CPU every frame, about 8-13 ms per frame with
7,000 polylines selected, and its geometry was cached together with the
hover, so moving the cursor over objects tessellated the whole selection
again. The selection and the hover are now cached apart, and the
selection goes through the GPU highlight path model space already uses:
about 0.1 ms per frame. In a clipped viewport it is kept inside the
outline with the stencil mask its contents use; before, it was only cut
to the outline's bounding rectangle.
2026-10-13 04:34:40 -04:00
aldozarandClaude Opus 5.5 483a9304ca cadcraft-cli mcp: --lazy and --launch for clients that start before the app (#756)
MCP clients start their servers when they start, usually before CADCraft is
open, and `--connect` refuses to start then. `--lazy` starts the bridge
anyway and reaches the app at the first tool call; until the app answers,
each call fails with how to open it. `--launch` (implies --lazy) opens
CADCraft on macOS with that control port at a call that finds nothing, and
waits up to 30 s. Neither falls back to a headless session (#103).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 04:34:14 -04:00
SteamSmithandClaude Opus 5.5 61c018845a control server: bound request lines and concurrent connections (#678)
Squash of PR #678 by @SteamSmith.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 03:12:34 -04:00
PedroGuilleandClaude Opus 5.5 ac9483c9c1 Add raw-HID SpaceMouse 2D navigation (#650)
Squash of PR #650 by @PedroGuille.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:29:48 -04:00
Daniel LošákandClaude Opus 5.5 6d7c652b65 i18n: add Czech (cs) interface language (#639)
Squash of PR #639 by @Losminek.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:11:51 -04:00
Gert Pellin - GPEandClaude Opus 5.5 f7c6293d09 feat: SVG import — read SVG and SVGZ into native entities (#636)
Squash of PR #636 by @switch87.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:11:22 -04:00
aldozarandClaude Opus 5.5 1e1f68bf40 Fix fatal wgpu buffer overflow; draw MOVE/COPY/ROTATE/SCALE previews and the selection highlight on the GPU (#642)
Squash of PR #642 by @aldozar.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:10:51 -04:00
STE1200andClaude Opus 5.5 244feb6e89 Render: level of detail and view clipping for pattern hatches on screen (fixes #544) (#638)
Squash of PR #638 by @STE1200.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:10:37 -04:00
Fabio LussiandClaude Opus 5.5 370150186b Linux: Theme > System follows the desktop's light/dark setting (#641)
Squash of PR #641 by @itrycodinganditsbad.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 19:53:48 -04:00
PedroGuilleandClaude Opus 5.5 f967dc7aca Persist SNAPUNIT and GRIDUNIT in DXF (fix #607) (#614)
Squash of PR #614 by @PedroGuille.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 14:28:56 -04:00
MarekSpodBiedryandClaude Opus 5.5 5e71a75383 Web: ?cpu selects the CPU canvas instead of installing the GPU canvas (#500) (#592)
Squash of PR #592 by @MarekSpodBiedry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 14:27:46 -04:00
PedroGuilleandClaude Opus 5.5 ce1cc631d5 Windows: use FXC by default for DirectX 12 startup (#613)
Squash of PR #613 by @PedroGuille.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 13:39:24 -04:00
Valentin KleinandClaude Opus 5.5 74e72c93f8 Plotters: device configurations with paper sizes and printable areas (fixes #564) (#570)
Squash of PR #570 by @vangel76.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 13:25:00 -04:00
Gert Pellin - GPEandClaude Opus 5.5 18b4a8d0dd CLI: cadcraft-cli exits quietly when stdout is closed early (#568)
Squash of PR #568 by @switch87.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 13:24:30 -04:00
Valentin KleinandClaude Opus 5.5 d0dd5cfe16 Support files: DWT templates for NEW/QNEW, SAVEAS .dwt, user alias files (fixes #552) (#562)
Squash of PR #562 by @vangel76.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 13:12:32 -04:00
Valentin KleinandClaude Opus 5.5 a4504cc098 DXF: keep what we don't model on save — unknown entities and objects, foreign xdata, extension dictionaries, reactors, colour books (fixes #528) (#545)
Squash of PR #545 by @vangel76.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 12:46:58 -04:00
Valentin KleinandClaude Opus 5.5 b13f1e6fa4 OPTIONS: preferences dialog, JSON form and a user profile that survives restarts (fixes #23) (#531)
Squash of PR #531 by @vangel76.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 12:34:34 -04:00
CyberFox-81andClaude Opus 5.5 7dead193e4 Web: opt-in embedding bridge for host pages (?host=parent) (#509)
Squash of PR #509 by @CyberFox-81.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:47:27 -04:00
Valentin KleinandClaude Opus 5.5 b3a73626b2 SAVEAS version and binary DXF, default save version (fixes #421) (#431)
Squash of PR #431 by @vangel76.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:19:47 -04:00
Valentin KleinandClaude Opus 5.5 64eed455e5 Autosave (SAVETIME, SAVEFILEPATH) and Drawing Recovery after a crash (fixes #406) (#416)
Squash of PR #416 by @vangel76.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:18:49 -04:00
skylar taylor-barrick a3f43a700f UI: recover persisted docking without resetting workspace preferences 2026-10-10 18:49:16 -06:00
skylar taylor-barrick 58a8494f98 UI: retain shared workspace while merging current main 2026-10-10 18:31:07 -06:00
Valentin KleinandClaude Opus 5.5 cdd25840cc Control channel: a timed-out request is cancelled, and the reply says whether it ran (fixes #345) (#348)
The control server gave up after 60 s with "timeout" but left the request
queued, so the app ran it later and a client retry applied it twice.
Requests now carry a shared state: on timeout the server cancels a request
the app has not started (reply state "not-run", safe to retry) and the app
skips it; one already started replies state "may-have-run". The MCP bridge
passes that on in its error.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:32:19 -04:00