Compare commits

..
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 fbd97d432c Release 0.14.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 12:09:15 -04:00
Brandon ThomasandClaude Opus 5.5 d9265c9245 macOS packaging: retry stapler when Apple's CloudKit drops the connection
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 12:09:14 -04:00
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 3929d94d89 Merge pull request #2230 from Jeremy8776/feat/install-scope-policy
Windows installation policy: CADCraft
2026-10-27 17:06:47 -04:00
aiongg 729f27de74 ci: use storytold/craft-common-actions at the merged installer-policy commit 2026-10-27 20:51:59 +00:00
Dylan M. Taylor 2721aa8982 Enforce Rust coding standards via Clippy (#2239)
* chore(clippy): enforce workspace Rust quality gates

* refactor(doc): use explicit entity imports

* refactor(constraints): split residual evaluation

* refactor(fonts): split mtext layout

* refactor(render): use explicit entity imports

* fix(fonts): retain height for empty paragraphs

* fix(doc): repair dimension and dynamic block links

* refactor(render): split entity drawing helpers

* docs: document workspace lint inheritance

* refactor(render): isolate dimension geometry

* refactor(engine): use explicit imports

* refactor(engine): split complex edit paths into helpers

* refactor(io): split DXF read and write helpers

* refactor(ui): split canvas interaction and drawing

* refactor(ui): split command and drawing helpers

* test(i18n): split locale preference checks

* fix(ui): use current egui scope id API

* fix(dim): preserve text offset and ellipse grip behavior
2026-10-27 15:55:09 -04:00
Jeremy 360bee4dcc fix(installer): check the exact architecture-specific machine folder
The pre-signing check matched any folder named Program Files, so it rejected
a valid MSI when Windows resolves Program Files elsewhere, accepted the 64-bit
root for an x86 package and accepted a different app name. Build the expected
folder from the package architecture's resolved root and the policy name.
2026-10-27 19:12:39 +00:00
aiongg 26da98e320 fix(ui-egui): Ui::scope_id for the block description field (#2336)
Ui::id is deprecated in the pinned egui, so clippy -D warnings fails on main
since #2113.
2026-10-27 14:55:46 -04:00
aiongg b4a8773c3c Merge pull request #2193 from dotCooCoo/feat/measure-arc-segments
AREA and DIST: the Arc option measures arc segments
2026-10-27 13:44:25 -04:00
aiongg 180c1885bb Merge pull request #2177 from dotCooCoo/feat/layout-saveas-template
LAYOUT: SAveas saves a layout as a drawing template
2026-10-27 13:44:03 -04:00
aiongg 9d32ead9e6 Merge pull request #2175 from dotCooCoo/feat/named-viewports
View: Viewports > Named Viewports... lists, restores and deletes saved viewport configurations
2026-10-27 13:43:43 -04:00
aiongg 2c73e5f063 Merge pull request #2162 from dotCooCoo/fix/area-too-large
AREA: an area, perimeter or running total past f64's range is refused
2026-10-27 13:43:23 -04:00
aiongg ec762af206 Merge pull request #2113 from dotCooCoo/feat/block-description
Blocks: Modify > Object > Block Description..., and the Block Editor saves a changed description
2026-10-27 13:43:02 -04:00
aiongg 786367e9ba Merge pull request #2331 from Silvertree2010/perf/export-speed
Speed up PNG export: stroke runs of hairline polylines as one path
2026-10-27 13:41:15 -04:00
Jeremy d1847cf9a0 fix(installer): resolve the wizard scope and folder correctly
Real msiexec runs (per-user and all-users, silent and wizard) found two defects
that the metadata fixtures cannot see:

- "Only for me" published ALLUSERS=2, so FindRelatedProducts treated the run as
  per-machine and skipped the existing per-user copy. An interactive upgrade
  left two registered products and uninstall left files behind. The page now
  leaves ALLUSERS unset for a per-user choice.
- Windows Installer resolves Program Files once, for the context it started in,
  so choosing "Everyone" in the wizard installed into the user profile with a
  machine-wide registration. The wizard now sets the new-install folder itself
  after the choice (Program Files, Program Files (x86) for 32-bit, or the
  per-user Programs folder). Silent installs are unchanged.

check-install-policy.ps1 now fails if either regresses.
2026-10-27 15:32:13 +00:00
NetterSilverandClaude Opus 5.5 850dd94f3c Stroke runs of hairline polylines as one path in the rasteriser
PNG export (and everything else drawn by raster::render) stroked every
polyline primitive with its own tiny-skia stroke_path call: a new path
and a newly built paint pipeline per primitive. Hatch patterns, linetype
dashes and text strokes produce long runs of short polylines in one
colour, so that per-call setup dominated the render.

Consecutive hairline polylines (zero width, or at most one pixel when
anti-aliased; the default for PNG export) with the same colour, alpha
and width are now collected into one path and stroked once. tiny-skia
draws hairlines segment by segment straight onto the pixmap with the
same per-contour round caps, so the pixels and their blending order are
unchanged. Thicker strokes (filled outlines), fills, points, infinite
lines and polylines with non-finite or far-out screen coordinates break
the run and are drawn exactly as before.

Adds crates/io/examples/export_bench.rs, which exports examples/
apartment.dxf repeated on a grid (10x10: 5,800 entities) and reports
median/min wall time per format.

PNG export, 10x10 grid, 11 interleaved runs (Ryzen, 16 threads, Linux):
  before: median 1790.7 ms, min 1703.3 ms
  after:  median 1037.6 ms, min  926.0 ms   (-42.1 %)
A/A noise on the same setup: 1.1 %. Output PNGs are byte-identical
(bench at 10x10 and 20x20, CLI export of apartment.dxf and a 23,200-
entity drawing). New tests compare render() against stroking each
primitive on its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 15:47:42 +01:00
aiongg 0ff1b03537 refactor(ui): use craft-ui's ComboBox for every dropdown (#2303)
* ui: use craft_ui::ComboBox so dropdown menus grow back (egui#5225)

* chore(deps): craft-libs 60d60d48 (dropdown fixes)

* chore(deps): craft-libs 6af63481 (dropdown fixes)

* chore(deps): craft-libs 4d7b82f7 (dropdown fixes)

* chore(deps): craft-libs 92d392cf (#387 merged)
2026-10-27 10:26:40 -04:00
Jeremy 7b947c3383 docs(installer): say that silent installs are per-user and when the scope page appears 2026-10-27 12:26:55 +00:00
Jeremy 29e2a139db fix(installer): harden install-policy checks and packaging-lint workflow 2026-10-27 12:00:53 +00:00
Jeremy 6795f0bd03 fix(installer): address scope and ARM64 review feedback 2026-10-27 12:00:53 +00:00
Jeremy 8581d30a7f feat(installer): default to a per-user ArtCraft folder 2026-10-27 12:00:53 +00:00
aiongg d864a08b01 Merge pull request #2220 from aiongg/layout
refactor: shared module layout (craft-contrib#20)
2026-10-26 21:30:19 -04:00
skylar taylor-barrick c3b8e7b7fe Merge pull request #2227 from storytold/fix/nextest-0.9.150
ci: require nextest 0.9.150
2026-10-26 17:35:13 -06:00
skylar b06fd039fb ci: require nextest 0.9.150
Use the fixed release for GHSA-vc25-qg58-w57c.
2026-10-26 14:13:04 -06:00
aiongg 7d2ca90783 refactor(engine): move Session methods from session.rs to session/ 2026-10-26 19:44:13 +00:00
aiongg af9798e200 refactor(doc): move entity.rs items to entity/ 2026-10-26 19:44:13 +00:00
aiongg c042e98abb refactor(render): move dim.rs items to dim/arrowheads.rs and dim/text.rs 2026-10-26 19:44:13 +00:00
aiongg 994af5cac9 refactor(render): move lib.rs items to display, pipeline, viewports, props, draw, text_entity, dim_draw, blocks 2026-10-26 19:44:13 +00:00
aiongg 970e6bb9d4 refactor(io): move dxf_read.rs items to dxf_read/ 2026-10-26 19:18:38 +00:00
aiongg 736a83464a refactor(io): move dxf_write.rs items to dxf_write/ 2026-10-26 19:18:38 +00:00
aiongg aa5dfc2099 refactor(engine): move lisp/builtins.rs items to lisp/builtins/ 2026-10-26 19:18:38 +00:00
aiongg f12b7570b7 refactor(engine): move lib.rs types to error, view, doc_state, settings, running, last_used and Session methods to session.rs 2026-10-26 19:18:38 +00:00
aiongg 795eb62762 refactor(engine): move cmd/draw.rs items to cmd/draw/ 2026-10-26 19:18:38 +00:00
aiongg e7b92c5b8e refactor(engine): move cmd/annotate.rs items to cmd/annotate/ 2026-10-26 19:18:38 +00:00
aiongg 6028cdf187 refactor(engine): move cmd/modify2.rs items to cmd/modify2/ 2026-10-26 19:18:38 +00:00
aiongg 172f9ca44c refactor(engine): move cmd/draw2.rs items to cmd/draw2/ 2026-10-26 19:18:38 +00:00
aiongg c5e7db78e4 refactor(engine): move cmd/modify.rs items to cmd/modify/ 2026-10-26 19:18:38 +00:00
aiongg 31a04e3dbe refactor(ui-egui): move canvas.rs items to canvas/ 2026-10-26 19:18:38 +00:00
aiongg 627ffb8d7e refactor(engine): move grips_tests.rs to grips/tests.rs 2026-10-26 18:18:08 +00:00
aiongg 18f47e021f refactor(engine): move cmd/inquiry/usage_tests.rs to cmd/inquiry/usage/tests.rs 2026-10-26 18:18:08 +00:00
aiongg 21d80b3231 refactor(engine): move cmd/txt2mtxt_tests.rs to cmd/txt2mtxt/tests.rs 2026-10-26 18:18:07 +00:00
aiongg 62deed7880 refactor(engine): move cmd/mleader_edit_tests.rs to cmd/mleader_edit/tests.rs 2026-10-26 18:18:07 +00:00
aiongg 45bcfa6f77 refactor(engine): move cmd/constraints_tests.rs to cmd/constraints/tests.rs 2026-10-26 18:18:07 +00:00
aiongg 627b5a3350 refactor(engine): move cmd/annotate_tests.rs to cmd/annotate/tests.rs 2026-10-26 18:18:07 +00:00
aiongg 1006e487da refactor(engine): move cmd/modify2_tests.rs to cmd/modify2/tests.rs 2026-10-26 18:18:07 +00:00
aiongg fcd4a0c9eb refactor(engine): move cmd/draw2_tests.rs to cmd/draw2/tests.rs 2026-10-26 18:18:07 +00:00
aiongg dbd2bf5dad style: cargo fmt after moving test modules out 2026-10-26 18:18:07 +00:00
aiongg d7915c5f3b refactor(ui-egui): move canvas.rs tests to canvas/tests.rs 2026-10-26 18:18:07 +00:00
aiongg 1bbc5ec76b refactor(engine): move layout.rs tests to cmd/layout/tests.rs 2026-10-26 18:18:07 +00:00
aiongg 012732136c refactor(geom): move spline.rs tests to spline/tests.rs 2026-10-26 18:18:07 +00:00
aiongg 273303134e refactor(ui-egui): move hatchdlg.rs tests to hatchdlg/tests.rs 2026-10-26 18:18:07 +00:00
aiongg 48d25a0414 refactor(render): move dim.rs tests to dim/tests.rs 2026-10-26 18:18:07 +00:00
aiongg 6021d96628 refactor(ui-egui): move capture.rs tests to capture/tests.rs 2026-10-26 18:18:07 +00:00
aiongg ad329273f2 refactor(ui-egui): move crates/ui-egui/src/options/mod.rs to crates/ui-egui/src/options.rs 2026-10-26 18:18:07 +00:00
aiongg f3796d1f7a style(ui-egui): cargo fmt after the i18n.rs move 2026-10-26 18:18:07 +00:00
aiongg 3a64a190ea refactor(ui-egui): move crates/ui-egui/src/i18n/mod.rs to crates/ui-egui/src/i18n.rs 2026-10-26 18:18:07 +00:00
aiongg 5074d2a54c refactor(engine): move crates/engine/src/lisp/mod.rs to crates/engine/src/lisp.rs 2026-10-26 18:15:12 +00:00
aiongg bccba7261e refactor(engine): move crates/engine/src/cmd/mod.rs to crates/engine/src/cmd.rs 2026-10-26 18:15:12 +00:00
aiongg c8ee56a44f test: scan source directories recursively
The translation gap report reads each listed UI module and every file under its directory, so code split into submodules stays in the report.
2026-10-26 17:59:21 +00:00
dotCooCoo 98a305514b AREA and DIST: the Arc option measures arc segments
AREA's next-point prompt and DIST's Multiple points prompt offered Arc,
and MEASUREGEOM's Distance, Area and Volume options ask the same
prompts, but Arc printed "Arc: not available yet". Arc now turns on arc
mode with PLINE's arc options (Angle, CEnter, CLose, Direction, Line,
Radius, Second pt, Undo; CEnter takes an end point, Angle or chord
Length), and the arcs count in the distance, area, perimeter and
volume. A point in arc mode ends an arc tangent to the
last segment, Length after an arc continues along its end tangent, and
Undo removes the last arc. CLose ends with an arc back to the first
point. A point straight behind the last segment is refused with
"Invalid arc endpoint.".

In a DIST measured through one viewport, an arc's computed end, a typed
radius and a typed chord length are in the viewport's model units, as
Length's distance is.
2026-10-25 21:17:18 -07:00
dotCooCoo 605dc43898 Merge branch 'main' into feat/named-viewports 2026-10-25 21:10:43 -07: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 d1cf06cbea refactor: split complex fillet, model, dimstyle and rectangle code into helpers (#2188)
Pure extraction, no behaviour change: same order of side effects, same
early returns and error values. Clears cognitive-complexity findings on
tangent_circles, Model::build_budgeted, DimStyle::apply_fields and
run_rectang2 (S3776), and replaces a redundant closure with a method
reference in menus.rs (S1612).
2026-10-25 21:50:25 -04:00
Dylan M. Taylor 7d33d9d4dd refactor: split complex input, explode, sweep, cache and options code into helpers (#2187)
Pure extraction, no behaviour change: same order of side effects, same
early returns and error values. Clears cognitive-complexity findings on
AreaM::input, explode_kind, hatch sweep, the COMPARE overlay paint and
the Files options tab (S3776).
2026-10-25 21:50:19 -04:00
Dylan M. Taylor 53080b1748 refactor: split complex matchers, scanners and solver setup into helpers (#2186)
Pure extraction, no behaviour change: same order of side effects, same
budget charges, same early returns and error values. Clears
cognitive-complexity findings on glob_inner, the PDF test ops scanner,
snap_ends, the loops inquiry run and build_budgeted (S3776).
2026-10-25 21:43:08 -04:00
Dylan M. Taylor 21c48cc05b refactor(io): split complex PDF marks and DXF readers into helpers (#2185)
Pure extraction, no behaviour change: same order of side effects, same
early returns and error values. Clears cognitive-complexity findings on
pdf::marks, dxf_read::read_budgeted, parse_entities,
entity_with_table_budget and Objects::apply (S3776).
2026-10-25 21:43:04 -04:00
aiongg 47237bd4dd chore(deps): safe dependency refresh (#2179)
* chore(deps): safe dependency refresh

* Hold acadrust at 0.6.6: 0.6.7 reads nothing from a damaged DWG
2026-10-25 21:15:06 -04:00
dotCooCoo c22ad3229f LAYOUT: SAveas saves a layout as a drawing template
LAYOUT SAveas printed "LAYOUT SAveas is not available yet." It now asks
for the layout (the current one by default) and the template file
(<layout>.dwt beside the drawing by default), and writes the layout as a
template: its page setup and paper space objects, viewports included, no
model space objects and no other layout. Named objects nothing in it uses
are left out, as PURGE All removes them. Layers the layout's viewports
freeze or recolor are kept, with those settings. The open drawing is not
changed.

The JSON form is layout.saveas {name?, path}, also reached as LAYOUT
{option: saveas}. A path without an extension gets .dwt. The command
also writes .dwg and .dxf paths and refuses other file types.
2026-10-25 17:07:28 -07:00
Dylan M. Taylor 2521605713 refactor: explicit imports instead of wildcards (S2208, batch_02) (#2039)
* refactor: replace wildcard imports with explicit ones (S2208, batch_02)

* Fix unused and missing imports the batch's CI gate reported

Remove two unused imports and restore the json macro and resolve_alias
in test modules that the explicit import lists had dropped.

* fix: keep the imports the explicit lists still need after the rebase

* fix(draw2): import machines, distinct_points and ok from cmd
2026-10-25 19:52:19 -04:00
Dylan M. Taylor f56be09993 refactor: explicit imports instead of wildcards (S2208, batch_04) (#2041)
* refactor: replace wildcard imports with explicit ones (S2208, batch_04)

* refactor: replace wildcard imports with explicit ones (S2208, batch_04)

* Format imports so the CI fmt gate passes

* fix: import f64_req in inquiry.rs
2026-10-25 19:37:00 -04:00
Dylan M. Taylor 1b6861698c refactor: cut test helper and two DXF writer closures to 7 parameters (S107) (#2048) 2026-10-25 19:22:31 -04:00
Dylan M. Taylor f24af47ffb refactor: cut helper signatures to 7 parameters (S107) (#2047)
* refactor: cut four helper signatures to 7 parameters (S107)

* fix: item_json params

* fix: keep the eight-argument dim calls to seven after rebase
2026-10-25 19:21:26 -04:00
Dylan M. Taylor 055b06a2f4 refactor: explicit imports instead of wildcards (S2208, batch_08) (#2045)
* refactor: replace wildcard imports with explicit ones (S2208, batch_08)

* Restore imports that the cadcraft batch dropped so the engine compiles

Explicit import lists missed names the files still use. Restore them and
the test-module globs.
2026-10-25 19:21:22 -04:00
Dylan M. Taylor 660a7881d1 refactor: explicit imports instead of wildcards (S2208, batch_07) (#2044)
* refactor: replace wildcard imports with explicit ones (S2208, batch_07)

* Restore imports that the cadcraft batch dropped so the engine compiles

Explicit import lists missed names the files still use. Restore them and
the test-module globs.
2026-10-25 19:21:17 -04:00
Dylan M. Taylor 3d8301b965 refactor: explicit imports instead of wildcards (S2208, batch_06) (#2043)
* refactor: replace wildcard imports with explicit ones (S2208, batch_06)

* Restore imports that the cadcraft batch dropped so the engine compiles

The batch replaced glob imports with explicit lists that missed names
the files still use, and pointed some sub-module imports at the wrong
level. Restore the required imports and the test module globs.

* Format pedit_vertex imports so the CI fmt gate passes
2026-10-25 19:21:13 -04:00
Dylan M. Taylor cb8b1709e9 refactor: explicit imports instead of wildcards (S2208, batch_05) (#2042)
* refactor: replace wildcard imports with explicit ones (S2208, batch_05)

* refactor: replace wildcard imports with explicit ones (S2208, batch_05)

* Format imports so the CI fmt gate passes
2026-10-25 19:21:08 -04:00
Dylan M. Taylor 66ecce06f5 refactor: explicit imports instead of wildcards (S2208, batch_01) (#2038)
* refactor: replace wildcard imports with explicit ones (S2208, batch_01)

* refactor: replace wildcard imports with explicit ones (S2208, batch_01)

* Format imports so the CI fmt gate passes
2026-10-25 19:19:58 -04:00
dotCooCoo 7d0d475aee View: Viewports > Named Viewports... lists, restores and deletes saved viewport configurations
-VPORTS saved, restored and deleted named model space viewport
configurations, and MVIEW Restore placed one in a layout, but View >
Viewports > Named Viewports... had no command. The new command
vports.named runs from the menu item. Typed or from the menu it opens
the Named Viewports dialog: the saved configurations, a preview of the
selected one's tiles, and in the Model tab the name of the configuration
whose tiles are where the Model tab's are, else *Active. Restore shows
the configuration in the Model tab, and in a layout it starts MVIEW
Restore with the name, which asks for the corners. Delete deletes the
saved configuration. The JSON form takes {name?, delete?, p1?, p2?,
layout?}, and without a name it returns the configurations and the
current one.

Catalog rows for the new menu label are added in all 15 languages. In
docs/gaps.md, U11 is narrowed, and F15, U11 and F21 no longer list MVIEW
Restore (#633) or PURGE of multiline styles, which are on main.
docs/parity-checklist.md is regenerated with cargo xtask parity.
2026-10-25 16:07:11 -07:00
Dylan M. Taylor 004287d919 refactor: explicit imports instead of wildcards (S2208, batch_03) (#2040)
* refactor: replace wildcard imports with explicit ones (S2208, batch_03)

* refactor: replace wildcard imports with explicit ones (S2208, batch_03)

* Format imports so the CI fmt gate passes
2026-10-25 18:18:33 -04:00
Dylan M. Taylor 656dd4d09a refactor: explicit imports instead of wildcards (S2208, batch_00) (#2037)
* refactor: replace wildcard imports with explicit ones (S2208, batch_00)

* refactor: replace wildcard imports with explicit ones (S2208, batch_00)

Explicit lists now resolve the names each file uses, and the test
module keeps its glob so it still sees the parent's items.
2026-10-25 18:18:29 -04:00
Dylan M. Taylor 876de8ad2f build: pin craft-update to craft-libs main (495e597) (#2168)
Picks up the startup prompt without the glyphs a font may lack (storytold/craft-libs#343, #344): the close mark is painted and the version pill is ASCII, so apps that panic on a missing glyph no longer take the prompt down.

Part of tracking issue storytold/craft-libs#319
2026-10-25 17:52:38 -04:00
Dylan M. TaylorandCodebuff 4feccf0abd build: pin craft-update to craft-libs main (495e597) (#2163)
Picks up the update prompt fix that draws its close mark and version pill
without glyphs a font may lack, so the prompt no longer takes the app down
when a bundled face has no ✕ or →.

Part of tracking issue storytold/craft-libs#319

🤖 Generated with Codebuff

Co-authored-by: Codebuff <noreply@codebuff.com>
2026-10-25 17:37:01 -04:00
dotCooCoo 48c4a5e9d5 AREA: an area, perimeter or running total past f64's range is refused
AREA reported an area or perimeter past f64's range as "Area = 0" (the
JSON form returned `"area": null`), and Add area or Subtract area could
push the running total past it, after which every total read
"Total area = 0". In MEASUREGEOM's Volume option, such an area waited
for a height that could never give a volume, so every later area in
Add or Subtract mode waited behind it.

`area` now returns "the area is too large" or "the perimeter is too
large". Typed, AREA echoes the same message instead of the result, and
an area that would carry the total past f64's range echoes "The total
area is too large." and leaves the total as it was. The Volume option
echoes "The area is too large." and does not ask for a height for that
area.
2026-10-25 13:19:12 -07:00
Dylan M. Taylor dfddd13656 build: pin craft-update to craft-libs main (db19c37) (#2161)
Picks up the fix that keeps the close request pending while the app holds unsaved work, the executable-name check in the Windows update helper, and the zip 9 / dirs-free build.

Part of tracking issue storytold/craft-libs#319
2026-10-25 15:33:55 -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
aiongg 7fc9f2756d Every craft-libs crate at craft-libs main a0916808 (#2153)
craft-ui and craft-docpath were pinned at intermediate commits of the egui
0.37 port (craft-libs#268), and the PDF stack at 6e7f285. a0916808 is main
after #268 merged and contains all three.
2026-10-25 14:02:27 -04:00
Dylan M. Taylor 754b609d09 Merge pull request #1992 from storytold/dependabot/cargo/cargo-dependencies-29f2f66057
chore(deps): bump lyon_tessellation from 1.0.22 to 1.0.23 in the cargo-dependencies group across 1 directory
2026-10-25 12:30:03 -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
MarekSpodBiedry d73e379fd2 Autosave no longer writes the old host as recovery when the Block Editor's changes can't be composed (#918) (#980) 2026-10-25 12:25:48 -04:00
MarekSpodBiedry 8343045863 Fix grid index span overflow with a very large view (#920) (#979) 2026-10-25 12:25:44 -04:00
MarekSpodBiedry 2528671106 Cap HATCH spline edge degree at 10 like the SPLINE reader (#923) (#978) 2026-10-25 12:25:41 -04:00
MarekSpodBiedry 3f92d57b79 Geometry: circle intersection judges tangency against the smaller circle, so a small circle crossing a huge one gives two points (#926) (#976) 2026-10-25 12:25:38 -04:00
MarekSpodBiedry c3c455fbbf Region: test very long segments against every segment so no crossing is missed (#929) (#975) 2026-10-25 12:25:34 -04:00
MarekSpodBiedry d0a1ea5268 Table commands reject a malformed handle instead of using the selected table (#934) (#970) 2026-10-25 12:25:31 -04:00
2994b52e03 Read dynamic block element tags from the entity's own xdata (#942) (#967)
* Read dynamic block element tags from the entity's own xdata (#942)

* dynblock test: an untagged object at index 1 is keyed 1_000_001

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:28 -04:00
0de051049c Keep associative-array parameters when an xref is loaded (#943) (#966)
* Keep associative-array parameters when an xref is loaded (#943)

* xref load test: initialise Ctx's new bytes counter

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:24 -04:00
cddf09beb2 DXF/DWG: read the drawing's WIPEOUTFRAME from its WIPEOUTVARIABLES object (#908)
AutoCAD stores the wipeout frame setting in the drawing's WIPEOUTVARIABLES object (group 70:
0 = frames hidden). CADCraft ignored it and always showed the frames (the default of 1), so
masks such as the diamond inside a numbered circle were outlined. The flag is now read into
the WIPEOUTFRAME setting.

Co-authored-by: simanov <asimfali@mail.ru>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 12:25:21 -04:00
1c85d339c3 Reject negative or out-of-range minutes/seconds in D'" angles (#2116)
Found while testing crates/engine/src/units.rs:333-340 (parse_dms)

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-25 12:15:42 -04:00
63a5569970 In-place text editing accepts IME composition and commit (#1905)
* ui: accept IME composition and commit in the command line (#1761)

The command line is a custom buffer with keyboard focus staying on the
drawing, so no egui TextEdit ever owned the IME: egui never published an
IME output, winit never called set_ime_allowed(true), and a Windows IME
(Microsoft Pinyin, Japanese, Korean) could not even switch on. Even when
IME events arrived they were ignored, so in-place single-line TEXT edits
(and every other text prompt) accepted ASCII only.

- Publish an IME output (command-line rect and caret) while a prompt asks
  for text, enabling the OS IME; release it when the prompt no longer
  collects text, so ASCII typing and shortcuts keep their behaviour.
- Handle Event::Ime: Preedit is drawn as an underlined composition after
  the buffer but never saved; Commit inserts the committed string once.
  Newline/Return commits are ignored like egui's own text fields, and
  Enter/Escape/submit clear any composition.

Tests: a headless command-line test asserts the IME is enabled for a
TEXTEDIT prompt, the preedit stays out of the buffer, the commit lands
exactly once and the text entity changes; a kittest double-click test
edits TEXT in place with IME preedit and commit.

* cmdline IME: build the IMEOutput with egui 0.37's constructor and a context generation

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:13:56 -04:00
Nicholas Gower 5acec7d0be PLOTWINDOW: pick both corners even with ORTHO on (#1893)
* PLOTWINDOW: pick both corners even with ORTHO on

PLOTWINDOW's opposite-corner prompt used Prompt::base, so the canvas bent
the picked point onto the nearest axis with ORTHOMODE on: the second corner
landed above/beside the first, the window had no width or height, and
set_plot_window refused it. The prompt now uses Prompt::corner, the flag
ZOOM Window, RECTANG and ARRAYRECT already use to keep a rectangle's
opposite corner free of ortho. The first corner sets the base as before, so
the rubber band and direct distance entry still work.

Regression test drives the interactive machine through snap_cursor with
ORTHOMODE on, picks two non-axis-aligned corners and checks the stored
window keeps both extents; an ordinary LINE prompt still follows ortho.

Fixes #1641

* docs: document the functions touched by #1641
2026-10-25 12:13:53 -04:00
Jonasandcloudj 8f7421737d fix(ui): keep layout tabs stable when toggling Tool Sets (#2008)
Co-authored-by: cloudj <jonbahnes@gmail.com>
2026-10-25 12:13:49 -04:00
skylar taylor-barrickandskylartaylor 422d42411a fix: omit empty outlines in complex text shaping (#1960)
* fix: filter empty outlines in complex shaping

* docs: describe complex shaping and its empty-glyph regression

---------

Co-authored-by: skylartaylor <26588250+skylartaylor@users.noreply.github.com>
2026-10-25 12:13:45 -04:00
Patrik LermonandClaude Opus 5.5 0c15f7106c i18n(sv): translate 49 new strings (#2120)
Brings the Swedish catalog up to date with upstream main (e86d895).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 12:13:42 -04:00
NetterSilverandClaude Haiku 5.5 31de470926 Fall back to decimal for huge engineering-unit distances (#2114)
Found while testing crates/engine/src/units.rs:18-27 (format_distance, LUNITS 3 branch)

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-25 12:13:39 -04:00
Marco Arnulfo 0f60269868 MCP: resend once when the app refuses a token read before --launch opened it (fixes #2087) (#2098) 2026-10-25 12:13:35 -04:00
Marco Arnulfo 3e6e1e534d Constraints: an ellipse solution stored with its axes swapped or mirrored must still meet every constraint (an axis end by index does not) (#2082) 2026-10-25 12:13:31 -04:00
NetterSilverandClaude Haiku 5.5 52aae9c1fb Fix wcmatch returning nil for patterns with many '*' wildcards (#2078)
Found while testing crates/engine/src/lisp/builtins.rs:798-800 (glob_inner depth limit; depth is incremented per '*' at line 808)

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-25 12:13:28 -04:00
Marco Arnulfo a89be5f2dd A Fix on a derived point (a line's midpoint, an arc's end) holds the position it had before the edit (#2077) 2026-10-25 12:13:25 -04:00
NetterSilverandClaude Haiku 5.5 a4a3a342a0 Fix format_angle zeroing radians near a full turn at low precision (#2073)
Found while testing crates/engine/src/units.rs:124-127 (wrap_full_turn, used at line 106 for AUNITS 3 and at lines 105 and 120)

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-25 12:13:21 -04:00
Marco Arnulfo 36fcc896f7 GETVAR CECOLOR reads back as text, as in AutoCAD (follow-up to #2014) (#2069)
CECOLOR now reads "BYLAYER", "BYBLOCK", the index as text ("1" after
"red") or "RGB:r,g,b" for a true colour, from getvar, LISP and the
SETVAR ? list, where it was the ACI number (256, 0, 1, or the nearest
index of a true colour). The header still keeps the index and
CECOLOR_RGB, so DXF output and new objects are unchanged.

SETVAR CECOLOR takes "RGB:r,g,b" too, so a value saved with getvar
can be set back, true colour included.
2026-10-25 12:13:18 -04:00
NetterSilverandClaude Haiku 5.5 a0a691e1f7 Reject leading '+' in hex handle and colour parsing (#2067)
Found while testing crates/doc/src/entity.rs:21

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-25 12:13:14 -04:00
NetterSilverandClaude Haiku 5.5 097c53bee3 Ignore last vertex bulge in open Polyline::centroid (#2053)
Found while testing crates/geom/src/curve.rs:568-581 (Polyline::centroid); compare area() at 533-538

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-25 12:13:11 -04:00
NetterSilverandClaude Haiku 5.5 5ec3a18aa7 Escape literal \U+ and \M+ backslashes in the DXF writer (#2050)
Found while testing crates/dxf/src/lib.rs:511-524 (escape_unicode) and 226-258 (unescape_unicode, applied in parse_ascii_with_limit at 278)

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-25 12:13:07 -04:00
Dylan M. TaylorandClaude Sonnet 5.5 b37b7ceb7b refactor: replace wildcard imports with explicit ones (S2208, batch_09) (#2046)
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 12:13:04 -04:00
aiongg 1d9ddd2406 "Save changes?" and Drawing Recovery appear at once (#2144)
An egui modal showed a frame late (its first frame only measured it) and
then faded in, dim included, over 0.2 s. These alerts answer a key press or
menu choice, so they now skip the fade, and their first show reruns the pass
with the measured size, which draws them in the first frame presented.
2026-10-25 12:07:54 -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
aiongg bbbd9eec51 craft-pdf and its PDF backends back at craft-libs 6e7f285 (#2147)
The egui 0.37 migration moved craft-pdf, krilla, hayro-syntax and
hayro-jbig2 to 7895f5e, 206 commits older, though none of them depends on
egui. 6e7f285 keeps krilla patch 13 (non-finite link and page-box geometry
refused), craft-libs#282 (URI links only for http(s) and mailto) and
craft-libs#255 (CCITT and JPEG 2000 decode bounds). The embedded ICC
profiles are identical at both revisions.
2026-10-25 12:06:35 -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
Brandon ThomasandClaude Opus 5.5 1fcf654ea8 Release 0.13.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 09:26:26 -04:00
aiongg e86d895ba1 Merge pull request #2115 from aiongg/dotcoocoo-followups
Batch follow-ups: #2049's later commits, CUT on locked layers, BLOCKREPLACE, THICKNESS and Volume prompts
2026-10-25 01:17:57 -04:00
aiongg c6c3aefe3f COMPAREEXPORT: a dimension whose block can't be copied is drawn again in the comparison color; Volume's second point says it needs a point; comments say only dimensions leave anonymous block names out 2026-10-25 05:06:46 +00:00
aiongg b34477d580 cargo fmt 2026-10-25 04:41:25 +00:00
aiongg 3d7e298b7d gaps.md: F7 notes the DIMTFILL 1 and true-color DIMTFILLCLR limits of written files 2026-10-25 04:26:38 +00:00
aiongg 23d44cd9c8 MEASUREGEOM Volume: the height can be given by two points, and Enter at the height prompt says a height is required 2026-10-25 04:26:14 +00:00
aiongg 8296bbc642 THICKNESS: a typed value out of range is reported as such and the prompt asks again 2026-10-25 04:22:57 +00:00
aiongg b90d68ec34 BLOCKREPLACE: a dynamic block or a replacement that references the block is refused when its name is given, and the prompt asks again 2026-10-25 04:17:52 +00:00
aiongg c42763ba0f CUTCLIP and CUTBASE leave the clipboard alone when every object is on a locked layer, and say how many were locked 2026-10-25 04:12:57 +00:00
dotCooCoo 57f828d04c Blocks: Modify > Object > Block Description..., and the Block Editor saves a changed description
No command changed an existing block's description; it could only be
given when the block was made or redefined (BLOCK, -BLOCK, entmake).
blocks.description (Modify > Object > Block Description...) changes
it: the block is the one the Block Editor edits, else the selected
reference's, else a name typed or a reference picked. In the Block
Editor, Properties with nothing selected shows the block's name and an
editable Description; BSAVE saves the new description with the block,
BCLOSE without saving drops it, and BSAVEAS gives it to the new block
only. A description is one line of up to 255 characters, and BLOCK and
-BLOCK now refuse one that is longer or has a line break.

Catalog rows for the new menu label are added in all 15 languages, and
docs/parity-checklist.md is regenerated with cargo xtask parity.
2026-10-24 21:11:10 -07:00
aiongg 642c3a59a3 COMPAREEXPORT: free block and style names come from a next-index counter per name base instead of a scan from 1 2026-10-25 04:04:14 +00:00
aiongg 53049d82e0 COMPAREEXPORT: keep #2049's *D copies of dimension blocks 2026-10-25 03:58:15 +00:00
aiongg e23a362c54 Merge storytold/cadcraft#2049 (commits after the batch) 2026-10-25 03:57:25 +00:00
aiongg f46a220375 Merge pull request #2112 from aiongg/dotcoocoo-batch
Merge 11 open PRs (tested batch)
2026-10-24 23:56:07 -04:00
aiongg 8171c9be17 DIMTFILL and DIMTFILLCLR are written in DIMSTYLE only when set; COMPAREEXPORT redraws dimensions instead of copying their blocks, and gaps.md notes its groups 2026-10-25 03:43:34 +00:00
dotCooCoo 4dbcbfdaaa COMPAREEXPORT: dimension block copies stay *D blocks, and image paths count in block reuse
A dimension's block took a recolored copy named COMPARE_<tag>_D1, and
the DXF writer redraws a dimension whose block is not a *D block, so
saving the snapshot replaced geometry read from a file. The copy now
gets a free *D name.

A named block both drawings define was reused when its images and
data links had the same relative paths, even when they were relative
to different folders. These paths are now taken from each drawing's
folder before the blocks are compared.
2026-10-24 19:37:44 -07:00
aiongg 429554b389 COMPAREEXPORT: tests move into the engine's it suite (the hook test joins tests/compare.rs); the copy drops constraint display exceptions for objects it doesn't hold 2026-10-25 02:35:48 +00:00
aiongg 17952af3fb Regenerate the parity checklist for the batch 2026-10-25 02:33:36 +00:00
aiongg 1b0cefce9c Merge storytold/cadcraft#2049
# Conflicts:
#	docs/gaps.md
2026-10-25 02:23:03 +00:00
aiongg e1cab10d01 Merge storytold/cadcraft#2089
# Conflicts:
#	crates/engine/src/cmd/utility.rs
2026-10-25 02:22:50 +00:00
aiongg 05cad7db49 Merge storytold/cadcraft#2085
# Conflicts:
#	crates/engine/src/cmd/utility.rs
#	crates/engine/tests/it/inquiry_keywords.rs
2026-10-25 02:22:40 +00:00
aiongg 4e73fb5bb3 Merge storytold/cadcraft#2079
# Conflicts:
#	docs/parity-checklist.md
2026-10-25 02:22:16 +00:00
aiongg 84e3478863 Merge storytold/cadcraft#2071
# Conflicts:
#	docs/parity-checklist.md
2026-10-25 02:22:16 +00:00
aiongg fac2d18df6 Merge storytold/cadcraft#2065
# Conflicts:
#	docs/parity-checklist.md
2026-10-25 02:22:16 +00:00
aiongg 23a0bd4fdb Merge storytold/cadcraft#2054
# Conflicts:
#	docs/parity-checklist.md
2026-10-25 02:22:16 +00:00
aiongg 75c54ce35d Merge storytold/cadcraft#2056 2026-10-25 02:21:58 +00:00
aiongg 313fab8827 Merge storytold/cadcraft#2101 2026-10-25 02:21:58 +00:00
aiongg bda4a9924c Merge storytold/cadcraft#2106 2026-10-25 02:21:58 +00:00
aiongg 3e64cc5d06 Merge storytold/cadcraft#2105 2026-10-25 02:21:58 +00:00
dotCooCoo 20087b4b5f COMPAREEXPORT: multiline attributes take the comparison color too
A multiline attribute is drawn from its own multiline text, so an
inline color code in it overrode the comparison color set on the
attribute: an unchanged attribute could stay red, or a removed one
green. The export now takes the inline colors out of the multiline
text of a reference's attributes and of attribute definitions in the
recolored blocks, as it does for MTEXT and multileader text.
2026-10-24 19:16:04 -07:00
dotCooCoo 5685085add COMPAREEXPORT: a block whose objects are hidden or thicker in the other drawing is copied as PART_2
The comparison of a named block both drawings define counted each
object's geometry, layer, color, linetype, lineweight and linetype
scale. A block whose line was hidden, more transparent, thicker or
extruded another way in the other drawing counted as the same, so the
other drawing's references drew with this drawing's definition. These
properties now count as well.
2026-10-24 18:52:56 -07:00
dotCooCoo 32be8a655e COMPAREEXPORT: a dimension block with another generated name does not make PART differ
A named block such as PART was compared with the other drawing's by the
blocks it draws, including a dimension's block. That block's generated
name (`*D1` here, `*D3` there) was part of its signature, so the same
PART counted as different and was copied as PART_2 with a copy of the
dimension block. The comparison now leaves out an anonymous block's
name and compares its contents.
2026-10-24 18:43:16 -07: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
dotCooCoo 1271b06b02 Refuse negative values for whole-number DIM* variables
DimStyle::apply_fields rounded a value for a whole-number field such as
DIMDEC or DIMTMOVE and clamped it to 0..255, so DIMDEC -1 became 0 and
DIMALTD "300" became 255. DIMSTYLE, DIMOVERRIDE and the SETVAR overrides
that new dimensions take all accepted these values.

A value below 0 or one that rounds above 255 is now refused, as an
out-of-range value is. DIMADEC still takes -1. A lineweight given as
-1, -2 or -3 (ByLayer, ByBlock, Default) is no longer read as 0 when
the style's lineweight is positive, and a lineweight above 211 given as
text is refused instead of read as 211.
2026-10-24 16:34:50 -07:00
dotCooCoo 6dc336160d SETVAR refuses DIM* values the dimension style refuses
SETVAR wrote a DIM* variable to the drawing header without checking it,
so values such as DIMTMOVE 3, DIMATFIT 7 or DIMLWD 500 were accepted.
New dimensions then ignored them, because the style override goes
through DimStyle::apply_fields, which refuses them.

SETVAR now runs the value through apply_fields on a copy of the current
dimension style, as DIMSTYLE and DIMOVERRIDE do, and reports an invalid
value instead of writing it.
2026-10-24 15:22:26 -07:00
dotCooCoo 8a64e42100 DIMTFILL: dimension text gets a background in the drawing's background color or in DIMTFILLCLR
DIMTFILL and DIMTFILLCLR were not modeled, so a dimension's text never had a
background and lines under it stayed visible. Dimension styles now carry both
variables. DIMTFILL 1 hides what is drawn under the text in the drawing's
background color, and DIMTFILL 2 fills the box in DIMTFILLCLR. The box is the
text box widened by DIMGAP on every side. The arc length symbol over the text
and the DIMINSPECT frame are drawn over the background.

The variables work with SETVAR, DIMOVERRIDE and the style fields. DXF stores
them as DIMSTYLE groups 69 and 70 from R2007 and in the style's CADCRAFT
xdata in older files; the reader skips the record's flags, which also use 70.
For DIMTFILL 2, the dimension's *D block and EXPLODE add a SOLID in
DIMTFILLCLR under the text. A dimension drawn from the *D block it was read
with gets a background around each of the block's texts, before the block's
first text or first segment of the text's marks, except for a text that a
SOLID of the block already covers. The background stays after the drawing is
saved and reopened.
2026-10-24 14:11:01 -07:00
aiongg fcfdf33001 Merge pull request #1556 from silaghis/fix/stretch-ortho-window
Keep rectangle opposite corners free of ortho (STRETCH window and friends)
2026-10-24 16:15:54 -04:00
dotCooCoo 898b539630 COMPAREEXPORT: a block left out at the block limit is reported when this drawing's differs
Past the limit on blocks copied, a block of the other drawing that this
drawing also defines was skipped without a note, and the red group drew
this drawing's definition even when the two differed. Such a block now
counts as left out unless both drawings' blocks draw the same thing, so
the export says that some blocks were too many to copy.
2026-10-24 11:50:33 -07:00
dotCooCoo bfa30e2192 MEASUREGEOM: Tools > Inquiry > Radius asks for the object, and Radius measures polyline arcs
Tools > Inquiry > Radius had no prompts, so with nothing selected it
stopped with "`handle` is required". It now asks "Select arc or circle"
and reports the radius and diameter of the arc or circle picked. A
selected arc or circle is still measured at once.

MEASUREGEOM's Radius option and Tools > Inquiry > Radius also measure
the arc segment of a polyline under the pick. radius.measure takes an
optional `point`, and for a polyline it measures the arc segment nearest
that point.
2026-10-24 11:34:08 -07:00
dotCooCoo 432cbfb106 COMPAREEXPORT: a named block whose dimension draws another block is copied as PART_2
A named block that holds a dimension was kept as this drawing's
definition when only the block the dimension draws differed between the
drawings, so the red group drew this drawing's dimension graphics. The
export's block comparison now covers a dimension's block, as it covers a
multileader's.
2026-10-24 11:24:50 -07:00
dotCooCoo 596ea36465 MEASUREGEOM: malformed points and volumes past f64's range are errors
AREA and Volume measured the selected object when `points` was given but
was not a list of points; they now report an error, and only an absent
or null `points` measures the selection. A volume too large for f64
(an area times a height) is an error in the JSON form. At the typed
prompt, a volume or an Add/Subtract volume running total too large for
f64 makes the prompt say "The volume is too large." and ask for the
height again, where it reported "Volume = 0" or "Total volume = 0".
2026-10-24 11:11:51 -07:00
dotCooCoo dc081c6f7f MEASUREGEOM: add an Angle option, and Tools > Inquiry > Angle asks for the objects
MEASUREGEOM typed at the command line offered Distance, Radius and ARea,
and Tools > Inquiry > Angle ran without a prompt, so it failed asking for
{vertex,p1,p2} or {h1,h2}. MEASUREGEOM now has an Angle option, and it
and Tools > Inquiry > Angle ask "Select arc, circle, line <specify
vertex>". An arc reports its included angle. A circle takes the pick as
the first endpoint, uses the center as the vertex, and asks for the
second endpoint. Two lines or polyline line segments report the angle
where the lines meet, extended. Enter asks for a vertex and two
endpoints. The JSON form of angle.measure is unchanged.
2026-10-24 09:30:38 -07:00
dotCooCoo 746942613c CUTBASE: a base point is required, and a handle listed twice is cut once
cutbase and copybase now fail with "`base` ([x, y]) is required" when
`base` is missing or not a point, as their parameters say, instead of
taking the lower-left corner of the objects. cutbase checks it before
erasing anything.

Copy, Copy with Base Point, Cut and Cut with Base Point put an object on
the clipboard once however often `handles` lists it, so the counts they
return match the objects copied or cut.
2026-10-24 09:00:24 -07:00
dotCooCoo 1b8be0b77d COMPAREEXPORT: a named block the other drawing defines differently is copied as PART_2
A block reference of the other drawing whose named block exists in this
drawing with other contents drew this drawing's definition in the red
group. The export now keeps this drawing's block only when both
drawings' blocks draw the same thing: the same objects, base point,
units, annotative and overlay flags and xref file, in the block and in
the blocks its references and multileaders use. Otherwise the other
drawing's block is copied under a free name (PART_2, PART_3, ...) and
its references point at the copy.
2026-10-24 08:26:46 -07:00
dotCooCoo 7fe832b284 MEASUREGEOM: a Volume option, and Tools > Inquiry > Volume
MEASUREGEOM offered Distance, Radius and ARea, and Tools > Inquiry had
no Volume item. The Volume option measures a polygon of three or more
points, or a closed 2D object as AREA measures it, and asks for a
height. It reports the area times the absolute value of the height.
Add volume and Subtract volume keep a running total, as AREA's Add
area and Subtract area do. Tools > Inquiry > Volume (volume.measure)
starts the same prompts, and the JSON form takes points or a handle
and a height.

Engineering and architectural units show the volume as a decimal
number. The de, fr and ru catalogs get a "Volume" row, and
docs/parity-checklist.md is regenerated with cargo xtask parity.
2026-10-24 08:09:26 -07:00
dotCooCoo 1fcdfc87b4 ATTDISP: View > Attribute Display > Normal, On and Off, and 'ATTDISP inside another command
ATTDISP had no menu entry. View > Attribute Display now has Normal, On
and Off, which set ATTMODE to 1, 2 and 0 as ATTDISP's options do. ATTDISP
can also be typed as 'ATTDISP while another command runs, and that
command resumes afterward.

The eight catalogs without a "Normal" row get one, and
docs/parity-checklist.md is regenerated with cargo xtask parity.
2026-10-24 07:04:43 -07:00
dotCooCoo 5a82f090df BLOCKREPLACE: Tools > Express Tools > Replace Block replaces every reference of one block with another
BLOCKREPLACE (and -BLOCKREPLACE) turns every reference of one block, in
model space, the layouts and other blocks, into a reference of another
block at the same insertion point, scale and rotation. Each new
reference carries the replacement's attributes, with the values of the
tags both blocks define, and is annotative when the replacement is.
The command then asks whether to purge the replaced definition, and
removes it only when no dimension style names it and no block
reference, dimension or multileader in the drawing or in another block
definition does.

The command refuses a replacement that references the block it
replaces, a dynamic block as the block to replace, and blocks that
come from an xref. It does not run while the Block Editor or REFEDIT
is open. The ATTSYNC helpers in blocks/attribs.rs walk the references
and build the new attributes.

The self-reference check that BLOCK, INSERT of a file, REFCLOSE and
Lisp block creation share now also follows dimension blocks and
multileader block content, so BLOCK no longer accepts a redefinition
of A from a multileader whose content is A.

docs/parity-checklist.md is regenerated.
2026-10-24 05:57:38 -07:00
aiongg 41d9b5e957 PDF: plot with craft-pdf's krilla writer (#2029)
* Depend on craft-pdf (krilla writer, reader for tests) at craft-libs 9f75d3dc

The root patches pin the patched krilla and hayro-syntax/hayro-jbig2 to
the same revision, as craft-pdf requires.

* Plot PDFs with craft-pdf's krilla writer

The hand-written object, xref, stream and font writer goes. Each sheet's
display list is built, written as its page and dropped before the next.
Strokes with the same colour, width and transparency stay one path, and
each filled primitive is one path of its triangles. Text in embeddable
TrueType fonts is drawn as glyphs; krilla subsets the fonts and writes
their ToUnicode maps, so the hand-written subsetter goes too and
cadcraft-fonts keeps only the fsType embedding check. Primitives with a
point that is not finite or beyond 1e7 pt are left out. A font krilla
cannot embed after all makes the export retry with text as outlines.

Optional content (PDF layers) is not written until craft-libs' writer
supports it: the layer transitions go through one empty function, layers
that are off are not plotted, and the layer tests are ignored.

The tests read the PDF back with craft-pdf's reader.

* Pin craft-pdf and its krilla and hayro patches to craft-libs cd160e73

cd160e73 adds optional content groups to the patched krilla.

* Write drawing layers as PDF optional content groups

Each drawing layer a page draws on gets one group, named after the layer
and shared by all pages, created before the first page that uses it. A
group starts hidden when its layer is off in the drawing, so `offLayers`
again plots layers that are off on hidden PDF layers. At most 4096
groups per file; objects on further layers print unmarked. A single
sheet with groups opens with the viewer's layer panel (/PageMode /UseOC).
Layer changes end and start the marked content, and the last mark ends
before the page finishes.

The layer and text tests that check the groups run again, and a test
covers the group cap.

* Plot a zero-length line as a round dot in PDFs

krilla leaves out a path without extent, so a lone zero-length line drew
nothing. It is filled as the dot its round-capped stroke would make, as
wide as the stroke, with 0.25 pt for a hairline.

* PDF: retry as outlines when the writer fails inside a font; cap layer names; restore two test checks

* Tests: read plotted PDFs back through the shared reader in plot_layout_scale and plot_styles

* Pin craft-pdf, krilla, hayro-syntax and hayro-jbig2 to craft-libs main (7895f5e)

* PDF tests: read path coordinates in page space; cover the font retry and out-of-range points

- pdf_read maps path operands through the CTM (`page_ops`, used by `drawn` and
  `clip_rect`) and checks that the plot area is drawn in page space.
- The 1:1 layout test finds the viewport corner at (72, 72) in page space, so a
  mirrored page fails it.
- A font with a short hmtx passes the writer's font checks but fails subsetting;
  the plot retries and draws its text as outlines.
- A primitive with a point past 1e7 pt is left out, and the rest still plots.

* PDF: clip a primitive with a far vertex to the plot area instead of leaving it out

A polyline or fill with a point past the writer's f32 range now keeps the
parts inside the plot area (polylines with the area widened by the stroke
width), each piece its own subpath. Primitives in range are written as before.
The test reader requires a decodable content stream.
2026-10-24 08:38:46 -04:00
dotCooCoo 6fe715e11c THICKNESS: Format > Thickness, and new 2D objects take the current thickness
New lines, arcs, circles, points and lightweight polylines (for example
PLINE, POLYGON, DONUT and BOUNDARY) now take the THICKNESS system
variable as their 3D thickness. THICKNESS could already be set with
SETVAR and with the Properties palette when nothing is selected, but new
objects were always created with a thickness of 0, apart from RECTANG's
own Thickness option. RECTANG keeps that option, and single-line text
does not take THICKNESS, as in AutoCAD.

The new THICKNESS command is on Format > Thickness. Typed or picked from
the menu, it asks "Enter new value for THICKNESS <0.0000>:"; Enter keeps
the current value, and a negative value is accepted.
docs/parity-checklist.md is regenerated.
2026-10-24 03:24:13 -07:00
dotCooCoo 028b98d71c CUTBASE: cut objects to the clipboard with a base point
Edit > Cut with Base Point asks for a base point, then for the objects,
copies them to the clipboard with that base point and erases them, as
COPYBASE does without the erase. A pickfirst selection skips the object
prompt. Objects on locked layers stay, as with CUTCLIP, which now shares
the cut helper. The JSON form takes {handles?, base: [x,y]}.

Catalog rows for the new menu label are added in all 15 languages, and
docs/parity-checklist.md is regenerated with cargo xtask parity.
2026-10-24 02:29:26 -07:00
Dylan M. TaylorandClaude Sonnet 5.5 b9f96d3dd3 refactor: replace wildcard imports in two test modules (S2208) (#2036)
* refactor: replace wildcard imports in two test modules (S2208)

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

* style: fmt

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 04:49:51 -04:00
dotCooCoo 39e9b62829 Contributors: add dotCooCoo (Robert Lee) 2026-10-24 01:36:29 -07:00
dotCooCoo 4aed7a2267 COMPAREEXPORT: export a drawing comparison to a new drawing
COMPAREEXPORT compares the two drawings again and opens the result as a
new untitled drawing, compare_<this> vs <other>.dwg. Model space holds
the other drawing's removed and changed objects as group
COMPARE_COMPARED and the compared tab's objects as group
COMPARE_CURRENT, in the overlay's colors: green for objects only in
this drawing or changed in it, red for the other drawing's, gray for
objects in both.

Block references, dimensions and multileaders refer to recolored copies
of their blocks, tables and tolerances use recolored copies of their
styles, and inline MTEXT and cell colors, dimension style colors,
multiline element colors and gradients follow the object. The new
drawing takes the layers and styles of the other drawing that this
drawing lacks, and the blocks its exported objects refer to; an
anonymous block whose name is taken is copied under a free name. The
comparison bar gets an Export button.
2026-10-24 01:36:29 -07: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 e59195edb8 usage: a read-only cross-reference of layers, blocks, styles and linetypes (#1889)
* usage: a read-only cross-reference of layers, blocks, styles and linetypes

USAGE reports, for every layer, block, text style and linetype, how many
objects use it in model space and the layouts, how many inside block
definitions, whether it is used only inside block definitions, and whether
it is empty or unused. Its unused lists are PURGE's own scan (candidates and
block reachability), shared read-only; PURGE's behaviour is unchanged.

The JSON form takes {type?, limit?, offset?}, returns schema 1 with a paged
items list, per-kind summary and PURGE's unused names in table order.

Fixes #1439

* docs: document the functions touched by #1439

* usage: mark a scan cut at its limit as incomplete (#1439)

USAGE's one pass over model space, the layouts and the block definitions was
capped at 5,000,000 objects with `take`, so a larger drawing silently omitted
later references while the counts, statuses, summary and message looked
complete — an unused layer or block could be reported as used, or vice versa.

The limit is now `MAX_SCAN`, and `Scan::new` takes it as a parameter: it counts
the drawing's objects first and records an `Incomplete { scanned, total }` when
the limit cuts the scan short. The report carries `incomplete: true` and an
`incompleteReason` naming both counts, and the message ends with a note, so a
partial report can never be mistaken for a complete one. A drawing holding
exactly the limit is still complete.

PURGE's scan limit and candidate behaviour are untouched; USAGE only reads
PURGE's unused lists as before.

Tests: unit tests cut a small drawing at a small limit (a 5,000,000-object
drawing is impractical) and check the boundary and the miscount the signal
prevents; the integration test checks a complete drawing says so. Also adds the
missing Ukrainian translation for the command label, which the i18n gate
requires.

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-24 02:04:10 -04:00
Brandon ThomasandClaude Opus 5.5 5e39af933f test(autosave): fix a flake: the owner-only folder test uses its own instance id
Two tests that start in the same millisecond got the same instance_id(), so this test's
shutdown() removed the other test's lock from the process-wide SAVEFILEPATH (after #1868).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 02:02:53 -04:00
c66f6f45b8 Reject bare or signed second token in mixed distances (#2011)
Found while testing crates/engine/src/units.rs:176-179 (parse_mixed)

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-24 01:59:50 -04:00
b062030a03 Reject a leading '+' in ACI indices and RGB triples in Color::parse (#1878)
Found while testing crates/color/src/lib.rs:115 and 126

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-24 01:59:45 -04:00
Nicholas GowerandBrandon Thomas 7aa952b8da DWG: write AutoCAD's drawing variable dictionary (AC1018 output) (#1872)
* DWG: write AutoCAD's drawing variable dictionary (fixes #1813)

A 2004 (AC1018) DWG of a drawing without an AcDbVariableDictionary is read as
empty by Vectric Aspire 10.5, though the same drawing as DXF imports. AutoCAD
always writes the dictionary; CADCraft's and ezdxf's DXF writers don't.

The DWG bridge now adds it, with AutoCAD's usual variables, when the drawing
has none, for DWG versions before 2018 (AC1032 output reads fine without it).
A dictionary the drawing already has keeps its entries and values.

* docs: document the functions touched by #1813

* DWG: keep the drawing's DIMASSOC and HIDETEXT in the variable dictionary

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-24 01:59:40 -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
Nicholas GowerandBrandon Thomas 9f2060d674 analysis: report found/analysed/skipped counts, caps and implausibility (#1890)
* analysis: report found/analyzed/skipped, caps and implausible values (fixes #1440)

* docs: document the functions touched by #1440

Add doc comments to the self-check helpers, the COUNT/SHAPES/MASSPROP entry points and helpers, and the tests and fixtures the analysis self-check change touches, so CodeRabbit's docstring coverage check passes.

* analysis: tolerate rounding past a bound, and skip area checks without extents (#1440)

The self-check's implausible() compared value > bound exactly, so a border
rectangle whose shoelace area equals the extents' width * height could be
flagged by one ulp of rounding. Allow a 1e-9 relative slack past the bound
(the roundings of two ways of measuring the same thing), while a clearly
larger value is still flagged.

MASSPROP and SHAPES fell back to an extents area of 0 when nothing visible
gave the drawing extents, so every region or shape with area got a false
"area is larger than the drawing extents" flag. Pass f64::INFINITY instead:
implausible() already skips non-finite bounds, because no bound applies.

Tests: one ulp past the bound is not flagged while 1e-6 past is; an
extents-filling rectangle is not flagged; invisible-only masses/shapes with
no visible extents are not flagged.

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-24 01:56:51 -04:00
9fc626a33b MCP: the open tool description names DWG as well as DXF (#1887)
* MCP: the open tool description names DWG as well as DXF

The open tool reads both DXF and DWG through the io layer, but
tools/list advertised it as "Open a DXF file." so clients reading
the descriptions would not try a DWG. Say "Open a DXF or DWG file."

Adds a regression test that finds open in the tools/list reply and
asserts its description still mentions DXF and now mentions DWG.

Fixes #1875

* contributors: credit nicholasgower by GitHub profile name

* docs: document the functions touched by #1875

---------

Co-authored-by: nicholasgower <nicholasgower@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-24 01:56:34 -04:00
fef32761af DXF/DWG: angular, diametric and radial dimensions get DIMASSOC (#1651) (#1874)
* DXF/DWG: angular, diametric and radial dimensions get DIMASSOC (#1651)

std_assoc_refs had slots only for linear/aligned and ordinate dimensions, so
every other associative kind was saved without a DIMASSOC object: AutoCAD
did not follow angular, diameter or radius dimensions, although CADCraft
itself did through its xdata.

Write the references AutoCAD writes for each kind: both lines of an angular
dimension (4 point references; the 3-point kind its three definition points)
and, for radial and diametric dimensions, the single centre reference to the
circle or arc (associativity flag 2), which is how AutoCAD files store them.
Read the same references back, with the circle's definition points
reconstructed, so files from other writers associate those kinds too.

Curve start references now use osnap 13 as AutoCAD writes them (1 is the
end), which also lets a stored point of 0,0,0 resolve to the right end.

Tests: the issue's commands through the engine, and a DXF/DWG round trip
with CADCraft's xdata stripped so only the standard objects remain.

* docs: document the functions touched by #1651

* DXF read: a line's osnap 1 with a stored point still picks the nearer end (other writers)

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-24 01:56:17 -04:00
Brandon ThomasandClaude Opus 5.5 9562d951df contributors: repair people.toml entries for ffishman, aussieboer61 and nicholasgower (merge of #1735/#1792/#1863)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 01:55:51 -04:00
f05fa9cec3 DWG read: keep a polyline's vertex order when handles are not ascending (#1863)
* DWG read: keep a polyline's vertex order when handles are not ascending (fixes #1856)

acadrust 0.6.4 assembled a polyline's VERTEX records in handle order and discarded the polyline's owned vertex-handle list, so a vertex inserted into an existing polyline (and therefore given a higher handle) moved to the end of the outline. 0.6.5 reads the owned list and orders each polyline's vertices by it.

Adds a regression test that writes a DWG whose second 3D-polyline vertex has the highest handle, reads it through the DWG bridge, and checks the order for AC1018 and AC1032.

* contributors: credit @nicholasgower by GitHub profile name

* docs: document the functions touched by #1856

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 01:55:23 -04:00
73e7486dc9 DXF: proxy objects and entities keep their class through a save (#1812) (#1858)
* dxf: proxy objects and entities keep their class through a save (#1812)

A proxy (ACAD_PROXY_OBJECT, ACAD_PROXY_ENTITY) names its class by number
in group 91: 500 plus the class's place in the CLASSES section (DXF
Reference). Saving rewrites CLASSES (CADCraft's own classes, then the kept
ones), but kept proxies were written with their read number, which then
named another class: an R14 file's visual styles came back as SCALE,
TABLESTYLE or MLEADERSTYLE, and numbers past the last class stayed.

The reader now keeps the file's class order (Foreign::class_order) when
the file has proxies, and the CLASS records its proxies name. The writer
renumbers each kept proxy to its class's place in the written CLASSES and
drops a proxy whose number names no class (with what it owns and its
dictionary entry). The DWG bridge writes through the same path.

Test proxies_keep_their_class_when_classes_are_rewritten (a file with
VISUALSTYLE and WIDGETX first in CLASSES, proxies of both and of a number
past the last class, and a proxy entity; also through a second save).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Nnsfu3QGqjD1aFZbBgUWP

* docs: the proxy class fix is for DXF save (#1812)

From the local CodeRabbit review: DWG save drops proxy entities (acadrust)
and proxy objects through DWG weren't checked, so the progress log says DXF.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Nnsfu3QGqjD1aFZbBgUWP

* dxf: a proxy's class number is found outside 102 groups (#1812)

From the PR review: an application's 102 {NAME ... 102 } group before a
proxy's own groups can hold a group 91, which was read as the class number
and overwritten on save. class_group finds the first 91 outside 102 groups
(before xdata), for reading and renumbering alike (test
proxies_keep_their_class_when_classes_are_rewritten, now with a {MYAPP
group holding a 91 that stays as it was).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Nnsfu3QGqjD1aFZbBgUWP

---------

Co-authored-by: Tyrel Haveman <1434399+tyrel@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-24 01:55:07 -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
7db656fdc7 Write a layout's paper-space viewport (id 1) ahead of its other viewports (#1782)
DWG stores no viewport ids; readers number a layout's viewports in order. A drawing whose draw
order (SORTENTS) put the paper-space viewport behind a model viewport came back from DWG with
that model viewport as the sheet.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-24 01:54:36 -04:00
MarekSpodBiedryandBrandon Thomas 9d9e4c3b0d STRETCH moves only the multiline vertices inside the area (#1712) (#1739)
* STRETCH moves only the multiline vertices inside the area (#1712)

* STRETCH selects a multiline when a crossing area holds one of its vertices (#1712)

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-24 01:54:20 -04:00
c92bb5cdad Close hatch boundaries where a line ends just short of a flattened ellipse (#1690) (#1738)
* Close hatch boundaries where a line ends just short of a flattened ellipse (#1690)

* Bound the hatch endpoint snap scan by segment count and visited segments (#1690)

* test: LINE returns its new objects as `handles`

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-24 01:54:04 -04:00
1c35d7f8c1 Keep the active layout when saving DWG/DXF (#1735)
* Keep the active layout when saving DWG/DXF

The layout a file marks active (block *Paper_Space) was not kept on read, and on write *Paper_Space always went to the first tab, so every save made the first sheet active. Store it as Drawing::active_layout and write it as *Paper_Space again; tab order is unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Match the active layout by name without regard to case

paper_space_layouts and layout.rename now compare the active layout's
name case-insensitively, as Drawing::layout() does, so an active layout
stored as "s2" still resolves the "S2" tab.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Test case-insensitive active layout matching

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: ffishman <20211677+ffishman@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-24 01:53:49 -04:00
BooksandBrandon Thomas a94fc0898b Diameter dimensions: the *D block's text keeps the sign as %%c (fixes #1645) (#1696)
The *D block and EXPLODE's MTEXT took the dimension's decoded text, so
%%c arrived as U+2300, which no SHX font has: other programs plotted a
diameter dimension in an SHX style without its sign. They now write
DimGeometry::mtext_contents(): the sign as %%c, which every font draws
(and CADCraft decodes), a % that would run into a code as %%%.

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-24 01:53:32 -04:00
WillandBrandon Thomas a0dced6d45 fix(render): honor DIMDSEP in angular dimensions (#1670)
Use the effective dimension style's decimal separator when formatting
angular labels, preserving the public formatter's decimal-point default.

Add rendering and stored DXF/DWG block regression tests, and update the
file-format progress documentation.

Fixes #1646.

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-24 01:53:16 -04:00
532c4ccc1c HATCH treats rooms enclosed by multilines as islands (#1642)
A pick inside a multiline wall found the band between the wall's lines
as the outer boundary, but the rooms the inner lines enclose were
dropped as islands: island_curve accepted lines, arcs, polylines and
splines, not multilines. The rooms were filled along with the walls. A
multiline's lines can now make up an island like the other open curves.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-24 01:53:00 -04:00
CapriandGPT-6 Luna 88a73b7618 Render: Match AutoCAD clearance for DIMTAD 1 text (#2004) (#2027)
Co-authored-by: GPT-6 Luna <noreply@openai.com>
2026-10-24 01:52:16 -04:00
NetterSilverandClaude Haiku 5.5 0fc8e5241c Reject surveyor bearing angles outside 0-90 degrees (#2024)
Found while testing crates/engine/src/units.rs:279-294 (bearing, via parse_dms 297-317)

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-24 01:52:11 -04:00
Marco Arnulfo cff2eb7262 Dimensions: text that does not fit follows DIMTMOVE, over the dimension line (with a leader for 1) instead of past the second extension line (#2015)
* Dimensions: text that does not fit follows DIMTMOVE, over the dimension line (with a leader for 1) instead of past the second extension line (fixes #1998)

* Dimensions: tighter check that the line is not carried out to moved text; drop a guard that never changed the outcome (#1998)
2026-10-24 01:52:05 -04:00
Marco Arnulfo ab6bff5e4e SETVAR CECOLOR takes the colour names and indexes the color command takes (fixes #1999) (#2014) 2026-10-24 01:52:00 -04:00
MarekSpodBiedry 1a76e10c20 hatch: list patternFile/patternText in the catalog and point to them from the unknown-pattern error (#2000) (#2007) 2026-10-24 01:51:53 -04:00
Jonasandcloudj 941ea341bc fix(ui): toggle QuickCalc with Ctrl/Cmd+8 (#2001)
Co-authored-by: cloudj <jonbahnes@gmail.com>
2026-10-24 01:51:47 -04:00
Antoine Boxho 71882cf5e6 constraints: bound combined expression and parameter depth (#1959) 2026-10-24 01:51:42 -04:00
64c35cadff Distinguish polyline midpoint grips from vertices (#1950) (#1958)
* ui: distinguish polyline midpoint grips

* docs: the midpoint-grip row belongs in the revision history (CodeRabbit)

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-24 01:51:36 -04:00
MarekSpodBiedry 63dec85749 Let getkword return arbitrary input when initget bit 128 is set (#1945) (#1953) 2026-10-24 01:51:31 -04:00
MarekSpodBiedry 1c3c25035c List binary fractions in the Units precision drop-down for Architectural and Fractional (#1861) (#1952) 2026-10-24 01:51:26 -04:00
2bc3b02ea1 Make LISP angtof honour its units argument (#1949)
* Make LISP angtof honour its units argument

Found while testing crates/engine/src/lisp/drawing.rs:962-966

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

* angtof: a units argument outside 0..=4 gives nil instead of reading degrees (CodeRabbit)

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

---------

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-24 01:51:21 -04:00
Kshitij VarshneyandClaude Opus 5.5 6842b8774b File tabs: stop the close button from flickering (#1914)
* File tabs: the close button no longer flickers under the pointer

An inactive drawing tab drew its X only while the tab was hovered. Once
the pointer reached the X, egui gave the hover to the X, so the next frame
left the X out, the tab got the hover back, and the frame after drew the X
again. The tab background and its X flickered for as long as the pointer
stayed there, and a click could land on the tab instead of the X.

The tab now asks whether the pointer is inside its rectangle
(`Ui::rect_contains_pointer`), which the X does not change, both for the
hover fill and for showing the X.

Regression test: tests/file_tab_close.rs points at an inactive tab's X,
checks the X is there on each of the next frames, and clicks it to close
that drawing. It failed on the first frame before the fix.

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

* File tabs: plainer comment on why the tab checks the pointer

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 01:51:16 -04:00
78c1653f50 loops: implied closed loops from loose LINE and ARC chains (#1906)
* loops: implied closed loops from loose LINE and ARC chains

LOOPS is a read-only inquiry command that chains LINE and ARC endpoints
meeting within a tolerance. Closed loops report their member handles,
perimeter, area and winding (exact with arcs, like SHAPES); open chains
report the endpoint gap, where it is and their length; branch junctions
(three or more ends at one point) are reported but excluded from the
chains rather than resolved.

The tolerance defaults to 1e-9 x the extents diagonal and is an option.
The quadratic endpoint pairing is capped (maxPairs, 1,000,000 by default)
and reports capped/pairTestsNeeded instead of guessing from a partial
pairing. Degenerate lines and arcs are counted under skipped; full-circle
arcs close on their own. The command is read-only.

Fixes #1437

* loops: cap maxPairs at 100,000,000 so a caller can't ask for unbounded work

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-24 01:51:11 -04:00
fda82da3df RECTANG: add a Center Rectangle construction mode (#1895)
* RECTANG: add a Center Rectangle construction mode (#1127)

The first point of RECTANG was always a corner. A new Center option makes it
the rectangle's centre, so the second point (or Dimensions/Area) builds it
outward evenly: centre 10,10 with a corner at 14,16 gives 6,4 to 14,16.

- interactive: a Center keyword at the first prompt (Chamfer keeps the c
  shortcut; Center matches ce or the full word), which also changes the
  prompt to Specify center point for rectangle;
- JSON: center: true on rectang, keeping p1/p2 and the default corner mode
  unchanged;
- rotation turns about the centre; chamfer, fillet, width, elevation and
  thickness apply as before; the rubber band previews the centred rectangle;
- params documentation updated.

Tests: centre-point geometry for p2, dimensions and area, rotation, fillet,
chamfer, width, thickness, the interactive keyword and preview, and the
unchanged default corner mode.

Fixes #1127

* docs: document the functions touched by #1127

* RECTANG: the Center option is CEnter, so C stays Chamfer's shortcut (as PLINE)

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-24 01:51:06 -04:00
NetterSilverandClaude Opus 5.5 2c59eb19c5 Fix LISP getangle subtracting ANGBASE in degrees from radians (#1883)
Found while testing crates/engine/src/lisp/input.rs:256-258 (lisp_angbase, used at :127 and :183); cause is crates/engine/src/sysvars.rs:141

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 01:50:56 -04:00
0299a3eaa0 fix(canvas): keep the OS cursor visible over menus and panels (#1879)
* fix(canvas): keep the OS cursor visible over menus and panels (fixes #1701)

The drawing area hid the OS cursor with `CursorIcon::None` whenever the
canvas response was hovered and the pointer lay inside the canvas rect.
egui still reports the canvas as hovered when the pointer is over a menu
popup's frame (the padding between its items and its border, and its
separators): only widgets claim the hover, so an open menu drawn over the
drawing left the canvas "hovered" and the cursor invisible there. The
same happened over a dialog's padding or a floating palette over the
canvas.

Require the topmost layer under the pointer to be the canvas's own layer
before treating the pointer as inside the drawing. Then menus, tooltips,
dialogs and floating panels keep a visible cursor (and the crosshair
stops under them, as over the menu bar already).

Tests: `crates/ui-egui/tests/cursor_over_menus.rs` (headless egui_kittest)
pins the canvas hiding the cursor, the menu bar and docked palettes
keeping it, the Show System Cursor option restoring the arrow off the
canvas, and the frame of an open menu over the drawing keeping the
cursor: the last case failed before this change. The whole
`cadcraft-ui-egui` test suite passes.

* docs: document the functions touched by #1701

* fix(canvas): keep the OS cursor visible over canvas-painted menus

Selection cycling's rows and Dynamic Input's options rows are menus, but
the canvas paints them itself: they share the canvas's layer, so
`layer_id_at` still reports the drawing and the OS cursor was hidden over
them (the review of #1701 caught it).

Exclude their rectangles from the crosshair's cursor contract, leaving the
canvas's input and the Dynamic Input tooltip under the pointer untouched
(so the list keeps highlighting and a click still picks its row).

Tests: two cases in `crates/ui-egui/tests/cursor_over_menus.rs` hover a
selection cycling row and a Dynamic Input options row and assert the OS
cursor stays visible; the drawing beside the list still hides it. Both
failed before this change.

* The painted menu frame counts as over the menu (CodeRabbit)

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

* cargo fmt

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-24 01:50: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 4f282b30cc OFFSET uses a single preselected object (#1870)
* OFFSET uses a single preselected object (#1610)

With exactly one offsettable object selected before OFFSET, take it and go
to the side prompt instead of asking to select an object again; a multiple
selection keeps the select-object loop. AutoCAD ignores preselection here,
but this is the requested behaviour for users coming from other tools.

* docs: document the functions touched by #1610
2026-10-24 01:50:34 -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
oracola91 4c327cccd0 Autosave: a new autosave folder is owner-only on Unix (#1868)
Autosave files are unsaved drawings. The autosave folder was made with
create_dir_all, by the first write or for the lock file, so on Unix it
got the default mode and other accounts on the machine could read the
files. A folder the autosaver creates is now 0o700. A folder that already
exists keeps its mode, since SAVEFILEPATH can be any folder the user
picked.

The same change as storytold/pdfcraft#3320 and storytold/vectorcraft#3406.
2026-10-24 01:50:24 -04:00
587d1dcd69 Spline extents: evaluate on the stack and sample relative to the spline (#1076) (#1834)
* Evaluate spline basis functions on the stack

`basis()` allocated three Vecs for every evaluated point, and a large
drawing evaluates millions of points while computing its extents on
open: `basis` was 10% and `calloc` 6% of `cadcraft-cli info` on the
1.1 M-entity drawing of #1076. `eval` now works in fixed arrays up to
degree 10 (the cap of the DXF reader and the SPLINE command) and falls
back to the heap above it. Results are bit-identical to the old code,
which the test keeps as its reference.

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

* Validate a spline once per tessellation

`tessellate` called `eval` for every sample, and `eval` re-ran
`is_valid` (a walk over the whole knot vector) and `domain()` each
time, so a spline with many control points cost O(knots) per point.
Validate and read the domain once, then evaluate the samples with the
checked variant. Part of the open time of large drawings (#1076).

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

* Size spline bounds sampling relative to the spline

`Spline::bounds` tessellated at an absolute tolerance of 1e-3, so every
spline of a drawing in millimetres hit the 4000-point cap: the extents
computed on open evaluated 12 M points for the 3072 five-point cubics
of the 1.1 M-entity drawing of #1076, a third of its open time in the
CLI and the GUI alike. Sample at 1e-3 of the spline's own control-box
diagonal instead, with at least four samples per knot span, straight
into the box without an intermediate Vec. The box of a typical spline
now takes a few hundred evaluations and stays within 1e-3 of the
spline's size of the finely sampled one (3.4e-4 at worst over the test
set); a degree-1 spline gets its exact control box.

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

* Give a spline with a non-finite control point its control box

`Bounds2::from_points` skips a non-finite point, so the control box
and its diagonal were finite while a NaN or infinite control point
poisoned every sample (`c * 0.0` is still NaN): `bounds` returned the
empty box instead of the box of the finite control points that the
other degenerate cases get. Check the control points before sampling.

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

* Size spline bounds sampling by a dimensionless ratio

`bounds` passed `diag * 1e-3` to `sample_count`, whose absolute floor
of 1e-9 on the tolerance made the count absolute again for a spline
with a diagonal under 1e-6: at a scale of 1e-12 every spline got the
minimum eight segments per degree whatever its shape, and an extreme
between two samples exceeded the 1e-3 margin the box promises. Derive
the count from the ratio of control-polygon length to tolerance in a
helper that `tessellate` (with its floor, unchanged) and `bounds`
(with the ratio to the diagonal) share, so a scaled copy of a spline
samples exactly like the original. The scale test now also runs at
1e-9 and 1e-12.

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

* Sample spline bounds over every knot span

`bounds` sampled one uniform grid over the parameter domain; the
four-per-span floor only raised the count. A narrow non-empty knot
span (a degree-2 peak supported on 0.5003..0.5004, say) fell between
grid points, at any count up to the cap, so the box missed part of
the curve and zoom extents clipped it. Spread the samples over the
non-empty knot spans in proportion to their length with a floor of
four per span, ends included, still folded straight into the box. The
cap holds over all spans: the floor drops to two or three when four
would exceed it, and a spline with more spans than two each allow
falls back to the grid. A typical spline evaluates as many points as
before.

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

* Hold a narrow knot span to eight samples, and say what that buys

The doc said the box falls short of an extreme by far under 1% of the
spline's size, which holds where the budget sets the count but not in a
span held to the floor: four segments across a degree-2 piece can miss
an off-grid peak by about 3% of the control box. Eight segments bring
that under 1%, and the comment now says both figures. A test puts the
peak between the grid's samples and checks the margin; four segments
would miss it by more.

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

* Give a spline with more spans than the cap samples its control box

Past two thousand knot spans the grid of the cap's four thousand
samples can step over a narrow span and leave its extreme out of the
box, which selection uses as a pre-filter. The control box contains the
curve whenever no weight is negative (the convex hull property), so
such a spline gets that box, conservative rather than sampled; only a
negative weight, which voids the property, keeps the grid.

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

* Keep the degree-1 shortcut for positive weights

A degree-1 spline is its control polygon only under the convex hull
property, which a negative weight voids: with control (1, 0), (2, 0)
and weights 1, -0.5 the curve passes (4, 0), outside the control box
that bounds() returned for every degree-1 spline. The shortcut, like
the many-spans one, now needs every weight positive; otherwise the
spline is sampled like any other.

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

* Sample a degenerate spline whose weights are not positive

Coincident control points with zero weights evaluate to the origin,
not to the point they sit on: eval() leaves the unnormalised sum alone
when the weights sum to nothing. The zero-diagonal shortcut returned
that point's box all the same. Like the degree-1 and many-spans
shortcuts, it now needs positive weights; otherwise the curve is
sampled, and the box holds what eval() gives.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-24 01:50:14 -04:00
philipp-tchandClaude Opus 5.5 e04056f714 The linetype scale of a block reference scales the linetypes inside the block (#1830)
A block reference's own linetype scale (DXF group 48) was ignored for the objects in the block:
a DASHED line inside a block inserted at linetype scale 2 kept dashes of scale 1. It now
multiplies their linetype scale, as LTSCALE does for the whole drawing (and as a dimension's
does for its block).


Claude-Session: https://claude.ai/code/session_01VP2GTYMskgGjiMw5GT5NL9

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 01:50:07 -04:00
25faf34d28 Interface Language dialog: list the languages alphabetically by their own name (#1787)
* Interface Language dialog: list the languages alphabetically by their own name

- English (and Auto where offered) stays first as the source language and fallback; the rest sort by native name with accents folded, so Čeština is under C and Magyar under M; Cyrillic and CJK names follow the Latin ones
- The list used the registry order, which is the order the languages were added

* Options dialog lists interface languages in the same order as the picker

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-24 01:49:59 -04:00
Books dadd1f227f AutoLISP: entmake defines blocks with BLOCK … ENDBLK (fixes #1662) (#1765)
* AutoLISP: entmake defines blocks with BLOCK ... ENDBLK (fixes #1662)

A BLOCK list wasn't an entity, so entmake returned nil and the entities
after it went into model space. Now a BLOCK list begins a definition
(name, flags, base point, description), the entities made meanwhile go
into it, and ENDBLK adds the block (redefining one of the same name, or
naming an anonymous *U block) and returns its name. An open definition,
an xref, an invalid name or a block that inserts itself is refused with
nil, using BLOCK's own name and self-reference checks.

* AutoLISP entmake blocks: review fixes (anonymous names keep a star, no redefinition during REFEDIT, a failed routine drops its open definition)

An anonymous block (flag 1) named without a * got a name like FO1; it now
gets *U. ENDBLK refuses to redefine a block REFEDIT is editing, as BLOCK
does. A routine that fails drops the definition it began, so the next
routine's entities go into the drawing; one that ends normally still
leaves it open.

* AutoLISP entmake blocks: refuse an anonymous block that inserts itself

The self-reference check ran only for named blocks, so an anonymous *U
block holding an INSERT of the name it was about to get (*U1 in an empty
drawing) was stored. The check now runs after the anonymous name is
given, for both kinds.

* AutoLISP entmake blocks: create a definition's layers only when the block is added

Each pending entity created its layer at once, so a definition ENDBLK
refused (a self-reference, REFEDIT) or a failed routine dropped left an
orphan layer. end_block now creates the layers after its checks pass,
just before it adds the block.
2026-10-24 01:49:54 -04:00
NetterSilverandClaude Haiku 5.5 1428174159 Fall back to decimal for huge architectural/fractional distances (#1762)
Found while testing crates/engine/src/units.rs:27-31 and 44-46

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-24 01:49:49 -04:00
NetterSilverandClaude Haiku 5.5 f686a595e6 Convert px/unitless SVG root size to mm when a viewBox is present (#1749)
Found while testing crates/io/src/svg_read.rs:576-593 (parse_length_mm), used at 605-612 (viewport)

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-24 01:49:44 -04:00
NetterSilverandClaude Haiku 5.5 7d2fbd959a Reject repeated leading '#' in Rgb::parse_hex (#1746)
Found while testing crates/color/src/lib.rs:24

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-24 01:49:38 -04:00
829909274a Make properties.set apply thickness and properties report it (#1722) (#1744)
* Make properties.set apply thickness and properties report it (#1722)

* test: CIRCLE returns its new object as `handle`

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-24 01:49:33 -04:00
f8f13c3f4e Report DWGPREFIX, DWGTITLED, FILEDIA, CMDECHO and more from GETVAR (#1715) (#1742)
* Report DWGPREFIX, DWGTITLED, FILEDIA, CMDECHO and more from GETVAR (#1715)

* Keep CMDECHO and FILEDIA in session settings so they survive drawing switches (#1715)

* Leave LOGINNAME out of GETVAR so scripts and agents can't read the user's login name

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-24 01:49:28 -04:00
Will f119610287 fix(ui): keep cursors visible over layout backgrounds (#1733)
Choose crosshair and pickbox ink from the painted paper or surround
under the pointer, including while editing through a layout viewport.
Reuse the existing contrast rule and preserve model-space appearance.

Add paint-output regression tests for backgrounds, navigation,
space changes, and isometric and full-screen cursors.

Fixes #1685.
2026-10-24 01:49:21 -04:00
NetterSilverandClaude Haiku 5.5 620e6a664c Use AutoCAD value steps in the ACI index palette (#1732)
Found while testing crates/color/src/lib.rs:159

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-24 01:49:16 -04:00
NetterSilverandClaude Haiku 5.5 8dd8635e10 Read SVG arc flags as single characters in path data (#1726)
Found while testing crates/io/src/svg_read.rs:892-894 (arc flags read via Numbers::next_num, 675-709)

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-24 01:49:12 -04:00
Patrik LermonandClaude Opus 5.5 4251f1e253 i18n(sv): translate 52 new strings, use the agreed terms in 19 (#1721)
* i18n(sv): translate 52 new strings, use the agreed terms in 19

Brings the Swedish catalog up to date with upstream main (0b32c2e).

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

* i18n(sv): use the agreed terms in 1

Brings the Swedish catalog up to date with upstream main (e86eacf).

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 01:49:07 -04:00
NetterSilverandClaude Haiku 5.5 9a32aa5613 Reject doubled minus sign in parse_distance (#1707)
Found while testing crates/engine/src/units.rs:138-160

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-24 01:49:02 -04:00
6ee6e652fd fix(snap): keep an arc's sweep for TAN and PER snaps (#1698)
* fix(snap): keep an arc's sweep for TAN, PER and deferred snaps

An arc was snapped as its full circle: TAN and PER solutions on the circle
that fall outside the arc's sweep were offered as if they touched the arc,
and a deferred tangent lost the sweep when it was stored.

- SnapCurve gains an Arc variant carrying start and end, so a deferred snap
  keeps the sweep and resolves onto the arc.
- TAN and PER filter their solutions with Arc::contains_angle; PER clamps to
  the arc with Arc::closest instead of the circle's closest point.
- Tangents and perpendiculars between two deferred snaps are filtered on
  both ends.
- The viewport projection maps the new variant.
- Tests: a tangent in the gap is not offered, the perpendicular clamps to an
  end, and a deferred tangent keeps its sweep and solves onto the arc.

Fixes #1697

* fix(snap): correct direct PER, support Arc in deferred pairs

Addresses the review on #1698:
- Direct PER on an arc used `arc.closest(bp)`, which clamps a gap solution to an
  end and misses the valid opposite antipode; both antipodes are now offered,
  filtered to the arc's sweep.
- `deferred_pairs` matched only circles and lines, so any deferred pair with an
  arc operand fell through to None. An arc is now matched as its carrier circle
  for the math, with the sweep filter still applied to the solutions.
- Tests: the direct PER case now expects the antipode on the arc, and a
  TAN-TAN pair between an arc and a circle only keeps the tangent on the arc.

* test(snap): use a margin over the boundary in the arc PER test

Addresses the review on #1698: the cursor is 5 units from the antipode, so the
aperture is 5.5 rather than 5.0 to keep the distance comparison off the
boundary; the comment now describes the antipode rather than the nearest end.

* cargo fmt

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-24 01:48:57 -04:00
76f7cea83f i18n : Add new translations to zh.tsv (#1695)
* i18n:Add missing translations for the print dialog and page settings.

* i18n:Add missing translations for the print dialog and page settings.

* i18n(zh): drop rows that repeat existing keys and the unused 'Page setup of'

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

* Add new translations to zh.tsv

Added translations for some newly added features.

* i18n : Add missing hatch dialog translations

* Localize modal labels and button texts

* Refactor layout label for better localization

Update layout label to use internationalization for 'None'.

* Add various measurement units in Chinese to zh.tsv

* Refactor closing prompt to use crate translation

* zh: drop rows main already translates (duplicate keys fail the catalog test)

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

* cargo fmt

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-24 01:48:51 -04:00
47e978b58e DXF/DWG: keep a file's own arrowhead blocks with standard names (_Dot, _DotSmall) (#1694)
* DXF/DWG: keep a file's own arrowhead blocks with standard names

Reading dropped every block with a standard arrowhead name (`_Dot`,
`_DotSmall`, ...) that no INSERT used, since CADCraft regenerates its own on
save. A block only a dimension style names (DIMBLK) was dropped too, so
AutoCAD's `_Dot` (a polyline with width) or a user's redefinition came back
as CADCraft's disc of 24 SOLIDs. Now only blocks whose content is exactly what
CADCraft writes are dropped; any other content is kept and saved as it was,
through DXF and DWG. PURGE keeps arrowhead blocks that dimension styles or
dimension overrides name.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Arrowheads: compare all properties, trim names in PURGE, test every standard arrow

is_generated_arrow now requires the whole Common to match, so only blocks
exactly as CADCraft writes them are regenerated. PURGE trims arrowhead names
from dimension styles and overrides. A new test writes every standard
arrowhead to DXF and DWG and checks none reads back as a user block. Review
of #1694.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: ffishman <20211677+ffishman@users.noreply.github.com>
2026-10-24 01:48:46 -04:00
MarekSpodBiedry a68d0a458b Export to a browser download on the web instead of failing on a path (#1655) (#1682) 2026-10-24 01:48:40 -04:00
MarekSpodBiedry 60265a1eee Save a dimangular made from two lines as a 2-line angular dimension (#1652) (#1681) 2026-10-24 01:48:35 -04:00
MarekSpodBiedry 20b0c9c11a Show surveyor's angles as degrees/minutes/seconds (#1643) (#1676) 2026-10-24 01:48:30 -04:00
b517e0749d i18n(pt-BR): native-speaker terminology review (#1665)
* i18n(pt-BR): native-speaker terminology review

Fix mistranslations (Thaw Layer read "Ativar camada", Snap type and the
snap settings label were translated as object snap, Revolve read
"Revolucionar", "Sobre-restrito" spelling) and make the CAD vocabulary
consistent with Brazilian usage: "snap" for every snap mode, "viewport"
and "grip" kept as loanwords, "Multilinha", "Explodir".

Update the Portuguese row in localization-parity.md (it still read 0%)
and the pt-BR note in localization.md.

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

* i18n(pt-BR): align offset and layer on/off wording with Brazilian usage

Brazilian university AutoCAD handouts (UFSC, UFSCar, UFRGS) keep
Offset as a loanword and describe layers as ligar/desligar.

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

* i18n(pt-BR): fix Bind, keep prompt keywords and SPDX in English

Bind merges an xref into the drawing, so Integrar, not Vincular.
Prompt keywords (Top/Zero/Bottom, Multiple) stay in English because
the command line only accepts the English keywords.

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

---------

Co-authored-by: Rodrigo Gressler <20211677+ffishman@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-24 01:48:25 -04:00
Akmal Kamalov 5c911ad824 Fixed broken / malicious link in README.MD (#1640) 2026-10-24 01:48:20 -04:00
Vinicius AugustoandClaude Opus 5.5 15147260c1 INSERT from a file brings the file's multiline styles (#1639)
Inserting a DWG/DXF as a block merged its layers, linetypes and text,
dimension, multileader and table styles, but not its multiline styles.
A drawing that brings its wall styles in by inserting a library file
(AutoLISP add-ons do this: INSERT walls.dwg, ERASE the reference, then
MLINE with one of its styles) got "Cannot find multiline style". The
styles now merge like the other tables: a name the drawing already has
keeps the drawing's definition.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 01:48:15 -04:00
bottelliemanuele-makerandClaude Fable 5.1 bbbf4465c4 i18n(it): translate the 99 strings added since 0.6.2 (0.7.0 and 0.8.0) (#1617)
Multileaders, LISP, recent files, in-place reference editing, the
selection options (modes, preview, visual effects), Hatch and Gradient
(shape, origin, boundaries, islands), dynamic blocks, the Block Editor
and text formatting. Terminology follows the Italian desktop CAD
convention already used by it.tsv (grip, xrif, tratteggio, sfumatura,
multidirettrice, editor blocchi). Placeholders and ellipses unchanged;
uk.tsv coverage: 1350/1350.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-24 01:48:10 -04:00
Dylan M. TaylorandCodebuff 7bf5905ac9 security: write the release from the release job only (SonarCloud S8233) (#2021)
The workflow-level contents: write granted every job the right to push
tags and releases; the reusable release workflow is the only job that
needs it. Workflow level drops to read.

🤖 Generated with Codebuff

Co-authored-by: Codebuff <noreply@codebuff.com>
2026-10-23 17:28:41 -04:00
Dylan M. Taylor c1dd60ffd6 Merge pull request #2018 from dylanmtaylor/ci/sonar-main-not-cancelled
ci(sonar): stop cancelling main-branch runs so coverage is published
2026-10-23 16:58:56 -04:00
Dylan M. Taylor bec28d6b87 ci(sonar): don't cancel superseded runs on main, so coverage is published 2026-10-23 16:50:42 -04:00
aiongg d3e6111cf1 Xrefs and data links: resolve stored paths with craft-docpath's resolve_absolute (#1750)
* Xrefs and data links: use resolve_absolute for stored paths

* CI: check that git dependencies come from storytold

* CI: release only with git dependencies from storytold

* Parse Cargo.lock as TOML when checking git sources

* Use the shared git-sources check in CI

* Parse cargo config, legacy .cargo/config included, in the git-sources check
2026-10-23 16:42:01 -04:00
aiongg dd1f0531ac i18n: missing translations never fail; cargo xtask i18n reports them (#2013) 2026-10-23 15:40:12 -04:00
aiongg 37150774c6 CI: run the gate through craft-common-actions' shared gate.yml; FreeBSD nightly and on release (#1987)
ci.yml is new: it calls the shared workflow (cached apt packages, nextest, main-only cache saves, Cargo.lock check). FreeBSD and Windows ARM64 run nightly and on pushes to release instead of on pull requests. Depends on storytold/craft-common-actions#69.
2026-10-23 13:56:16 -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
dependabot[bot] d0e83aefa6 chore(deps): bump lyon_tessellation
Bumps the cargo-dependencies group with 1 update in the / directory: [lyon_tessellation](https://github.com/nical/lyon).


Updates `lyon_tessellation` from 1.0.22 to 1.0.23
- [Release notes](https://github.com/nical/lyon/releases)
- [Commits](https://github.com/nical/lyon/commits)

---
updated-dependencies:
- dependency-name: lyon_tessellation
  dependency-version: 1.0.23
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: cargo-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-23 17:02:24 +00:00
aiongg 9d8f16c825 Merge pull request #1991 from marcoarnulfo/fix/harfrust-0.14
Build main again after the #1957 dependency bump
2026-10-23 13:00:16 -04:00
Dylan M. Taylor f043540d13 packaging: use [[ ]] tests, named locals and explicit returns in shell scripts (SonarCloud S7688/S7679/S7682) (#1972)
All flagged scripts run under bash (shebang or '# shellcheck shell=bash'), so
[[ ]] is safe. Positional parameters in helper functions are copied into named
locals, and helper functions end with a bare 'return', which keeps the exit
status of the last command (no behaviour change, notably for sha256 under
pipefail). packaging/linux/postinst.sh is POSIX sh and is left alone.
2026-10-23 11:16:40 -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
Marco Arnulfo 8ad9a3ce15 dwg: require acadrust 0.6.5, the first version that inverts dimension bit 0 itself 2026-10-23 14:54:19 +02:00
Marco Arnulfo 251fddb658 fonts: harfrust comment names both versions; the advance check skips fonts at 1000 per em 2026-10-23 09:49:38 +02:00
Marco Arnulfo 51db2d52e4 dependabot: leave the egui crates at 0.36 until craft-ui moves to 0.37 (craft-libs#199) 2026-10-23 09:49:38 +02:00
Marco Arnulfo 9621dcbd94 dwg: drop the dimension bit-0 inversion that acadrust 0.6.5 now does itself 2026-10-23 09:30:10 +02:00
Marco Arnulfo 5648a06ac4 Hold the egui crates at 0.36 until craft-ui moves to 0.37 (craft-libs#199) 2026-10-23 09:14:51 +02:00
Marco Arnulfo f344cf4460 engine: pass the query envelope by value to rstar 0.13 2026-10-23 08:50:32 +02:00
Marco Arnulfo e61e328edb io: read SVG element and attribute names with quick-xml 0.42 2026-10-23 08:50:32 +02:00
Marco Arnulfo 611eb9f5a9 fonts: port complex-text shaping to harfrust 0.14 2026-10-23 08:44:31 +02:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 1977a493c7 chore(deps): bump the cargo-dependencies group across 1 directory with 17 updates (#1957)
Bumps the cargo-dependencies group with 17 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [serde_json](https://github.com/serde-rs/json) | `1.0.151` | `1.0.154` |
| [tiny-skia](https://github.com/linebender/tiny-skia) | `0.11.4` | `0.12.0` |
| [skrifa](https://github.com/googlefonts/fontations) | `0.47.0` | `0.48.0` |
| [harfrust](https://github.com/harfbuzz/harfrust) | `0.12.0` | `0.14.0` |
| [quick-xml](https://github.com/tafia/quick-xml) | `0.41.0` | `0.42.0` |
| [egui](https://github.com/emilk/egui) | `0.36.2` | `0.37.0` |
| [egui_kittest](https://github.com/emilk/egui) | `0.36.2` | `0.37.0` |
| [eframe](https://github.com/emilk/egui) | `0.36.2` | `0.37.0` |
| [egui-wgpu](https://github.com/emilk/egui) | `0.36.2` | `0.37.0` |
| [arboard](https://github.com/1Password/arboard) | `3.6.1` | `3.7.0` |
| [acadrust](https://github.com/hakanaktt/acadrust) | `0.6.4` | `0.6.5` |
| [rstar](https://github.com/georust/rstar) | `0.12.2` | `0.13.0` |
| [libc](https://github.com/rust-lang/libc) | `0.2.190` | `0.2.191` |
| [miniz_oxide](https://github.com/Frommi/miniz_oxide) | `0.8.9` | `0.9.1` |
| [getrandom](https://github.com/rust-random/getrandom) | `0.3.4` | `0.4.3` |
| [muda](https://github.com/tauri-apps/muda) | `0.21.0` | `0.21.2` |
| [winresource](https://github.com/BenjaminRi/winresource) | `0.1.31` | `0.1.34` |



Updates `serde_json` from 1.0.151 to 1.0.154
- [Release notes](https://github.com/serde-rs/json/releases)
- [Commits](https://github.com/serde-rs/json/compare/v1.0.151...v1.0.154)

Updates `tiny-skia` from 0.11.4 to 0.12.0
- [Changelog](https://github.com/linebender/tiny-skia/blob/main/CHANGELOG.md)
- [Commits](https://github.com/linebender/tiny-skia/compare/v0.11.4...v0.12.0)

Updates `skrifa` from 0.47.0 to 0.48.0
- [Release notes](https://github.com/googlefonts/fontations/releases)
- [Changelog](https://github.com/googlefonts/fontations/blob/main/docs/RELEASE.md)
- [Commits](https://github.com/googlefonts/fontations/compare/skrifa-v0.47.0...skrifa-v0.48.0)

Updates `harfrust` from 0.12.0 to 0.14.0
- [Release notes](https://github.com/harfbuzz/harfrust/releases)
- [Changelog](https://github.com/harfbuzz/harfrust/blob/main/CHANGELOG.md)
- [Commits](https://github.com/harfbuzz/harfrust/compare/0.12.0...0.14.0)

Updates `quick-xml` from 0.41.0 to 0.42.0
- [Release notes](https://github.com/tafia/quick-xml/releases)
- [Changelog](https://github.com/tafia/quick-xml/blob/master/Changelog.md)
- [Commits](https://github.com/tafia/quick-xml/compare/v0.41.0...v0.42.0)

Updates `egui` from 0.36.2 to 0.37.0
- [Release notes](https://github.com/emilk/egui/releases)
- [Changelog](https://github.com/emilk/egui/blob/main/CHANGELOG.md)
- [Commits](https://github.com/emilk/egui/compare/0.36.2...0.37.0)

Updates `egui_kittest` from 0.36.2 to 0.37.0
- [Release notes](https://github.com/emilk/egui/releases)
- [Changelog](https://github.com/emilk/egui/blob/main/CHANGELOG.md)
- [Commits](https://github.com/emilk/egui/compare/0.36.2...0.37.0)

Updates `eframe` from 0.36.2 to 0.37.0
- [Release notes](https://github.com/emilk/egui/releases)
- [Changelog](https://github.com/emilk/egui/blob/main/CHANGELOG.md)
- [Commits](https://github.com/emilk/egui/compare/0.36.2...0.37.0)

Updates `egui-wgpu` from 0.36.2 to 0.37.0
- [Release notes](https://github.com/emilk/egui/releases)
- [Changelog](https://github.com/emilk/egui/blob/main/CHANGELOG.md)
- [Commits](https://github.com/emilk/egui/compare/0.36.2...0.37.0)

Updates `arboard` from 3.6.1 to 3.7.0
- [Release notes](https://github.com/1Password/arboard/releases)
- [Commits](https://github.com/1Password/arboard/compare/v3.6.1...v3.7.0)

Updates `acadrust` from 0.6.4 to 0.6.5
- [Release notes](https://github.com/hakanaktt/acadrust/releases)
- [Changelog](https://github.com/hakanaktt/acadrust/blob/main/CHANGELOG.md)
- [Commits](https://github.com/hakanaktt/acadrust/compare/v0.6.4...v0.6.5)

Updates `rstar` from 0.12.2 to 0.13.0
- [Commits](https://github.com/georust/rstar/compare/v0.12.2...v0.13.0)

Updates `libc` from 0.2.190 to 0.2.191
- [Release notes](https://github.com/rust-lang/libc/releases)
- [Changelog](https://github.com/rust-lang/libc/blob/0.2.191/CHANGELOG.md)
- [Commits](https://github.com/rust-lang/libc/compare/0.2.190...0.2.191)

Updates `miniz_oxide` from 0.8.9 to 0.9.1
- [Changelog](https://github.com/Frommi/miniz_oxide/blob/master/CHANGELOG.md)
- [Commits](https://github.com/Frommi/miniz_oxide/commits)

Updates `getrandom` from 0.3.4 to 0.4.3
- [Changelog](https://github.com/rust-random/getrandom/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rust-random/getrandom/compare/v0.3.4...v0.4.3)

Updates `muda` from 0.21.0 to 0.21.2
- [Release notes](https://github.com/tauri-apps/muda/releases)
- [Changelog](https://github.com/tauri-apps/muda/blob/dev/CHANGELOG.md)
- [Commits](https://github.com/tauri-apps/muda/compare/muda-v0.21...muda-v0.21.2)

Updates `winresource` from 0.1.31 to 0.1.34
- [Commits](https://github.com/BenjaminRi/winresource/commits)

---
updated-dependencies:
- dependency-name: acadrust
  dependency-version: 0.6.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: cargo-dependencies
- dependency-name: arboard
  dependency-version: 3.7.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: cargo-dependencies
- dependency-name: eframe
  dependency-version: 0.37.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: cargo-dependencies
- dependency-name: egui
  dependency-version: 0.37.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: cargo-dependencies
- dependency-name: egui-wgpu
  dependency-version: 0.37.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: cargo-dependencies
- dependency-name: egui_kittest
  dependency-version: 0.37.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: cargo-dependencies
- dependency-name: getrandom
  dependency-version: 0.4.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: cargo-dependencies
- dependency-name: harfrust
  dependency-version: 0.14.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: cargo-dependencies
- dependency-name: libc
  dependency-version: 0.2.191
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: cargo-dependencies
- dependency-name: miniz_oxide
  dependency-version: 0.9.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: cargo-dependencies
- dependency-name: muda
  dependency-version: 0.21.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: cargo-dependencies
- dependency-name: quick-xml
  dependency-version: 0.42.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: cargo-dependencies
- dependency-name: rstar
  dependency-version: 0.13.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: cargo-dependencies
- dependency-name: serde_json
  dependency-version: 1.0.154
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: cargo-dependencies
- dependency-name: skrifa
  dependency-version: 0.48.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: cargo-dependencies
- dependency-name: tiny-skia
  dependency-version: 0.12.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: cargo-dependencies
- dependency-name: winresource
  dependency-version: 0.1.34
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: cargo-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-22 23:54:32 -04:00
skylar taylor-barrick a08fd7c1d1 Merge pull request #1963 from storytold/docs/security-review-docstrings-20261022
docs: document security limits and regression guarantees
2026-10-22 18:36:19 -06:00
skylartaylor fa6bb0351b docs: clarify security regression guarantees 2026-10-23 02:14:11 +02:00
Brandon ThomasandClaude Opus 5.5 bad1073fdf Release 0.12.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 20:06:04 -04:00
skylartaylor 48b4165591 docs: document security limits and regression guarantees 2026-10-23 01:59:55 +02:00
Dylan M. Taylor 01bbdb24bc security: confine craft-fonts manifest paths and reject newline directives (CWE-22, CWE-74) (#1841)
The optional `CRAFT_FONTS_DIR` build input lets a font manifest select the file
each face is embedded from. `build.rs` canonicalised that file with no
containment check, so an absolute path, a `..` traversal, or a symlink inside
the checkout could make the generated `include_bytes!` read an arbitrary file
on the build host and expose its bytes through `CRAFT_FONTS` (CWE-22).

It also printed the resolved path straight into Cargo's newline-delimited
build-script protocol (`cargo::rerun-if-changed=…`). A path containing an
embedded newline — reachable through a crafted symlink target — could inject a
second directive such as `cargo::rustc-env=OUT_DIR=…`, redirecting the
generated-source include to attacker-supplied Rust (CWE-74).

- `resolve_font` rejects absolute and `..` manifest paths and requires the
  canonicalised result to stay inside the canonicalised checkout root.
- `rerun_if_changed` refuses any path containing CR/LF before emitting the
  directive; `one_line` collapses CR/LF in checkout-derived diagnostics.

Addresses findings rows 5 (CWE-22) and 14 (CWE-74).
2026-10-22 18:42:51 -04:00
Dylan M. Taylor 15ba3dc5cc security: submit XATTACH picked path as literal text, not a command line (CWE-74) (#1843)
The External References palette's XATTACH handler passed the file picker's
result to `app.cmdline(&path)`. `cmdline` interprets command-line syntax, so a
selected filename beginning with an apostrophe (for example `'closeall`) was run
as an engine command rather than naming the file — closing every drawing without
the UI's unsaved-change confirmation.

The picked path is attacker-controlled (the picker returns whatever the user
selected, and `relative_to_host` can reduce it to a bare basename). After
starting XATTACH, submit it through `Session::input(Input::Text(path))` so it is
delivered to the command's prompt as literal text and never interpreted as a
command, matching how other picker/dialog bridges already behave.

Addresses finding row 26.
2026-10-22 18:19:13 -04:00
Dylan M. Taylor 07bb604214 security: guard the DXF->DWG conversion input and document decoder limits (CWE-789) (#1847)
The native DWG bridge wraps the third-party `acadrust` decoder, which allocates
tables and section buffers from sizes a file declares and cannot be interrupted
once it starts. The DWG->DXF read path already rejects oversized input before
decoding, caps the DXF output while writing, and runs inside `catch_unwind`. The
DXF->DWG `convert` path had no input-size guard, so a very large DXF went
straight into the dependency's reader.

- `convert` now rejects DXF input larger than `Limits::max_dxf_bytes` before
  calling `DxfReader::read`, symmetric with the read path, and keeps the existing
  `catch_unwind` around the decoder and writer (rows 23, 3, 27, large-input
  variant).
- Documented the residual risk: a small file that declares a huge internal table
  or section can still make the dependency allocate before any size is known.
  Fully fixing that needs an upstream fix in `acadrust` (validate declared sizes
  against a decoded-memory budget) or a separate worker with an OS memory limit;
  the in-crate limits keep the blast radius bounded until then.

Addresses findings rows 3, 23, 27 (bounds the reachable input/output size;
the deeper allocation fix belongs in the unmodified dependency).
2026-10-22 18:18:43 -04:00
Dylan M. Taylorandskylartaylor dc4bbe86f6 security: bound font outline geometry and MTEXT line scanning (CWE-400/407) (#1852)
* security: bound font outline geometry and MTEXT line scanning (CWE-400/407)

Two font/text paths let crafted input monopolise the rendering thread:

- Glyph shaping copied a glyph's full contour set for every placed character
  with no cumulative budget, so a compact CFF/OTF font with a large expanded
  outline, repeated across a short string, could grow the shaped result without
  bound. `shape` now charges retained outline points against a cumulative
  per-line budget and stops placing geometry once it is exhausted (advances
  still accumulate, so the width stays sensible) (row 9).
- Default MTEXT layout recomputed `line.text.chars().count()` before appending
  each segment or space, rescanning the whole accumulated line every time —
  multiplicative on a long line. The line now keeps a running character count,
  updated only from newly appended text (row 20).

New tests cover the MTEXT layout; all font tests pass and render still builds,
clippy clean.

Addresses findings rows 9, 20.

* fix: budget glyph geometry before outline placement

* fix: skip empty outlines without text metadata

---------

Co-authored-by: skylartaylor <26588250+skylartaylor@users.noreply.github.com>
2026-10-22 17:57:39 -04:00
Dylan M. Taylorandskylartaylor 953a6b3b01 security: bound Lisp callbacks, native comparisons, and local file reads (CWE-400, CWE-407) (#1842)
* security: bound Lisp callbacks, native comparisons, and local file reads (CWE-400, CWE-407)

The command and scripting engine had several unbounded paths a supplied LISP
routine or a selected path could exploit to monopolise the thread or exhaust
memory:

- `call_sync` ran synchronous callbacks (vl-sort comparators, vl-remove-if
  predicates, …) with `u64::MAX` fuel, so a constant-memory infinite loop never
  yielded and bypassed the blocking execution cap. It now drives the callback in
  bounded slices and aborts with an error past a hard step cap (row 2).
- `wcmatch`/`glob` explored `*` placements with only a recursion-depth guard,
  and `equal` re-traversed shared cons subtrees. Both now charge every step
  against a shared work budget and stop when it is exhausted (row 33).
- `setvar` converted a LISP value to JSON unboundedly, so a shared cons DAG
  expanded into gigabytes of owned arrays. Conversion is now depth- and
  element-bounded and rejects over-budget values with a LISP error (row 39).
- OPEN/LOAD/APPLOAD (`read_text_file`), SCRIPT, and native open (`read_file`,
  `run_open`) buffered a path with `std::fs::read`, so an endless device
  exhausted memory before any size check. Reads now go through a new
  `capped_read` helper that rejects non-regular files and caps bytes
  (rows 21, 34).

New tests cover the callback loop, the oversized setvar structure, and the
capped reader. Addresses findings rows 2, 21, 33, 34, 39.

* fix: bound native Lisp scans and nonblocking file reads

* fix: enforce callback fuel before each execution slice

---------

Co-authored-by: skylartaylor <26588250+skylartaylor@users.noreply.github.com>
2026-10-22 17:57:09 -04:00
Dylan M. Taylorandskylartaylor 8085745a22 security: bound native DXF/SVG/PDF import allocation and quadratic work (CWE-400/407/789) (#1846)
* security: bound native DXF/SVG/PDF import allocation and quadratic work (CWE-400/407/789)

Several native import paths let a crafted drawing cause quadratic CPU work or
unbounded allocation:

- DXF layer decoding scanned the whole layer list for every LAYER record
  (`d.layer_mut`), so many distinct layer names made import quadratic. Keep a
  case-normalised name→index map and update/append in O(1) (row 29).
- ACAD_TABLE cells were materialised per table under a per-table cap, but many
  small tables could still add up. Add a document-wide table-cell budget in
  parse_entities; a table that would overflow it is reduced to empty (row 8).
- DXF image name allocation restarted its `_2, _3…` suffix probe for every
  definition sharing a base name (quadratic). Keep a per-base next-suffix
  counter so probing resumes where it left off (row 13).
- Image resolution cloned each definition's path/name into every referring
  image. Add an aggregate byte budget; once spent, string copies stop while
  geometry fields are still applied (row 42).
- PDF link annotation scanned the whole selected-link vector for every
  primitive. Build a HashSet of linked handles once for O(1) membership (row 15).
- SVG arc/curve flattening retained each flushed run with only a per-run cap, so
  repeated arcs amplified a compact path into unbounded geometry. Add a
  document-wide generated-vertex budget charged in `add` (row 31).

New test `svg_arc_budget` covers the SVG budget. All `cadcraft-io` tests pass;
clippy adds no new warnings.

Addresses findings rows 8, 13, 15, 29, 31, 42.

* fix: share DXF table budgets before allocation

---------

Co-authored-by: skylartaylor <26588250+skylartaylor@users.noreply.github.com>
2026-10-22 17:35:43 -04: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. Taylorandskylartaylor 916be26ed3 security: bound constraint-engine work and allocation (CWE-400/407) (#1851)
* security: bound constraint-engine work and allocation (CWE-400/407)

The constraint engine let imported geometry or settings cause disproportionate
CPU/memory work on the synchronous analysis thread:

- Auto-constraint inference scanned the whole caller-supplied type list for every
  geometric candidate. Resolve the allowed type names into a set once and use
  O(1) membership per candidate (row 46).
- Rigid-point sampling stopped after collecting points but kept traversing block
  members that contribute none (Text, Ray, …), repeated for every Insert. Count
  every examined member against a bounded budget (row 47).
- Control-mode residual evaluation cloned the whole retained spline, including
  excess imported weights, for each free parameter. Drop weights that do not
  match the control-point count before retaining the template (row 45).
- The DOF analysis budget estimated only matrix-rank work, ignoring the spline
  reconstruction each tangent residual performs during numerical differentiation.
  Add a residual-evaluation budget (rows × free × per-residual cost, including a
  spline-reconstruction term) enforced before building the Jacobian (row 43).

All constraints tests pass; clippy adds no new warnings.

Addresses findings rows 43, 45, 46, 47.

* fix: budget constraint work without changing spline weights

---------

Co-authored-by: skylartaylor <26588250+skylartaylor@users.noreply.github.com>
2026-10-22 17:12:04 -04:00
Dylan M. Taylorandskylartaylor 2dbc7699a0 security: budget the rendering pipeline against DoS geometry (CWE-400/407/789) (#1849)
* security: budget the rendering pipeline against DoS geometry (CWE-400/407/789)

Several render paths let crafted drawing data spin the rendering thread or
allocate without bound:

- Hatch dash sweeping reset its per-span step counter and used emitted geometry
  as its only limit, so gap-only families (which emit nothing) could multiply
  CPU work. Skip families that cannot draw and charge every dash step against a
  shared per-hatch work budget (row 10).
- Hatch polygon deduplication compared loops with an O(n²) cyclic match and
  every loop against every kept one. Charge vertex comparisons against a shared
  budget and stop deduplicating when spent (row 12).
- Table coverage marked every merge rectangle, so overlapping merges re-marked
  covered cells quadratically. Bound marking to a small multiple of the cell
  count (row 11).
- Broken-table part layout expanded sparse rows to full width before checking
  whether detailed layout is allowed. Only expand when within the cell limit;
  otherwise leave cells sparse and let layout draw an outline (row 17).
- Dimension text substitution copied the base for every `<>` placeholder with no
  size bound. Estimate the expansion and cap it at a fixed text budget (row 32).
- Linetype range generation cycled all-gap patterns forever (nothing emitted, so
  the emit limit never tripped). Return continuous for all-gap patterns and
  bound visited pattern elements (row 38).
- Viewport contents built with a fresh expansion counter, so a layout could far
  exceed the documented block-expansion limit. Share one budget across the whole
  build, seeded with what the enclosing list already spent (row 22).

New tests for the all-gap linetype path; all render tests pass, clippy clean.

Addresses findings rows 10, 11, 12, 17, 22, 32, 38.

* fix: preserve bounded render fallbacks and text output

---------

Co-authored-by: skylartaylor <26588250+skylartaylor@users.noreply.github.com>
2026-10-22 17:08:25 -04:00
Dylan M. Taylorandskylartaylor 7c72ec45a9 security: bound drawing document-model work and allocation (CWE-400/407) (#1850)
* security: bound drawing document-model work and allocation (CWE-400/407)

Several document-model operations let crafted drawing data cause quadratic or
exponential work, or multiply retained allocations:

- Polyline grip generation rebuilt the whole segment vector inside the loop over
  every vertex, so enumerating one large polyline's grips was quadratic. Compute
  the segments once before the loop (row 35).
- Associative-array name sync searched the whole replacement vector for every
  placement. Index replacements by placement coordinate once (last entry wins),
  turning the per-item lookup O(1) (row 44).
- Dimension-extent bounds recursively expanded the referenced block without the
  shared bounds cache the Insert path uses, so a branching or self-referencing
  dimension block cost one pass per reference. Route it through the same
  `(name, depth)` cache (row 37).
- Dynamic-block parsing parsed every graph-member occurrence into its own owned
  parameter, so a graph repeating a member multiplied one record into gigabytes.
  Deduplicate member handles before resolving them (row 7).
- Stretch-action application linearly searched the vertex list and cloned the
  index vector for every selection occurrence. Index vertices by handle and skip
  handles already handled in the action's selection (row 30).

New tests for polyline grips; all doc tests pass, clippy clean.

Addresses findings rows 7, 30, 35, 37, 44.

* fix: retain first dynamic stretch vertex selection

---------

Co-authored-by: skylartaylor <26588250+skylartaylor@users.noreply.github.com>
2026-10-22 17:08:02 -04:00
Dylan M. Taylorandskylartaylor 0158712bfd security: bound geometry-kernel degree and work (CWE-407) (#1848)
* security: bound geometry-kernel degree and work (CWE-407)

Several geometry routines let a crafted spline or region cause quadratic or
worse CPU work:

- B-spline basis evaluation is O(degree²) per sample and tessellation takes
  O(degree) samples, so a HATCH/DXF spline edge declaring a very high degree
  (DXF allows up to 100 000) turned a tiny spline into billions of iterations.
  Add `MAX_DEGREE = 64` and treat higher-degree splines as invalid, so
  `eval`/`tessellate` fall back to the control polygon. The render clip boundary
  also rejects such degrees up front (rows 18, 28).
- `Spline::derivative` re-ran the O(knots) validity scan on every call, and
  arc-length integration calls it once per sample, making a valid spline's
  measurement quadratic. Split off `derivative_unchecked` and have integration
  (validated once by its public entry points) use it (row 24).
- `infer_knot_param` matched each fit parameter against the whole knot vector
  from the start. Both are ascending, so replace it with a linear merge (row 16).
- Fillet seed collection did not stop the enclosing run-pair loops when the
  per-call candidate cap was hit, and refined duplicate candidates. Bound total
  seeds across pairs and deduplicate before Newton refinement (row 36).
- Region grid intersection counted only distinct pairs against its budget, so a
  long segment replicated across many cells re-visited the same pair unbounded.
  Charge every candidate-pair visit against a budget (row 41).

New spline degree tests added; all geom and render tests pass, clippy clean.

Addresses findings rows 16, 18, 24, 28, 36, 41.

* fix: preserve spline knot and fillet search behavior

---------

Co-authored-by: skylartaylor <26588250+skylartaylor@users.noreply.github.com>
2026-10-22 17:05:16 -04:00
Dylan M. Taylorandskylartaylor 683eb5e6e7 security: do not follow directory symlinks in cargo xtask stats (CWE-400) (#1845)
* security: do not follow directory symlinks in `cargo xtask stats` (CWE-400)

The stats walker descended with `Path::is_dir()`, which follows symlinks, and
kept no record of visited directories. A small acyclic graph of directory
aliases planted in a workspace package (for example through a contribution a
developer inspects) therefore caused exponentially repeated enumeration and file
reads, consuming CPU and I/O far beyond the repository's real size.

`walk` now inspects each entry's own `file_type()` and skips symlinks before
descending or reading, so directory aliases are never re-scanned. A traversal
budget (`MAX_ENTRIES`) bounds total work as defense in depth.

Addresses finding row 4.

* fix: report statistics traversal exhaustion

---------

Co-authored-by: skylartaylor <26588250+skylartaylor@users.noreply.github.com>
2026-10-22 17:04:35 -04:00
Dylan M. Taylorandskylartaylor 345fb27a69 security: write files atomically through an exclusive temp file (CWE-59) (#1840)
* security: write files atomically through an exclusive temp file (CWE-59, CWE-377)

Save, export, plot, autosave and the audit log each wrote their output by
putting bytes in a predictable sibling temporary path (`<path>.cadcraft-tmp`,
`<recovery>.tmp`, or the companion `.adt`) and renaming it over the real file.
Opening that pre-existing temporary path for a truncating write follows a
symlink planted there by another local user, letting them redirect the write
onto any file the victim can write before the rename.

Add a shared `atomic_write` helper that creates a fresh temporary file with
`create_new` (O_CREAT|O_EXCL) in the destination directory, writes through the
retained handle, flushes/syncs, and atomically renames it into place. An
existing entry (symlink or not) makes the exclusive create fail instead of
being followed. Route autosave, save, export, plot and the audit log through it.

The macOS CI cert importer likewise wrote the decoded PKCS#12 container to a
fixed name in a possibly shared temp directory; it now creates a private
per-run directory with `mktemp -d` and creates the file with noclobber.

Addresses findings rows 1, 25, 40 (CWE-59) and 19 (CWE-377).

* fix: preserve permissions during atomic replacement

* fix: restrict temporary permissions during exclusive creation

---------

Co-authored-by: skylartaylor <26588250+skylartaylor@users.noreply.github.com>
2026-10-22 17:03:46 -04:00
Brandon ThomasandClaude Opus 5.5 8f988f630d Release 0.11.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 15:37:25 -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
Antoine Boxho f4e6873130 Merge pull request #1568 from C00kieMonsta/readme-official-source
README: verify official releases and download checksums (fixes #1554)
2026-10-22 14:05:42 -04:00
skylar taylor-barrick e4214f7180 Merge pull request #1677 from MarekSpodBiedry/issue-1644
Write the ACAD_TABLE *T block relative to its insertion point (#1644)
2026-10-22 07:51:40 -06:00
skylar taylor-barrick 35934718ea Merge pull request #1683 from MarekSpodBiedry/issue-1667
Keep a layout's plot window corners when saving DXF (#1667)
2026-10-22 07:51:33 -06:00
skylar taylor-barrick 916d440bed Merge pull request #1718 from oracola91/fix/control-accept-backoff
Control server: wait after a failed accept instead of retrying at once
2026-10-22 07:51:26 -06:00
skylar taylor-barrick 632967aa78 Merge pull request #1336 from midasmatterhorn/fix/r4-dxf-table-record-caps
io: the four symbol tables honour the same record cap as their peers
2026-10-22 07:51:19 -06:00
skylar taylor-barrick f15be2305c Merge pull request #1680 from MarekSpodBiedry/issue-1650
DWG: write the current text style's own handle, so AutoCAD opens with it current (#1650)
2026-10-22 07:51:12 -06:00
skylar taylor-barrick f2dadec23a Merge pull request #1678 from MarekSpodBiedry/issue-1648
Multileader sizes no longer multiplied by DIMSCALE (#1648)
2026-10-22 07:51:06 -06:00
skylar taylor-barrick 00acec952e Merge pull request #1774 from philipp-tch/fix/foreign-handles-above-2-48
DXF: keep foreign objects whose handles are above 2^48 (fixes #1773)
2026-10-22 07:51:00 -06:00
skylar taylor-barrick 78254b4d3f Merge pull request #1480 from Silvertree2010/fix/h2426-polyline-segments-with-a-tiny-no
Treat polyline bulges with a sub-EPS sweep as straight segments
2026-10-22 07:50:53 -06:00
skylar taylor-barrick 8094d7746f Merge pull request #1635 from Hamza0101/entsel-pickbox
AutoLISP: an entsel prompt shows the pick box whatever its message says
2026-10-22 07:50:47 -06:00
skylar taylor-barrick 4ad4f4f598 Merge pull request #1628 from Hamza0101/lisp-eq-symbols
AutoLISP: `=` compares the symbols `type` returns
2026-10-22 07:50:41 -06:00
BFlat 477eec5026 Merge pull request #1894 from storytold/readme/app-icon
README: the app icon above the title
2026-10-22 18:01:10 +07:00
bflatastic 38750c6eb8 README: the app icon above the title 2026-10-22 17:57:20 +07:00
Dylan M. Taylor 75d9d72703 Merge pull request #1837 from storytold/sonar-fix/enforce-https
security: enforce HTTPS on curl downloads (SonarCloud S6506)
2026-10-21 20:13:55 -04:00
Philipp ThomannandClaude Opus 5.5 82424e47c9 DXF: keep foreign objects whose handles are above 2^48
AutoCAD writes handles above 2^48 (whole drawings whose handles start at 0xDAD1...).
dxf_raw dropped every foreign object with such a handle, so these drawings lost their
dynamic blocks and all other unmodelled objects on read, DXF to DXF as well. DXF handles
are up to 16 hex digits; the whole 64-bit range is now believed (null still is not).
Modelled entities and table records already accepted such handles.

Test: the DOOR fixture with every handle moved above 2^48 keeps its dynamic block
through a save (red before the change).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 07:23:16 +02: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
MarekSpodBiedry a7bb28d722 DWG: write the current text style's own handle, so AutoCAD opens with it current (#1650) 2026-10-19 22:33:02 +02:00
MarekSpodBiedry 27bff9cc30 Write the ACAD_TABLE *T block relative to its insertion point (#1644) 2026-10-19 22:29:01 +02:00
MarekSpodBiedry d600a80354 Multileader sizes no longer multiplied by DIMSCALE (#1648) 2026-10-19 22:27:34 +02:00
MarekSpodBiedry 81f0aa53ff Keep a layout's plot window corners when saving DXF (#1667) 2026-10-19 22:27:04 +02:00
Hamza 27861ad0ed Read a prompt's wording only where the engine wrote it
The pick-box heuristic read the message for a leading "Select", and
this branch had widened it past leading whitespace so that a routine's
own message would be read the same way. That makes a plain point prompt
look like a pick: (getpoint "\nSelect a point: ") showed the pick box
and lost its point modifiers, since pointmod refuses them wherever
picks_objects holds.

A routine writes all of its own message (raw), and says through
picks_one when it picks an object, so the wording needs reading only
where the engine wrote it. Covered for both spellings a routine might
use, with and without the leading newline.
2026-10-20 00:31:05 +05:00
Hamza b72680cfb7 AutoLISP: an entsel prompt shows the pick box whatever its message says
Whether the cursor shows the pick box came from `Prompt::picks_objects()`,
which read the prompt's wording: a selection-set prompt, or a point prompt whose
message starts with "Select". An `entsel`/`nentsel` prompt takes a point and is
not a selection set, so it fell to the wording -- and an AutoLISP routine writes
its own message, conventionally starting with a newline:

  (nentsel)                                       "Select object: "   pick box
  (nentsel "\nSelect second line or polyline: ")   starts with "\n"   none
  (entsel  "\nPick a line: ")                     says no such word  none

A prompt now says that it picks one object rather than being read for it, and
`GetRequest::prompt()` sets that for `GetKind::EntSel`, so all three show the
pick box. The wording test is read past leading whitespace as well, so a command
machine's prompt beginning with a newline is not caught by the same thing.

Point modifiers (`.x`, `.y`…) are offered at a point prompt that is not a pick,
so they no longer appear at an `entsel` prompt with a message -- as they never
did at one without.

Fix: #1598
2026-10-19 19:13:23 +05:00
Hamza d9adce038c AutoLISP: = compares the symbols type returns
`(= (type x) 'LIST)` is how an AutoLISP routine tests a type, because `type`
returns a symbol. `=` coerced both of its arguments to numbers, so the symbol
failed the coercion and the routine stopped at its first type test:

  _$ (= (type 1) 'INT)
  bad argument type: numberp: INT

`=` and `/=` now compare numbers and strings as before, and any other atom -- a
symbol, `nil`, an entity name, a selection set, a file handle -- by what it is,
the way `eq` does. Two consequences, both closer to AutoLISP than raising was:
a mismatch of kinds answers `nil` rather than failing, so `(= 1 "1")` and
`(= '(1) '(1))` answer (comparing list structure is `equal`'s job), and
`(= nil nil)` is `T`.

The ordering comparisons still take numbers and strings only, as AutoLISP has
them: `(< 'a 'b)` remains `bad argument type: numberp: A`.

Fix: #1597
2026-10-19 17:26:39 +05:00
SorinandClaude Opus 5.5 cda7381c3d Keep rectangle opposite corners free of ortho (STRETCH window and friends)
With ORTHOMODE on, the opposite corner of STRETCH's crossing window was bent
onto an axis, so the window collapsed and caught nothing. The prompt set only
the rubber-band base; it now marks it as a window corner (Prompt::corner), as
ZOOM Window and RECTANG already do.

Same fix for the other two-corner rectangles: the Window/Crossing/Box selection
modes, PLOTWINDOW, MVIEW (and its Restore), -VIEW Window and REVCLOUD
Rectangular. Regression tests in crates/engine/tests/stretch_window_ortho.rs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 13:17:25 +03:00
NetterSilverandClaude Haiku 5.5 4c75b0c458 Treat polyline bulges with a sub-EPS sweep as straight segments
Found while testing crates/geom/src/curve.rs:435 (bulge_to_arc) together with crates/geom/src/lib.rs:45-48 (ccw_sweep)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 04:43:01 +00:00
midasmatterhorn 8a2b8f4bdf io: the four symbol tables honour the same record cap as their peers
LAYER, LTYPE, STYLE and DIMSTYLE records were appended with no cap while
the VIEW, UCS and VPORT arms of the same loop guard at MAX_OBJECTS — so
a hostile DXF could grow the layer table (each record found by a linear
case-insensitive name scan) without bound. The same guard now applies to
all seven table kinds.

Behaviour is unchanged for every legal drawing: no drawing approaches a
million table records, so no regression test can reach the cap at unit
scale (the same reason the peer guards shipped without one). The
quadratic cost of the name lookups themselves is a separate, larger
change (resolving table names through a map while reading) deliberately
left to the maintainers.
2026-10-15 17:43:04 +03:00
782 changed files with 61043 additions and 32631 deletions
+3
View File
@@ -0,0 +1,3 @@
# Minimum cargo-nextest for this repository: GHSA-vc25-qg58-w57c is fixed in 0.9.150 and CI
# installs exactly that version, so an older nextest refuses to run (exit 92 REQUIRED_VERSION_NOT_MET).
nextest-version = "0.9.150"
+11
View File
@@ -14,6 +14,17 @@ updates:
- "*" - "*"
commit-message: commit-message:
prefix: "chore(deps)" prefix: "chore(deps)"
# craft-ui is still on egui 0.36, and two eguis in the graph don't build (#1990). Remove this
# once craft-ui moves to egui 0.37 (storytold/craft-libs#199).
ignore:
- dependency-name: "egui"
versions: [">=0.37"]
- dependency-name: "eframe"
versions: [">=0.37"]
- dependency-name: "egui-wgpu"
versions: [">=0.37"]
- dependency-name: "egui_kittest"
versions: [">=0.37"]
# Maintain GitHub Actions workflows # Maintain GitHub Actions workflows
- package-ecosystem: "github-actions" - package-ecosystem: "github-actions"
+26
View File
@@ -0,0 +1,26 @@
# Structural rules for the command-line code (RFC-0012): lint/ast-grep's rule tests, then a scan.
# Seconds, no Rust build.
name: ast-grep
on:
push:
branches: [main]
paths: ["lint/ast-grep/**", "apps/cadcraft-cli/src/**", "apps/cadcraft/src/launch_args.rs", "apps/cadcraft/src/main.rs", ".github/workflows/ast-grep.yml"]
pull_request:
paths: ["lint/ast-grep/**", "apps/cadcraft-cli/src/**", "apps/cadcraft/src/launch_args.rs", "apps/cadcraft/src/main.rs", ".github/workflows/ast-grep.yml"]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ast-grep-${{ github.ref }}
cancel-in-progress: true
jobs:
scan:
# Tracked at @main like the other shared workflows.
uses: storytold/craft-common-actions/.github/workflows/ast-grep.yml@main
with:
config: lint/ast-grep/sgconfig.yml
paths: apps/cadcraft-cli/src apps/cadcraft/src/launch_args.rs apps/cadcraft/src/main.rs
+30
View File
@@ -0,0 +1,30 @@
name: CI
# Quality gates for every push to main and every pull request (`cargo xtask ci`), through the
# suite's shared gate workflow in craft-common-actions.
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
permissions:
contents: read
# A newer push to a PR branch cancels its older run; every push to main runs to the end.
concurrency:
group: ci-${{ github.event_name == 'pull_request' && github.ref || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
gate:
# Tracks @main so suite-wide CI fixes reach every app without a PR in each repository.
uses: storytold/craft-common-actions/.github/workflows/gate.yml@main
with:
# Same craft-fonts commit as release.yml.
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
timeout-minutes: 90
packages: >-
pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcb1-dev libxcursor-dev
libxrandr-dev libxi-dev libfontconfig1-dev libfreetype-dev mesa-vulkan-drivers
libgl1-mesa-dev
+4 -1
View File
@@ -2,6 +2,9 @@
# (GitHub has no FreeBSD runners). GPU tests skip themselves when the VM has no adapter. # (GitHub has no FreeBSD runners). GPU tests skip themselves when the VM has no adapter.
name: FreeBSD name: FreeBSD
# The VM build is slow, so it runs nightly on main and on pushes to `release`, not on every pull
# request: a drifted dependency still shows up within a day, and always before a release ships.
# PRs that change the FreeBSD workflow or packaging itself still run it.
on: on:
push: push:
branches: [release] branches: [release]
@@ -26,4 +29,4 @@ jobs:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
with: with:
craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3 craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
+38
View File
@@ -21,3 +21,41 @@ jobs:
nix: nix:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/nix.yml@main uses: storytold/craft-common-actions/.github/workflows/nix.yml@main
git-sources:
name: Git dependencies come from storytold
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Git dependencies come from storytold
run: |
set -euo pipefail
test -f Cargo.lock || { echo "Cargo.lock is missing"; exit 1; }
# Source replacement, patches and path overrides in cargo config never show up
# in Cargo.lock. Parsed rather than grepped: TOML has many spellings of one key.
# Cargo still reads the legacy .cargo/config.
python3 - <<'PY'
import os, sys, tomllib
for path in (".cargo/config", ".cargo/config.toml"):
if not os.path.exists(path):
continue
config = tomllib.load(open(path, "rb"))
replaced = [name for name, source in config.get("source", {}).items()
if isinstance(source, dict) and "replace-with" in source]
if replaced or "patch" in config or "paths" in config:
print(f"{path} replaces, patches or overrides a source")
sys.exit(1)
PY
# Fails when Cargo.lock is out of date with the manifests.
cargo metadata --locked --format-version 1 > /dev/null
python3 - <<'PY'
import sys, tomllib
lock = tomllib.load(open("Cargo.lock", "rb"))
bad = sorted({p["source"] for p in lock.get("package", [])
if p.get("source", "").startswith("git+")
and not p["source"].startswith("git+https://github.com/storytold/")})
if bad:
print("Git dependencies outside github.com/storytold:", *bad, sep="\n")
sys.exit(1)
PY
+24
View File
@@ -21,3 +21,27 @@ jobs:
lint: lint:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main
windows-install-policy:
timeout-minutes: 10
name: Windows installation policy (no app build)
runs-on: windows-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Install the pinned packaging toolchain
shell: pwsh
run: |
$tools = Join-Path $env:RUNNER_TEMP 'craft-install-policy-wix'
dotnet tool install --tool-path $tools wix --version 5.0.2
if ($LASTEXITCODE -ne 0) { throw 'WiX installation failed.' }
$tools >> $env:GITHUB_PATH
$policy = Get-Content -Raw -LiteralPath packaging/windows/install-policy.json | ConvertFrom-Json
foreach ($extension in @($policy.extensions)) {
& (Join-Path $tools 'wix.exe') extension add -g "WixToolset.$extension.wixext/5.0.2"
if ($LASTEXITCODE -ne 0) { throw "WiX extension $extension installation failed." }
}
- name: Build and check non-executable installer fixtures
shell: pwsh
run: ./packaging/windows/test-install-policy.ps1 -OutputDirectory (Join-Path $env:RUNNER_TEMP 'craft-install-policy-tests')
+45 -2
View File
@@ -11,20 +11,63 @@ on:
default: "" default: ""
permissions: permissions:
contents: write contents: read
concurrency: concurrency:
group: release-${{ github.ref }} group: release-${{ github.ref }}
cancel-in-progress: false cancel-in-progress: false
jobs: jobs:
git-sources:
name: Git dependencies come from storytold
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Git dependencies come from storytold
run: |
set -euo pipefail
test -f Cargo.lock || { echo "Cargo.lock is missing"; exit 1; }
# Source replacement, patches and path overrides in cargo config never show up
# in Cargo.lock. Parsed rather than grepped: TOML has many spellings of one key.
# Cargo still reads the legacy .cargo/config.
python3 - <<'PY'
import os, sys, tomllib
for path in (".cargo/config", ".cargo/config.toml"):
if not os.path.exists(path):
continue
config = tomllib.load(open(path, "rb"))
replaced = [name for name, source in config.get("source", {}).items()
if isinstance(source, dict) and "replace-with" in source]
if replaced or "patch" in config or "paths" in config:
print(f"{path} replaces, patches or overrides a source")
sys.exit(1)
PY
# Fails when Cargo.lock is out of date with the manifests.
cargo metadata --locked --format-version 1 > /dev/null
python3 - <<'PY'
import sys, tomllib
lock = tomllib.load(open("Cargo.lock", "rb"))
bad = sorted({p["source"] for p in lock.get("package", [])
if p.get("source", "").startswith("git+")
and not p["source"].startswith("git+https://github.com/storytold/")})
if bad:
print("Git dependencies outside github.com/storytold:", *bad, sep="\n")
sys.exit(1)
PY
release: release:
needs: git-sources
permissions:
# The reusable release workflow tags and uploads the draft release.
contents: write
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/release.yml@main uses: storytold/craft-common-actions/.github/workflows/release.yml@main
with: with:
app-name: cadcraft app-name: cadcraft
app-title: CADCraft app-title: CADCraft
craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3 craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
version: ${{ inputs.version }} version: ${{ inputs.version }}
secrets: secrets:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
+2 -1
View File
@@ -11,7 +11,8 @@ permissions:
concurrency: concurrency:
group: sonarcloud-${{ github.ref }} group: sonarcloud-${{ github.ref }}
cancel-in-progress: true # A push to main is the run that publishes coverage; superseded runs there would never finish.
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs: jobs:
sonarcloud: sonarcloud:
+8 -4
View File
@@ -3,15 +3,17 @@
# binaries are ARM64, run the CLI natively, and uninstall. # binaries are ARM64, run the CLI natively, and uninstall.
name: Windows ARM64 name: Windows ARM64
# Nightly on main and on pushes to `release` (dependency and runner-image drift shows up within a
# day, and always before a release ships), and on PRs that change the Windows packaging itself.
on: on:
schedule:
- cron: "3 5 * * *"
push: push:
branches: [release] branches: [release]
pull_request: pull_request:
paths: paths:
- .github/workflows/windows-arm64.yml - .github/workflows/windows-arm64.yml
- packaging/windows/** - packaging/windows/**
schedule:
- cron: "41 3 * * 1"
workflow_dispatch: workflow_dispatch:
concurrency: concurrency:
@@ -23,8 +25,10 @@ permissions:
jobs: jobs:
windows-arm64: windows-arm64:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. # Immutable supporting workflow revision until the new install contract lands upstream.
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@8bb4e5ab95443171104ad88113fe1ef9b0e5ce3f
with: with:
app-name: cadcraft app-name: cadcraft
app-dir: CADCraft app-dir: CADCraft
install-relative-path: Programs/ArtCraft/CADCraft
install-scope: perUser
+2 -1
View File
@@ -19,7 +19,7 @@ People trust CADCraft with their drawings; a crash loses their work. **This outr
- **Bound recursion** (nested/cyclic block references: `MAX_BLOCK_DEPTH`). - **Bound recursion** (nested/cyclic block references: `MAX_BLOCK_DEPTH`).
- **Last-resort guard:** `Session::execute` and every step of an interactive command (its factory, `begin`, input, `prompt` and `preview`; `crates/engine/src/guard.rs`) run under `catch_unwind`; an escaped panic restores the drawing and reports an error. - **Last-resort guard:** `Session::execute` and every step of an interactive command (its factory, `begin`, input, `prompt` and `preview`; `crates/engine/src/guard.rs`) run under `catch_unwind`; an escaped panic restores the drawing and reports an error.
- **Prove it:** every crash fix lands with a regression test (see `hostile_params_never_panic`, `hostile_dxf_does_not_panic`). - **Prove it:** every crash fix lands with a regression test (see `hostile_params_never_panic`, `hostile_dxf_does_not_panic`).
- Every production crate root carries `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`. - Every workspace crate manifest carries `[lints] workspace = true` to inherit `[workspace.lints]`; every production crate root carries `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`.
## Non-negotiables ## Non-negotiables
- **Clean-room.** AutoCAD is installed on the dev machine and may be *observed* black-box: run it, use its UI with synthetic drawings, take screenshots (by window id) stored only under `plan/autocad/screenshots/` (never committed). **Never** read, disassemble or copy anything inside the AutoCAD bundle (names/listings only); never copy Autodesk fonts (SHX), hatch patterns (`.pat`), linetypes (`.lin`), templates, CUI/PGP files, icons, artwork or help text; never commit files produced by AutoCAD. File formats come from public specifications (Autodesk's published DXF Reference, the Open Design Alliance's public DWG specification) and our own synthetic tests. Never copy or link GPL/LGPL/AGPL code (LibreDWG, LibreCAD, QCAD, FreeCAD's planegcs, SolveSpace, OpenCASCADE) — don't even read their sources. - **Clean-room.** AutoCAD is installed on the dev machine and may be *observed* black-box: run it, use its UI with synthetic drawings, take screenshots (by window id) stored only under `plan/autocad/screenshots/` (never committed). **Never** read, disassemble or copy anything inside the AutoCAD bundle (names/listings only); never copy Autodesk fonts (SHX), hatch patterns (`.pat`), linetypes (`.lin`), templates, CUI/PGP files, icons, artwork or help text; never commit files produced by AutoCAD. File formats come from public specifications (Autodesk's published DXF Reference, the Open Design Alliance's public DWG specification) and our own synthetic tests. Never copy or link GPL/LGPL/AGPL code (LibreDWG, LibreCAD, QCAD, FreeCAD's planegcs, SolveSpace, OpenCASCADE) — don't even read their sources.
@@ -30,6 +30,7 @@ People trust CADCraft with their drawings; a crash loses their work. **This outr
- **Programmatic calls never open dialogs.** `engine.execute` runs the JSON form with defaults; only menu/toolbar invocation starts the interactive prompt sequence. - **Programmatic calls never open dialogs.** `engine.execute` runs the JSON form with defaults; only menu/toolbar invocation starts the interactive prompt sequence.
- **Layering** is enforced by `cargo xtask layers`: L0 `geom`, `dxf` → L1 `color`, `doc` → L2 `fonts`, `render`, `constraints` → L3 `io` → L4 `engine` → L5 `ui-egui`, `mcp` → apps. Nothing below L5 depends on egui/eframe/winit/wgpu/rfd. **The UI crate is swappable.** - **Layering** is enforced by `cargo xtask layers`: L0 `geom`, `dxf` → L1 `color`, `doc` → L2 `fonts`, `render`, `constraints` → L3 `io` → L4 `engine` → L5 `ui-egui`, `mcp` → apps. Nothing below L5 depends on egui/eframe/winit/wgpu/rfd. **The UI crate is swappable.**
- **The UI is thin**: panels read engine state and act through `app.run(id, params)` / `app.start(id)` / `app.cmdline(text)`. Colours come from `theme::Tokens`. - **The UI is thin**: panels read engine state and act through `app.run(id, params)` / `app.start(id)` / `app.cmdline(text)`. Colours come from `theme::Tokens`.
- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`). - **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, assets, layers, wasm). Commit after every feature arc that builds, and push to `main`. - **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, assets, layers, wasm). Commit after every feature arc that builds, and push to `main`.
+5
View File
@@ -23,6 +23,11 @@ Generated-in-code assets are original and have no file to list:
| Asset | Author | Source | Licence | Notes | | Asset | Author | Source | Licence | Notes |
|---|---|---|---|---| |---|---|---|---|---|
| `epaint_default_fonts::EGUI_ICONS`, `epaint_default_fonts::EMOJI_ICON` (dependency font inputs) | John Slegers | [egui 173cc737](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [emoji-icon-font](https://github.com/jslegers/emoji-icon-font) | MIT (Copyright (c) 2014 John Slegers; full upstream text in `NOTICE`) | egui-icons.ttf is the five-glyph subset; emoji-icon-font.ttf is the full monochrome fallback. Registered by the production default font definitions; dependency assets unmodified here, no font file copied into this repository. |
| `krilla 0.8.2 / icc/sGrey-v2-magic.icc` (dependency-embedded) | Clinton Ingram | [craft-libs 9f89fe8 vendor/krilla/icc/sGrey-v2-magic.icc](https://github.com/storytold/craft-libs/blob/9f89fe8f6d72ab4db4cc06f186381a6a254463ab/vendor/krilla/icc/sGrey-v2-magic.icc) (Krilla 0.8.2 upstream 3ffdf0588cf98050aad6edba51ca70162e1fb5b5) | CC0-1.0 | Embedded by the pinned PDF writer (`craft-pdf` 0.1.0, Krilla 0.8.2); unmodified dependency asset, no file copied into this repository. SHA-256 `4110169287e5f1b5149ca9f1da1814d32943905bba8030e849d1a3b729a19481`. |
| `krilla 0.8.2 / icc/sGrey-v4.icc` (dependency-embedded) | Clinton Ingram | [craft-libs 9f89fe8 vendor/krilla/icc/sGrey-v4.icc](https://github.com/storytold/craft-libs/blob/9f89fe8f6d72ab4db4cc06f186381a6a254463ab/vendor/krilla/icc/sGrey-v4.icc) (Krilla 0.8.2 upstream 3ffdf0588cf98050aad6edba51ca70162e1fb5b5) | CC0-1.0 | Embedded by the pinned PDF writer (`craft-pdf` 0.1.0, Krilla 0.8.2); unmodified dependency asset, no file copied into this repository. SHA-256 `00c0f94e09127520a17dc0e1d9264b5702081d96dbdb1549ee88e3631ce42a9d`. |
| `krilla 0.8.2 / icc/sRGB-v2-magic.icc` (dependency-embedded) | Clinton Ingram | [craft-libs 9f89fe8 vendor/krilla/icc/sRGB-v2-magic.icc](https://github.com/storytold/craft-libs/blob/9f89fe8f6d72ab4db4cc06f186381a6a254463ab/vendor/krilla/icc/sRGB-v2-magic.icc) (Krilla 0.8.2 upstream 3ffdf0588cf98050aad6edba51ca70162e1fb5b5) | CC0-1.0 | Embedded by the pinned PDF writer (`craft-pdf` 0.1.0, Krilla 0.8.2); unmodified dependency asset, no file copied into this repository. SHA-256 `af4efe28f6d311799865f325ba39184a2e978b113ca74124d60af7de22b105f4`. |
| `krilla 0.8.2 / icc/sRGB-v4.icc` (dependency-embedded) | Clinton Ingram | [craft-libs 9f89fe8 vendor/krilla/icc/sRGB-v4.icc](https://github.com/storytold/craft-libs/blob/9f89fe8f6d72ab4db4cc06f186381a6a254463ab/vendor/krilla/icc/sRGB-v4.icc) (Krilla 0.8.2 upstream 3ffdf0588cf98050aad6edba51ca70162e1fb5b5) | CC0-1.0 | Embedded by the pinned PDF writer (`craft-pdf` 0.1.0, Krilla 0.8.2); unmodified dependency asset, no file copied into this repository. SHA-256 `c56e1685d888f5edb92fe07f2750f387f8fe8e91b32ff8fb0b56bfbbb9458353`. |
| `assets/app-icon/hicolor/128x128/apps/ai.storyteller.cadcraft.png` | CADCraft contributors | generated from `assets/app-icon/cadcraft-1024.png` | MIT OR Apache-2.0 | Linux hicolor icon | | `assets/app-icon/hicolor/128x128/apps/ai.storyteller.cadcraft.png` | CADCraft contributors | generated from `assets/app-icon/cadcraft-1024.png` | MIT OR Apache-2.0 | Linux hicolor icon |
| `assets/app-icon/hicolor/16x16/apps/ai.storyteller.cadcraft.png` | CADCraft contributors | generated from `assets/app-icon/cadcraft-1024.png` | MIT OR Apache-2.0 | Linux hicolor icon | | `assets/app-icon/hicolor/16x16/apps/ai.storyteller.cadcraft.png` | CADCraft contributors | generated from `assets/app-icon/cadcraft-1024.png` | MIT OR Apache-2.0 | Linux hicolor icon |
| `assets/app-icon/hicolor/24x24/apps/ai.storyteller.cadcraft.png` | CADCraft contributors | generated from `assets/app-icon/cadcraft-1024.png` | MIT OR Apache-2.0 | Linux hicolor icon | | `assets/app-icon/hicolor/24x24/apps/ai.storyteller.cadcraft.png` | CADCraft contributors | generated from `assets/app-icon/cadcraft-1024.png` | MIT OR Apache-2.0 | Linux hicolor icon |
+1 -1
View File
@@ -19,7 +19,7 @@ People trust CADCraft with their drawings; a crash loses their work. **This outr
- **Bound recursion** (nested/cyclic block references: `MAX_BLOCK_DEPTH`). - **Bound recursion** (nested/cyclic block references: `MAX_BLOCK_DEPTH`).
- **Last-resort guard:** `Session::execute` and every step of an interactive command (its factory, `begin`, input, `prompt` and `preview`; `crates/engine/src/guard.rs`) run under `catch_unwind`; an escaped panic restores the drawing and reports an error. - **Last-resort guard:** `Session::execute` and every step of an interactive command (its factory, `begin`, input, `prompt` and `preview`; `crates/engine/src/guard.rs`) run under `catch_unwind`; an escaped panic restores the drawing and reports an error.
- **Prove it:** every crash fix lands with a regression test (see `hostile_params_never_panic`, `hostile_dxf_does_not_panic`). - **Prove it:** every crash fix lands with a regression test (see `hostile_params_never_panic`, `hostile_dxf_does_not_panic`).
- Every production crate root carries `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`. - Every workspace crate manifest carries `[lints] workspace = true` to inherit `[workspace.lints]`; every production crate root carries `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`.
## Non-negotiables ## Non-negotiables
- **Clean-room.** AutoCAD is installed on the dev machine and may be *observed* black-box: run it, use its UI with synthetic drawings, take screenshots (by window id) stored only under `plan/autocad/screenshots/` (never committed). **Never** read, disassemble or copy anything inside the AutoCAD bundle (names/listings only); never copy Autodesk fonts (SHX), hatch patterns (`.pat`), linetypes (`.lin`), templates, CUI/PGP files, icons, artwork or help text; never commit files produced by AutoCAD. File formats come from public specifications (Autodesk's published DXF Reference, the Open Design Alliance's public DWG specification) and our own synthetic tests. Never copy or link GPL/LGPL/AGPL code (LibreDWG, LibreCAD, QCAD, FreeCAD's planegcs, SolveSpace, OpenCASCADE) — don't even read their sources. - **Clean-room.** AutoCAD is installed on the dev machine and may be *observed* black-box: run it, use its UI with synthetic drawings, take screenshots (by window id) stored only under `plan/autocad/screenshots/` (never committed). **Never** read, disassemble or copy anything inside the AutoCAD bundle (names/listings only); never copy Autodesk fonts (SHX), hatch patterns (`.pat`), linetypes (`.lin`), templates, CUI/PGP files, icons, artwork or help text; never commit files produced by AutoCAD. File formats come from public specifications (Autodesk's published DXF Reference, the Open Design Alliance's public DWG specification) and our own synthetic tests. Never copy or link GPL/LGPL/AGPL code (LibreDWG, LibreCAD, QCAD, FreeCAD's planegcs, SolveSpace, OpenCASCADE) — don't even read their sources.
Generated
+1484 -342
View File
File diff suppressed because it is too large Load Diff
+56 -15
View File
@@ -4,10 +4,10 @@ members = ["crates/*", "apps/*", "xtask"]
# default-members # default-members
[workspace.package] [workspace.package]
version = "0.10.0" version = "0.14.0"
edition = "2024" edition = "2024"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
rust-version = "1.90" rust-version = "1.95"
repository = "https://github.com/storytold/cadcraft" repository = "https://github.com/storytold/cadcraft"
[workspace.dependencies] [workspace.dependencies]
@@ -25,34 +25,47 @@ cadcraft-engine = { path = "crates/engine" }
cadcraft-ui-egui = { path = "crates/ui-egui" } cadcraft-ui-egui = { path = "crates/ui-egui" }
cadcraft-mcp = { path = "crates/mcp" } cadcraft-mcp = { path = "crates/mcp" }
# Which paths stored in a drawing (xrefs, data links) may be opened. # Which paths stored in a drawing (xrefs, data links) may be opened.
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" } craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
# PDF writing (krilla) for plots, and reading them back in tests. The root patches below pin
# craft-pdf's patched backends to the same revision (Cargo ignores dependency-local patches).
craft-pdf = { version = "=0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", default-features = false }
clap = { version = "4", features = ["derive", "env"] }
clap_complete = "4.6.11"
clap_mangen = { version = "0.3.3", features = ["env"] }
# foundation # foundation
thiserror = "2" thiserror = "2"
same-file = "1"
serde = { version = "1", features = ["derive", "rc"] } serde = { version = "1", features = ["derive", "rc"] }
serde_json = { version = "1", features = ["float_roundtrip"] } serde_json = { version = "1", features = ["float_roundtrip"] }
log = "0.4" log = "0.4"
# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md).
chrono = { version = "0.4.45", default-features = false, features = ["std"] }
rayon = "1" rayon = "1"
# rendering / formats # rendering / formats
tiny-skia = { version = "0.11", default-features = false, features = ["std", "simd", "png-format"] } tiny-skia = { version = "0.12", default-features = false, features = ["std", "simd", "png-format"] }
lyon_tessellation = "1.0.22" lyon_tessellation = "1.0.22"
skrifa = "0.47" skrifa = "0.48"
# Text shaping (OpenType GSUB/GPOS, HarfBuzz port on read-fonts; the version egui's epaint uses) # Text shaping (OpenType GSUB/GPOS, HarfBuzz port on read-fonts; egui's epaint is still on 0.12,
# and the Unicode Bidirectional Algorithm for right-to-left drawing text. # so the graph has both) and the Unicode Bidirectional Algorithm for right-to-left drawing text.
harfrust = "0.12" harfrust = "0.14"
unicode-bidi = "0.3.18" unicode-bidi = "0.3.18"
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] } image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
encoding_rs = "0.8.35" encoding_rs = "0.8.35"
# XML for the SVG reader (streaming parser, no entity expansion beyond the five predefined ones) # XML for the SVG reader (streaming parser, no entity expansion beyond the five predefined ones)
quick-xml = "0.41" quick-xml = "0.42"
# ui # ui
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] } craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["serde"] }
egui = "0.36" craft-window = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] }
egui_kittest = "0.36" egui = { version = "0.37", default-features = false, features = ["default_fonts", "monochrome_emoji_fonts"] }
eframe = { version = "0.36", default-features = false } egui_kittest = "0.37"
egui-wgpu = "0.36" eframe = { version = "0.37", default-features = false }
egui-wgpu = "0.37"
# Monotonic time on native and web (performance.now on wasm).
web-time = "1.1"
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] }
rfd = "0.17" rfd = "0.17"
arboard = { version = "3.6", default-features = false } arboard = { version = "3.6", default-features = false }
@@ -60,19 +73,47 @@ arboard = { version = "3.6", default-features = false }
unsafe_code = "forbid" unsafe_code = "forbid"
[workspace.lints.clippy] [workspace.lints.clippy]
cognitive_complexity = "deny"
too_many_arguments = "deny"
wildcard_imports = "deny"
dbg_macro = "warn" dbg_macro = "warn"
todo = "warn" todo = "warn"
too_many_arguments = "allow"
type_complexity = "allow" type_complexity = "allow"
[profile.dev.package."*"] [profile.dev.package."*"]
opt-level = 2 opt-level = 2
debug = false
[profile.dev] [profile.dev]
opt-level = 1 opt-level = 1
debug = "line-tables-only"
[profile.release] [profile.release]
opt-level = 3 opt-level = 3
lto = "thin" lto = "thin"
codegen-units = 16 codegen-units = 16
debug = 0 debug = 0
# PDF backends for craft-pdf: pinned to the same reviewed craft-libs revision as craft-pdf itself
# (Cargo ignores dependency-local patches).
[patch.crates-io]
krilla = { version = "=0.8.2", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
# Keep the complete egui family and native event bridge on reviewed fork commits.
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui-winit = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_glow = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_extras = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" }
+30 -4
View File
@@ -15,10 +15,36 @@ Mozilla Public License 2.0 and is used unmodified as a separate library (crates/
is available from crates.io and the repository above. is available from crates.io and the repository above.
None: every bundled asset is original to CADCraft (icons, drafting font, hatch patterns and CADCraft's icons, drafting font, hatch patterns and linetypes are drawn or defined in code.
linetypes are drawn or defined in code). The per-file list is in ATTRIBUTION.md. Builds made with The UI also bundles the egui font inputs described below. The per-file list is in ATTRIBUTION.md.
the optional craft-fonts input (CRAFT_FONTS_DIR) embed the fonts listed in its ATTRIBUTION.md, Builds made with the optional craft-fonts input (CRAFT_FONTS_DIR) embed the fonts listed in its
each under the SIL Open Font License 1.1. ATTRIBUTION.md, each under the SIL Open Font License 1.1.
CADCraft is an independent project and is not affiliated with, sponsored by or endorsed by CADCraft is an independent project and is not affiliated with, sponsored by or endorsed by
Autodesk, Inc. AutoCAD and DWG are trademarks of Autodesk, Inc. Autodesk, Inc. AutoCAD and DWG are trademarks of Autodesk, Inc.
The PDF writer in the pinned craft-pdf 0.1.0 / Krilla 0.8.2 dependency embeds four Compact ICC
Profiles by Clinton Ingram, under CC0-1.0. They are unmodified dependency assets; no profile file
is copied into this repository. Per-file sources and SHA-256 hashes are in ATTRIBUTION.md.
Source: https://github.com/LaurenzV/krilla/tree/3ffdf0588cf98050aad6edba51ca70162e1fb5b5/crates/krilla/icc
(pinned through https://github.com/storytold/craft-libs/tree/9f89fe8f6d72ab4db4cc06f186381a6a254463ab/vendor/krilla/icc).
CC0 1.0 Universal: https://creativecommons.org/publicdomain/zero/1.0/
Pinned licence text: https://github.com/storytold/craft-libs/blob/9f89fe8f6d72ab4db4cc06f186381a6a254463ab/vendor/krilla/icc/LICENSE
The UI's egui-icons.ttf subset and emoji-icon-font.ttf fallback are provided by epaint_default_fonts
and registered through egui's default font definitions. They derive from emoji-icon-font by
John Slegers, under the MIT License. The subset and full face share the following upstream notice.
Source: https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts
Original: https://github.com/jslegers/emoji-icon-font
The following text is reproduced verbatim from emoji-icon-font-mit-license.txt:
MIT License
Copyright (c) 2014 John Slegers
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+23 -4
View File
@@ -7,6 +7,10 @@
</a> </a>
</p> </p>
<p align="center">
<img src="assets/app-icon/hicolor/256x256/apps/ai.storyteller.cadcraft.png" width="128" height="128" alt="CADCraft icon: a drafting set square and compass arc on a blue grid tile">
</p>
<h1 align="center">CADCraft</h1> <h1 align="center">CADCraft</h1>
<p align="center"> <p align="center">
@@ -119,8 +123,7 @@ numbers):
## Quick start ## Quick start
Desktop and web UI builds require Rust 1.95 or later. The core workspace's declared minimum CADCraft requires Rust 1.95 or later.
remains Rust 1.90.
```sh ```sh
git clone https://github.com/storytold/cadcraft git clone https://github.com/storytold/cadcraft
@@ -137,7 +140,7 @@ offset 0.25 then pick the circle and a side
zoom e zoom to extents zoom e zoom to extents
``` ```
The web build: `cd apps/cadcraft-web && trunk serve` (needs [trunk](https://trunkrs.dev)). The web build: `cd apps/cadcraft-web && trunk serve` (needs [trunk](https://github.com/trunk-rs/trunk)).
### Fonts ### Fonts
@@ -193,7 +196,7 @@ crashed survives the next start. The file stops growing at 16 MiB. `--version` w
| Variable | Effect | | Variable | Effect |
|---|---| |---|---|
| `CADCRAFT_CONFIG_DIR` | Use this directory instead of the platform settings directory (the log goes to its `logs/`) | | `CADCRAFT_CONFIG_DIR` | Use this directory instead of the platform settings directory (the log goes to its `logs/`); empty means no settings folder (no log file, autosave, workspace or plotters) |
| `RUST_LOG` | Log levels for standard error and the log file. Default: `info` for CADCraft's own crates, `warn` for everything else. env_logger-style directives replace that, e.g. `RUST_LOG=debug`, `RUST_LOG=warn,cadcraft_io=trace` or `RUST_LOG=info,wgpu_core=warn`; a directive ending in `*` covers every target starting with it (`cadcraft*=debug`). `cadcraft_engine=debug` also logs every command-line message. | | `RUST_LOG` | Log levels for standard error and the log file. Default: `info` for CADCraft's own crates, `warn` for everything else. env_logger-style directives replace that, e.g. `RUST_LOG=debug`, `RUST_LOG=warn,cadcraft_io=trace` or `RUST_LOG=info,wgpu_core=warn`; a directive ending in `*` covers every target starting with it (`cadcraft*=debug`). `cadcraft_engine=debug` also logs every command-line message. |
The logger is `apps/cadcraft/src/logging.rs`; the web build logs to the browser console instead. The logger is `apps/cadcraft/src/logging.rs`; the web build logs to the browser console instead.
@@ -215,6 +218,10 @@ cadcraft-cli convert drawing.dxf drawing.svg
cadcraft-cli convert drawing.dwg detail.png --window 0,0,20000,15000 cadcraft-cli convert drawing.dwg detail.png --window 0,0,20000,15000
cadcraft-cli run drawing.dwg --cmd 'zoom {"mode":"window","p1":[0,0],"p2":[20000,15000]}' --export detail.png cadcraft-cli run drawing.dwg --cmd 'zoom {"mode":"window","p1":[0,0],"p2":[20000,15000]}' --export detail.png
cadcraft-cli run drawing.dwg --cmd 'select {"window":[[0,0],[20000,15000]]}' --cmd 'zoom {"mode":"object"}' --export sel.png cadcraft-cli run drawing.dwg --cmd 'select {"window":[[0,0],[20000,15000]]}' --cmd 'zoom {"mode":"object"}' --export sel.png
# Act on what an earlier --cmd made: {last} is the last handle it returned.
cadcraft-cli run --metric --cmd 'layout.set {"name":"Layout1"}' \
--cmd 'mview {"p1":[20,20],"p2":[200,150]}' --cmd 'viewport.set {"handle":"{last}","scale":1}'
``` ```
How image exports (PNG 2400×1600, SVG, PDF) are framed: `convert` fits the model-space extents. How image exports (PNG 2400×1600, SVG, PDF) are framed: `convert` fits the model-space extents.
@@ -231,6 +238,9 @@ MCP tools include `command_line` (type at the prompt), `execute` (any command wi
To run LISP routines, scripts and alias files, or to drive CADCraft from your own programs, see To run LISP routines, scripts and alias files, or to drive CADCraft from your own programs, see
[docs/customization.md](docs/customization.md). [docs/customization.md](docs/customization.md).
`cadcraft-cli --help` documents every subcommand and flag; exit codes, streams, environment
variables and its RFC-0012 conventions are in [docs/cli.md](docs/cli.md).
## Architecture ## Architecture
```text ```text
@@ -285,6 +295,14 @@ See [ROADMAP.md](ROADMAP.md) for the stage, measured parity and the estimate of
**Download CADCraft** from GitHub: the [latest release](https://github.com/storytold/cadcraft/releases/latest) has every build listed below, and [all releases](https://github.com/storytold/cadcraft/releases) has earlier versions and their notes. `<ver>` in the file names is the version number, and `SHA256SUMS.txt` lists a checksum for every file. **Download CADCraft** from GitHub: the [latest release](https://github.com/storytold/cadcraft/releases/latest) has every build listed below, and [all releases](https://github.com/storytold/cadcraft/releases) has earlier versions and their notes. `<ver>` in the file names is the version number, and `SHA256SUMS.txt` lists a checksum for every file.
> [!WARNING]
> Download official CADCraft builds from the [storytold/cadcraft releases](https://github.com/storytold/cadcraft/releases). Verify that the repository owner is `storytold`. Before you run a download, check that its checksum matches the line for that file in the release's `SHA256SUMS.txt`:
>
> ```sh
> shasum -a 256 cadcraft-<ver>-macos-universal.dmg # macOS (on Linux: sha256sum FILE)
> Get-FileHash .\cadcraft-<ver>-windows-x64.msi # Windows PowerShell
> ```
### Windows ### Windows
| Build | Installer | Portable | | Build | Installer | Portable |
@@ -296,6 +314,7 @@ See [ROADMAP.md](ROADMAP.md) for the stage, measured parity and the estimate of
Installers and executables are code-signed. Installers and executables are code-signed.
The installer adds CADCraft to the Start menu and asks whether to add a desktop shortcut too (unticked by default). For a silent install with the desktop shortcut: `msiexec /i cadcraft-<ver>-windows-x64.msi /qn DESKTOPSHORTCUT=1`. The installer adds CADCraft to the Start menu and asks whether to add a desktop shortcut too (unticked by default). For a silent install with the desktop shortcut: `msiexec /i cadcraft-<ver>-windows-x64.msi /qn DESKTOPSHORTCUT=1`.
A silent install is per-user by default; add `ALLUSERS=1`, from an elevated terminal or deployment tool, for an all-users install.
**If the app doesn't open on Windows:** the desktop app initializes only DirectX 12 by default. **If the app doesn't open on Windows:** the desktop app initializes only DirectX 12 by default.
Letting wgpu also create an OpenGL instance can crash some graphics drivers (AMD's Letting wgpu also create an OpenGL instance can crash some graphics drivers (AMD's
+3 -1
View File
@@ -2,7 +2,7 @@
**Stage: pre-alpha** · next: alpha, ~2 points (38% → 40% ready for real work) and ~125–190 Opus 5.5 hours away (the four failing core workflows of the [alpha gate](docs/roadmap.md#alpha-gate)) **Stage: pre-alpha** · next: alpha, ~2 points (38% → 40% ready for real work) and ~125–190 Opus 5.5 hours away (the four failing core workflows of the [alpha gate](docs/roadmap.md#alpha-gate))
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (HATCHEDIT boundary add/remove progress) · **Target:** Autodesk AutoCAD 2027 > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** trivial (progress log: proxy objects keep their class through a save, #1812; previously minor: HATCHEDIT boundary add/remove progress) · **Target:** Autodesk AutoCAD 2027
CADCraft targets full parity with AutoCAD (2D drafting first, then annotation, layouts and CADCraft targets full parity with AutoCAD (2D drafting first, then annotation, layouts and
plotting, DWG, parametrics and 3D), plus what AutoCAD doesn't have: agent control over MCP, a plotting, DWG, parametrics and 3D), plus what AutoCAD doesn't have: agent control over MCP, a
@@ -119,6 +119,7 @@ Ranked; detail and the full beta checklist in [docs/roadmap.md](docs/roadmap.md)
| Date | Entry | | Date | Entry |
|---|---| |---|---|
| 2026-10-21 | DXF save keeps each proxy object's and proxy entity's class: its class number (group 91) is renumbered to the rewritten CLASSES instead of naming another class, so AutoCAD's visual styles in an R14 file no longer come back as SCALE or MLEADERSTYLE (#1812) |
| 2026-10-14 | HATCHEDIT adds boundaries from internal points or closed objects and removes selected loops in the dialog, command line and JSON command; associative links and undo are covered by tests. | | 2026-10-14 | HATCHEDIT adds boundaries from internal points or closed objects and removes selected loops in the dialog, command line and JSON command; associative links and undo are covered by tests. |
| 2026-10-12 | TRIM: a pick after a closed polyline's last cut keeps only the complementary run instead of an extra circuit of the outline (#726) | | 2026-10-12 | TRIM: a pick after a closed polyline's last cut keeps only the complementary run instead of an extra circuit of the outline (#726) |
| 2026-10-12 | HATCHEDIT refuses an explicit bad `scale` or `angle` before it edits anything, so a rejected request leaves the drawing, undo and redo as they were (#724) | | 2026-10-12 | HATCHEDIT refuses an explicit bad `scale` or `angle` before it edits anything, so a rejected request leaves the drawing, undo and redo as they were (#724) |
@@ -135,6 +136,7 @@ Ranked; detail and the full beta checklist in [docs/roadmap.md](docs/roadmap.md)
| Date | Change | Summary | | Date | Change | Summary |
|---|---|---| |---|---|---|
| 2026-10-21 | trivial | Progress log: proxy objects keep their class through a save (#1812) |
| 2026-10-14 | minor | Progress log: HATCHEDIT boundary add/remove | | 2026-10-14 | minor | Progress log: HATCHEDIT boundary add/remove |
| 2026-10-12 | trivial | Progress log: closed-polyline TRIM (#726) | | 2026-10-12 | trivial | Progress log: closed-polyline TRIM (#726) |
| 2026-10-12 | trivial | Progress log: HATCHEDIT numeric validation (#724) | | 2026-10-12 | trivial | Progress log: HATCHEDIT numeric validation (#724) |
+17
View File
@@ -6,6 +6,14 @@ edition.workspace = true
license.workspace = true license.workspace = true
publish = false publish = false
[lib]
name = "cadcraft_cli"
path = "src/lib.rs"
[[bin]]
name = "cadcraft-cli"
path = "src/main.rs"
[lints] [lints]
workspace = true workspace = true
@@ -14,4 +22,13 @@ cadcraft-engine = { workspace = true }
cadcraft-io = { workspace = true } cadcraft-io = { workspace = true }
cadcraft-mcp = { workspace = true } cadcraft-mcp = { workspace = true }
cadcraft-render = { workspace = true } cadcraft-render = { workspace = true }
clap = { workspace = true }
same-file = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
thiserror = { workspace = true }
[dev-dependencies]
# Shell completions and man pages (examples/man-and-completions.rs, for the Linux packages); not
# in the binary.
clap_complete = { workspace = true }
clap_mangen = { workspace = true }
@@ -0,0 +1,41 @@
//! Writes cadcraft-cli's shell completions ([`clap_complete`]: bash, zsh, fish, elvish,
//! PowerShell) and man pages ([`clap_mangen`]: `cadcraft-cli.1` and one per subcommand, such as
//! `cadcraft-cli-convert.1`) into a directory. The Linux packages ship them
//! (`packaging/linux/package.sh`):
//!
//! ```sh
//! cargo run -p cadcraft-cli --example man-and-completions -- <out-dir>
//! ```
use std::path::Path;
use std::process::ExitCode;
use clap::ValueEnum;
use clap_complete::Shell;
fn main() -> ExitCode {
let mut args = std::env::args_os().skip(1);
let (Some(dir), None) = (args.next(), args.next()) else {
eprintln!("usage: man-and-completions <out-dir>");
return ExitCode::from(2);
};
let dir = Path::new(&dir);
match generate(dir) {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("error: {}: {e}", dir.display());
ExitCode::FAILURE
}
}
}
pub(crate) fn generate(dir: &Path) -> std::io::Result<()> {
std::fs::create_dir_all(dir)?;
let mut cmd = cadcraft_cli::command();
for &shell in Shell::value_variants() {
clap_complete::generate_to(shell, &mut cmd, "cadcraft-cli", dir)?;
}
// A fresh command: `clap_complete::generate_to` built `cmd`, adding the `help` subcommand,
// which `clap_mangen` would otherwise give pages of its own (`cadcraft-cli-help-convert.1`).
clap_mangen::generate_to(cadcraft_cli::command(), dir)
}
+786
View File
@@ -0,0 +1,786 @@
//! The command line, as clap types. Values are parsed into their types here and the relations
//! between flags are checked, 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. A flag a subcommand
//! doesn't take is a usage error too, so a typo can't be silently ignored. A value flag never takes
//! the next flag as its value: a value starting with `-` is written `--save=-out.dxf` (or
//! `./-out.dxf`).
use std::ffi::{OsStr, OsString};
use std::path::{Path, PathBuf};
use cadcraft_engine::geom::{Bounds2, Vec2};
use cadcraft_io::Format;
use cadcraft_mcp::args::{blank_env_is_unset, name_env_vars};
use clap::builder::{OsStringValueParser, PathBufValueParser, TypedValueParser};
use clap::error::ErrorKind;
use clap::parser::ValueSource;
use clap::{ArgAction, ArgGroup, ArgMatches, Args, CommandFactory, FromArgMatches, Parser, Subcommand, ValueEnum};
use serde_json::Value;
/// Help text that names the formats of `list`, built once from [`Format::READ`] or [`Format::WRITE`]
/// so it can't drift from what the code accepts.
fn with_formats(cell: &'static std::sync::OnceLock<String>, before: &str, list: &[Format], after: &str) -> &'static str {
cell.get_or_init(|| format!("{before}({}){after}", Format::list(list)))
}
/// The drawing an input argument names.
fn read_help() -> &'static str {
static C: std::sync::OnceLock<String> = std::sync::OnceLock::new();
with_formats(&C, "The drawing to read ", Format::READ, "")
}
fn open_help() -> &'static str {
static C: std::sync::OnceLock<String> = std::sync::OnceLock::new();
with_formats(&C, "Drawings to open ", Format::READ, "")
}
fn write_help() -> &'static str {
static C: std::sync::OnceLock<String> = std::sync::OnceLock::new();
with_formats(&C, "The file to write; its extension picks the format ", Format::WRITE, "")
}
fn save_help() -> &'static str {
static C: std::sync::OnceLock<String> = std::sync::OnceLock::new();
with_formats(&C, "Save the drawing to this file; its extension picks the format ", Format::WRITE, "")
}
fn export_help() -> &'static str {
static C: std::sync::OnceLock<String> = std::sync::OnceLock::new();
with_formats(&C, "Export the drawing to this file; its extension picks the format ", Format::WRITE, "")
}
fn convert_about() -> &'static str {
static C: std::sync::OnceLock<String> = std::sync::OnceLock::new();
with_formats(&C, "Convert a drawing; OUT's extension picks the format ", Format::WRITE, "")
}
fn convert_long_about() -> &'static str {
static C: std::sync::OnceLock<String> = std::sync::OnceLock::new();
C.get_or_init(|| {
format!(
"{}\n\nImage output (png 2400x1600, svg, pdf) shows model space, fitted to the drawing extents unless\n--window or --view says otherwise.",
convert_about()
)
})
}
/// cadcraft-cli: headless CADCraft
#[derive(Parser)]
#[command(name = "cadcraft-cli", version = cadcraft_engine::build_version(), arg_required_else_help = true)]
pub(crate) struct Cli {
#[command(subcommand)]
pub(crate) command: Command,
}
#[derive(Subcommand)]
pub(crate) enum Command {
Info(InfoArgs),
Convert(ConvertArgs),
Run(RunArgs),
Commands(CommandsArgs),
Mcp(McpArgs),
Perf(PerfArgs),
Sample(SampleArgs),
}
/// Print a drawing's summary as JSON
#[derive(Args)]
pub(crate) struct InfoArgs {
#[arg(help = read_help())]
pub(crate) file: PathBuf,
/// One line of JSON instead of pretty-printed
#[arg(long)]
pub(crate) compact: bool,
}
/// Convert a drawing; OUT's extension picks the format
#[derive(Args)]
#[command(about = convert_about(), long_about = convert_long_about())]
pub(crate) struct ConvertArgs {
#[arg(value_name = "IN", help = read_help())]
pub(crate) input: PathBuf,
#[arg(value_name = "OUT", help = write_help())]
pub(crate) output: PathBuf,
#[command(flatten)]
pub(crate) framing: FramingArgs,
/// Allow an output that is an input file, replacing the original
#[arg(long)]
pub(crate) in_place: bool,
#[command(flatten)]
pub(crate) settings: Settings,
}
/// The settings the desktop app reads from its environment, for the subcommands that draw or run
/// commands. A variable is the flag's fallback; an empty one counts as unset, except
/// `CADCRAFT_CONFIG_DIR`.
#[derive(Args)]
#[command(next_help_heading = "Settings")]
pub(crate) struct Settings {
/// The settings folder; plotter configurations are in its `plotters` folder (default: the
/// platform's settings folder). An empty value means no settings folder
// Not RFC-0012 rule 1.7's "an empty variable counts as unset": an empty value is the
// documented way to run with no settings folder (isolated runs, tests), and it predates the
// CLI port. Keep it out of any blanket treatment of empty variables.
#[arg(long, value_name = "DIR", env = "CADCRAFT_CONFIG_DIR", value_parser = OsStringValueParser::new().map(PathBuf::from))]
pub(crate) config_dir: Option<PathBuf>,
/// The font that replaces a missing one (FONTALT)
#[arg(long, value_name = "FONT", env = "CADCRAFT_FONTALT")]
pub(crate) fontalt: Option<String>,
/// The fonts tried, in order, for characters a font lacks, comma-separated (FONTFALLBACK)
#[arg(long, value_name = "FONTS", env = "CADCRAFT_FONTFALLBACK")]
pub(crate) fontfallback: Option<String>,
}
impl Settings {
/// Install the settings for this run; an unset one is the default. An empty or blank
/// `--config-dir` is no settings folder.
pub(crate) fn apply(&self) {
cadcraft_engine::settings_dir::set(self.settings_dir());
cadcraft_engine::fonts::ttf::set_font_alt(self.fontalt.as_deref().unwrap_or(""));
cadcraft_engine::fonts::ttf::set_fallback_fonts(self.fontfallback.as_deref().unwrap_or(""));
}
/// The settings folder: `--config-dir`, else the platform's; `None` when the flag is empty or
/// blank.
fn settings_dir(&self) -> Option<PathBuf> {
match &self.config_dir {
Some(dir) if dir.to_str().is_some_and(|d| d.trim().is_empty()) => None,
Some(dir) => Some(dir.clone()),
None => cadcraft_engine::settings_dir::platform(|k| std::env::var_os(k)),
}
}
}
// What part of model space an image shows: shared by `convert` and `run`.
#[derive(Args)]
pub(crate) struct FramingArgs {
/// Show this rectangle (drawing units, x0<x1, y0<y1), centred and scaled to fit the image
/// without distortion. A negative x0 needs the `=` form: --window=-10,0,10,5
#[arg(long, value_name = "x0,y0,x1,y1", value_parser = parse_window, conflicts_with = "view")]
pub(crate) window: Option<Bounds2>,
/// What part of model space the image shows (case-insensitive)
#[arg(long, value_name = "extents|current", value_enum, ignore_case = true)]
pub(crate) view: Option<ViewMode>,
}
/// `--view`.
#[derive(Clone, Copy, Debug, PartialEq, ValueEnum)]
pub(crate) enum ViewMode {
/// Always fit the drawing extents
Extents,
/// The current view, as last set by zoom, pan or view.set (a freshly opened drawing's view is
/// its extents plus a 10% margin)
Current,
}
/// Open drawings (or start a new one), run scripts and commands in order, save or export the result
///
/// Drawings open in the order given, and the last one is current; without any, a new imperial
/// drawing is used. --script, --script-file and --cmd steps run in the order given, and each
/// --cmd prints its JSON result on one line (JSON lines); a command with no result prints nothing.
/// An image --export (png 2400x1600, svg, pdf) shows model space:
/// the current view if the steps changed it (zoom, pan, view.set…), otherwise the drawing
/// extents, unless --window or --view says otherwise.
#[derive(Args)]
#[command(group(ArgGroup::new("framing").args(["window", "view"]).multiple(true).requires("export")))]
#[command(after_long_help = r#"--cmd chaining:
A --cmd parameter value of exactly "{last}" is replaced with the last object handle an
earlier --cmd in the same run returned, so a command can act on what the previous one made:
--cmd 'mview {"p1":[20,20],"p2":[200,150]}' prints {"viewports":["100"]}
--cmd 'viewport.set {"handle":"{last}","scale":1}' changes viewport 100
It works in any parameter, at any depth (e.g. "handles":["{last}"]); every other string is
passed unchanged. With no earlier returned handle the run fails and says so."#)]
pub(crate) struct RunArgs {
#[arg(value_name = "FILE", help = open_help())]
pub(crate) files: Vec<PathBuf>,
/// Open the built-in sample drawing
#[arg(long)]
pub(crate) sample: bool,
/// Start a new metric drawing
#[arg(long)]
pub(crate) metric: bool,
/// Command-line script text to run (`\n` separates lines)
#[arg(long, value_name = "TEXT", action = ArgAction::Append)]
pub(crate) script: Vec<String>,
/// A script file (.scr) to run
#[arg(long, value_name = "F.scr", action = ArgAction::Append)]
pub(crate) script_file: Vec<PathBuf>,
/// An engine command and its JSON parameters (default {}); see "--cmd chaining" in --help
#[arg(long, value_name = "id {json}", action = ArgAction::Append, value_parser = cmd_step)]
pub(crate) cmd: Vec<CmdStep>,
#[arg(long, value_name = "OUT.dxf", help = save_help())]
pub(crate) save: Option<PathBuf>,
#[arg(long, value_name = "OUT.png", help = export_help())]
pub(crate) export: Option<PathBuf>,
#[command(flatten)]
pub(crate) framing: FramingArgs,
/// Allow an output that is an input file, replacing the original
#[arg(long)]
pub(crate) in_place: bool,
#[command(flatten)]
pub(crate) settings: Settings,
/// The drawings, in command-line order, set by [`parse`].
#[arg(skip)]
pub(crate) drawings: Vec<Drawing>,
/// The steps, in command-line order, set by [`parse`].
#[arg(skip)]
pub(crate) steps: Vec<Step>,
}
/// A drawing `run` opens.
#[derive(Clone, Debug, PartialEq)]
pub(crate) enum Drawing {
File(PathBuf),
Sample,
Metric,
}
/// A `--script`, `--script-file` or `--cmd` of `run`.
#[derive(Clone, Debug, PartialEq)]
pub(crate) enum Step {
Script(String),
ScriptFile(PathBuf),
Cmd(CmdStep),
}
/// `--cmd 'id {json}'`: an engine command and its parameters. `{last}` placeholders are filled in
/// when it runs.
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct CmdStep {
pub(crate) id: String,
pub(crate) params: Value,
}
/// Print the command catalog as JSON
#[derive(Args)]
pub(crate) struct CommandsArgs {
/// Only the commands whose id or label contains this text (case-insensitive)
pub(crate) filter: Option<String>,
/// One line of JSON instead of pretty-printed
#[arg(long)]
pub(crate) compact: bool,
}
/// Run the MCP server on stdio: a headless drawing session, or a bridge to the running app
#[derive(Args)]
pub(crate) struct McpArgs {
/// Bridge to the app's control channel (its --control port, e.g. 127.0.0.1:7979)
#[arg(long, value_name = "HOST:PORT", value_parser = connect_addr)]
pub(crate) connect: Option<String>,
/// Reach the app at the first call instead of at startup, so the server can start before the
/// app is open
#[arg(long, requires = "connect")]
pub(crate) lazy: bool,
/// Like --lazy, and a call that finds nothing at HOST:PORT opens the app with that control
/// port first (macOS)
#[arg(long, requires = "connect")]
pub(crate) launch: bool,
#[command(flatten)]
pub(crate) settings: Settings,
}
/// Time the engine on a synthetic N-entity drawing (default 200000), or the canvas display-list
/// build of a drawing, zoomed in step by step
#[derive(Args)]
pub(crate) struct PerfArgs {
/// An entity count (clamped to 1-20000000), or a drawing to time
#[arg(value_name = "N|FILE", value_parser = PathBufValueParser::new().map(perf_target))]
pub(crate) target: Option<PerfTarget>,
}
/// What `perf` times.
#[derive(Clone, Debug, PartialEq)]
pub(crate) enum PerfTarget {
/// A synthetic drawing with this many entities, within [`MAX_PERF_COUNT`].
Count(usize),
File(PathBuf),
}
/// The largest synthetic drawing `perf` builds.
const MAX_PERF_COUNT: usize = 20_000_000;
/// `perf N|FILE`: a value that reads as a count is one, clamped to 1..=[`MAX_PERF_COUNT`] so `perf
/// 0` still times something; anything else is a drawing.
fn perf_target(p: PathBuf) -> PerfTarget {
match p.to_str().and_then(|t| t.parse::<usize>().ok()) {
Some(n) => PerfTarget::Count(n.clamp(1, MAX_PERF_COUNT)),
None => PerfTarget::File(p),
}
}
/// Write a built-in sample drawing
#[derive(Args)]
pub(crate) struct SampleArgs {
/// Which sample
#[arg(value_name = "bracket|floorplan", value_enum)]
pub(crate) name: SampleName,
#[arg(value_name = "OUT", help = write_help())]
pub(crate) output: PathBuf,
#[command(flatten)]
pub(crate) settings: Settings,
}
/// `sample`'s drawings.
#[derive(Clone, Copy, Debug, PartialEq, ValueEnum)]
pub(crate) enum SampleName {
Bracket,
#[value(alias = "floor")]
Floorplan,
}
/// What part of model space an image export shows.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) enum Framing {
/// `run`: the current view if the commands changed it, else the extents. `convert`: extents.
Auto,
Extents,
Current,
Window(Bounds2),
}
/// Why a `--window` value is refused: clap prints it after the flag and its value (exit 2).
#[derive(Debug, thiserror::Error)]
pub(crate) enum WindowError {
#[error("expected four numbers x0,y0,x1,y1")]
NeedsFour,
#[error("{name} must be a finite number, got `{value}`")]
NotFinite { name: &'static str, value: String },
#[error("need x0 < x1 and y0 < y1 (lower-left corner first)")]
Order,
#[error(transparent)]
Limits(#[from] cadcraft_io::IoError),
}
/// Why a value is refused: clap prints it after the flag and its value (exit 2).
#[derive(Debug, thiserror::Error)]
pub(crate) enum ValueError {
#[error("expected HOST:PORT (the app's --control port, e.g. 127.0.0.1:7979)")]
Connect,
#[error("expected a command id, then optionally its JSON parameters")]
CmdId,
#[error(transparent)]
CmdJson(#[from] serde_json::Error),
}
/// Why `parse` refuses flags clap accepted: clap prints it as a usage error (exit 2).
#[derive(Debug, thiserror::Error)]
enum UsageError {
/// The framing flags choose what an image shows; a drawing file has nothing to frame.
#[error("{flag} only applies to image output (png, svg, pdf), not `{}`", .out.display())]
FramingNeedsImage { flag: &'static str, out: PathBuf },
/// The output is one of the input files, so writing it would replace the original.
#[error("{} is an input file, so the result would replace the original: choose another output, or pass --in-place to overwrite it", .0.display())]
OutIsInput(PathBuf),
}
impl UsageError {
fn into_clap(self, cmd: &mut clap::Command) -> clap::Error {
let kind = match self {
Self::FramingNeedsImage { .. } | Self::OutIsInput(_) => ErrorKind::ArgumentConflict,
};
cmd.error(kind, self)
}
}
/// Parse `--window x0,y0,x1,y1` (min corner first). Hostile values are errors, never panics.
pub(crate) fn parse_window(text: &str) -> Result<Bounds2, WindowError> {
let parts: Vec<&str> = text.split(',').map(str::trim).collect();
let [x0, y0, x1, y1] = parts.as_slice() else {
return Err(WindowError::NeedsFour);
};
let num = |name: &'static str, v: &str| -> Result<f64, WindowError> {
match v.parse::<f64>() {
Ok(n) if n.is_finite() => Ok(n),
_ => Err(WindowError::NotFinite { name, value: v.to_string() }),
}
};
let (x0, y0, x1, y1) = (num("x0", x0)?, num("y0", y0)?, num("x1", x1)?, num("y1", y1)?);
if !(x0 < x1 && y0 < y1) {
return Err(WindowError::Order);
}
let w = Bounds2 { min: Vec2::new(x0, y0), max: Vec2::new(x1, y1) };
cadcraft_io::check_window(&w)?;
Ok(w)
}
/// `run --cmd 'id {json}'`: the id up to the first space, then JSON parameters (default `{}`).
/// Whether they suit the command is the command's to say.
fn cmd_step(s: &str) -> Result<CmdStep, ValueError> {
let (id, json) = s.split_once(' ').unwrap_or((s, "{}"));
if id.is_empty() {
return Err(ValueError::CmdId);
}
Ok(CmdStep { id: id.to_string(), params: serde_json::from_str(json)? })
}
impl FramingArgs {
/// The framing `--window` or `--view` asks for (clap refuses both together).
pub(crate) fn framing(&self) -> Framing {
match (self.window, self.view) {
(Some(w), _) => Framing::Window(w),
(None, Some(ViewMode::Extents)) => Framing::Extents,
(None, Some(ViewMode::Current)) => Framing::Current,
(None, None) => Framing::Auto,
}
}
/// The framing flag given, if any.
fn flag(&self) -> Option<&'static str> {
if self.window.is_some() {
Some("--window")
} else if self.view.is_some() {
Some("--view")
} else {
None
}
}
}
/// A framing flag given with an output that isn't an image (the extension decides). `run` without
/// `--export` is clap's `requires` error.
fn framing_error(cli: &Cli) -> Option<UsageError> {
let (framing, out): (&FramingArgs, Option<&Path>) = match &cli.command {
Command::Convert(c) => (&c.framing, Some(&c.output)),
Command::Run(r) => (&r.framing, r.export.as_deref()),
_ => return None,
};
let (flag, out) = (framing.flag()?, out?);
(!cadcraft_io::is_image(out)).then(|| UsageError::FramingNeedsImage { flag, out: out.to_path_buf() })
}
/// An output that is the same file as an input, without `--in-place`. An output that doesn't exist
/// can't be an input, and an I/O error in the comparison is left to the open or write that follows.
fn out_is_input(cli: &Cli) -> Option<UsageError> {
let same = |input: &Path, out: &Path| out.exists() && same_file::is_same_file(input, out).unwrap_or(false);
match &cli.command {
Command::Convert(c) if !c.in_place && same(&c.input, &c.output) => Some(UsageError::OutIsInput(c.output.clone())),
Command::Run(r) if !r.in_place => {
let inputs = r.drawings.iter().filter_map(|d| if let Drawing::File(f) = d { Some(f) } else { None });
let inputs: Vec<&PathBuf> = inputs.collect();
[&r.save, &r.export].into_iter().flatten().find(|out| inputs.iter().any(|i| same(i, out))).map(|out| UsageError::OutIsInput(out.clone()))
}
_ => None,
}
}
/// The backend `cadcraft-cli mcp` serves from.
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum McpTarget {
/// An in-process drawing session (no `--connect`).
Headless,
/// The running app's control channel at `HOST:PORT`; `lazy`: reached at the first call;
/// `launch`: opened then if it isn't running.
Connect { addr: String, lazy: bool, launch: bool },
}
impl McpArgs {
pub(crate) fn target(&self) -> McpTarget {
match &self.connect {
// `--launch` connects at the first call too (the app opens then).
Some(addr) => McpTarget::Connect { addr: addr.clone(), lazy: self.lazy || self.launch, launch: self.launch },
None => McpTarget::Headless,
}
}
}
/// `mcp --connect`: an empty, flag-like or port-less address is an error, never a silent fall back
/// to a headless session (#103). The host is not resolved here.
fn connect_addr(s: &str) -> Result<String, ValueError> {
let s = s.trim();
let valid = !s.starts_with('-')
&& s.rsplit_once(':').is_some_and(|(host, port)| {
let host = host.strip_prefix('[').and_then(|h| h.strip_suffix(']')).unwrap_or(host);
!host.is_empty() && port.parse::<u16>().is_ok()
});
if valid { Ok(s.to_string()) } else { Err(ValueError::Connect) }
}
/// The values of `id` with their command-line indices.
fn indexed<'a, T: Clone + Send + Sync + 'static>(m: &'a ArgMatches, id: &str) -> impl Iterator<Item = (usize, &'a T)> {
let at = m.indices_of(id).into_iter().flatten();
let values = m.try_get_many::<T>(id).ok().flatten().into_iter().flatten();
at.zip(values)
}
/// The command-line index of flag `id`, when it was given.
fn flag_index(m: &ArgMatches, id: &str) -> Option<usize> {
if m.value_source(id) == Some(ValueSource::CommandLine) { m.indices_of(id).and_then(Iterator::last) } else { None }
}
/// `run`'s drawings and steps back in command-line order: clap keeps each flag's values apart,
/// their indices say how they interleave.
fn run_order(m: &ArgMatches) -> (Vec<Drawing>, Vec<Step>) {
let mut drawings: Vec<(usize, Drawing)> = indexed(m, "files").map(|(i, f): (_, &PathBuf)| (i, Drawing::File(f.clone()))).collect();
drawings.extend(flag_index(m, "sample").map(|i| (i, Drawing::Sample)));
drawings.extend(flag_index(m, "metric").map(|i| (i, Drawing::Metric)));
drawings.sort_by_key(|d| d.0);
let mut steps: Vec<(usize, Step)> = indexed(m, "script").map(|(i, s): (_, &String)| (i, Step::Script(s.clone()))).collect();
steps.extend(indexed(m, "script_file").map(|(i, s): (_, &PathBuf)| (i, Step::ScriptFile(s.clone()))));
steps.extend(indexed(m, "cmd").map(|(i, c): (_, &CmdStep)| (i, Step::Cmd(c.clone()))));
steps.sort_by_key(|s| s.0);
(drawings.into_iter().map(|d| d.1).collect(), steps.into_iter().map(|s| s.1).collect())
}
/// Parse the arguments (without the binary name). clap reads them as `OsString`s: a path need not
/// be valid Unicode, text must. A bare `--help` or `-h` is help even after a mistake on the same
/// line (`run --script --help`): clap reports the mistake first.
pub(crate) fn parse(args: &[OsString]) -> Result<Cli, clap::Error> {
parse_with(command(|k| std::env::var_os(k)), args)
}
/// The command line. An empty or blank variable counts as unset, except `CADCRAFT_CONFIG_DIR`:
/// empty means no settings folder. `var` looks a variable up.
fn command(var: impl Fn(&OsStr) -> Option<OsString> + Copy) -> clap::Command {
let blank = move |a: clap::Arg| if a.get_id() == "config_dir" { a } else { blank_env_is_unset(a, var) };
Cli::command().mut_args(blank).mut_subcommands(|s| s.mut_args(blank))
}
/// [`parse`] with `cmd` from [`command`]; a bad value from a variable is a usage error naming the
/// variable.
fn parse_with(mut cmd: clap::Command, args: &[OsString]) -> Result<Cli, clap::Error> {
let argv = std::iter::once(OsString::from("cadcraft-cli")).chain(args.iter().cloned());
let matches = match cmd.try_get_matches_from_mut(argv) {
// `use_stderr`: a real error, not the help `-h` asked for (a retry would make it `--help`).
Err(e) if e.use_stderr() && args.iter().take_while(|a| *a != "--").any(|a| *a == "--help" || *a == "-h") => {
let help = [OsString::from("cadcraft-cli"), args.first().cloned().unwrap_or_default(), OsString::from("--help")];
return Err(cmd.try_get_matches_from_mut(help).err().unwrap_or(e));
}
matches => matches.map_err(|e| name_env_vars(e, &cmd, args))?,
};
let mut cli = Cli::from_arg_matches(&matches)?;
if let (Command::Run(r), Some((_, m))) = (&mut cli.command, matches.subcommand()) {
(r.drawings, r.steps) = run_order(m);
}
if let Some(e) = framing_error(&cli).or_else(|| out_is_input(&cli)) {
if let Some(sub) = matches.subcommand_name().and_then(|n| cmd.find_subcommand_mut(n)) {
return Err(e.into_clap(sub));
}
return Err(e.into_clap(&mut cmd));
}
Ok(cli)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn parse_args(a: &[&str]) -> Result<Cli, clap::Error> {
parse(&a.iter().map(OsString::from).collect::<Vec<_>>())
}
fn mcp(a: &[&str]) -> Result<McpTarget, clap::Error> {
let args: Vec<&str> = std::iter::once("mcp").chain(a.iter().copied()).collect();
match parse_args(&args)?.command {
Command::Mcp(m) => Ok(m.target()),
_ => panic!("{a:?} is not mcp"),
}
}
/// Every file argument's help names each format of the list it uses.
#[test]
fn help_names_every_format() {
let cli = Cli::command();
let help = |sub: &str, arg: &str| {
let a = cli.find_subcommand(sub).and_then(|c| c.get_arguments().find(|a| a.get_id() == arg)).expect("argument exists");
a.get_help().map(|h| h.to_string()).unwrap_or_default()
};
let cases: [(&str, &str, &[Format]); 8] = [
("info", "file", Format::READ),
("convert", "input", Format::READ),
("convert", "output", Format::WRITE),
("run", "files", Format::READ),
("run", "save", Format::WRITE),
("run", "export", Format::WRITE),
("sample", "output", Format::WRITE),
("convert", "", Format::WRITE),
];
for (sub, arg, list) in cases {
let text = if arg.is_empty() {
cli.find_subcommand(sub).and_then(|c| c.get_about()).map(|a| a.to_string()).unwrap_or_default()
} else {
help(sub, arg)
};
for f in list {
assert!(text.contains(f.extension()), "`{sub} {arg}` help `{text}` lacks {}", f.extension());
}
}
}
#[test]
fn mcp_without_connect_is_headless() {
assert_eq!(mcp(&[]).unwrap(), McpTarget::Headless);
}
#[test]
fn mcp_connect_with_address_selects_remote() {
let to = |addr: &str, lazy, launch| McpTarget::Connect { addr: addr.into(), lazy, launch };
assert_eq!(mcp(&["--connect", "127.0.0.1:7979"]).unwrap(), to("127.0.0.1:7979", false, false));
assert_eq!(mcp(&["--connect", " localhost:7979 "]).unwrap(), to("localhost:7979", false, false));
assert_eq!(mcp(&["--lazy", "--connect", "localhost:7979"]).unwrap(), to("localhost:7979", true, false));
assert_eq!(mcp(&["--connect", "localhost:7979", "--launch"]).unwrap(), to("localhost:7979", true, true));
assert_eq!(mcp(&["--connect", "[::1]:7979"]).unwrap(), to("[::1]:7979", false, false));
// Without an app to reach they mean nothing: never a headless session.
for flag in ["--lazy", "--launch"] {
assert_eq!(mcp(&[flag]).unwrap_err().kind(), clap::error::ErrorKind::MissingRequiredArgument, "{flag}");
}
}
#[test]
fn mcp_connect_without_address_is_an_error_not_headless() {
for bad in [
&["--connect"][..],
&["--connect", ""],
&["--connect", " "],
&["--connect=-v"],
&["--connect", "example.com"],
&["--connect", ":7979"],
&["--connect", "host:99999"],
&["--connect", "[]:1"],
] {
let r = mcp(bad);
let Err(e) = r else { panic!("{bad:?} should be rejected, got {r:?}") };
assert_eq!(e.exit_code(), 2, "{bad:?}");
assert!(e.to_string().contains("--connect <HOST:PORT>"), "{bad:?}: {e}");
}
}
#[test]
fn mcp_rejects_unknown_and_repeated_arguments() {
assert!(mcp(&["--conect", "127.0.0.1:7979"]).is_err());
assert!(mcp(&["127.0.0.1:7979"]).is_err());
// clap names the unknown flag, not the value it left missing.
for flag in ["--foo", "-v"] {
assert_eq!(mcp(&["--connect", flag]).unwrap_err().kind(), clap::error::ErrorKind::UnknownArgument, "{flag}");
}
assert!(mcp(&["--connect", "127.0.0.1:1", "--connect", "127.0.0.1:2"]).is_err());
}
#[test]
fn run_keeps_command_line_order() {
let args =
["run", "a.dxf", "--script", "S1", "--metric", "--cmd", "c1", "b.dxf", "--script-file", "f.scr", "--sample", "--cmd", "c2 {\"n\":1}"];
let Command::Run(r) = parse_args(&args).unwrap().command else { panic!("not run") };
let file = |f: &str| Drawing::File(f.into());
let cmd = |id: &str, params| Step::Cmd(CmdStep { id: id.into(), params });
assert_eq!(r.drawings, [file("a.dxf"), Drawing::Metric, file("b.dxf"), Drawing::Sample]);
assert_eq!(r.steps, [Step::Script("S1".into()), cmd("c1", json!({})), Step::ScriptFile("f.scr".into()), cmd("c2", json!({"n": 1}))]);
}
#[test]
fn cmd_values() {
assert_eq!(cmd_step("line").unwrap(), CmdStep { id: "line".into(), params: json!({}) });
assert_eq!(cmd_step("line [1]").unwrap(), CmdStep { id: "line".into(), params: json!([1]) });
for bad in ["", " {}", "line {bad json", "line "] {
assert!(cmd_step(bad).is_err(), "`{bad}` should be rejected");
}
}
#[test]
fn perf_targets() {
let target = |a: &[&str]| -> Option<PerfTarget> {
let args: Vec<&str> = std::iter::once("perf").chain(a.iter().copied()).collect();
let Command::Perf(p) = parse_args(&args).unwrap().command else { panic!("not perf") };
p.target
};
assert_eq!(target(&[]), None);
assert_eq!(target(&["5"]), Some(PerfTarget::Count(5)));
assert_eq!(target(&["0"]), Some(PerfTarget::Count(1)));
assert_eq!(target(&["999999999"]), Some(PerfTarget::Count(MAX_PERF_COUNT)));
assert_eq!(target(&["a.dxf"]), Some(PerfTarget::File("a.dxf".into())));
assert_eq!(target(&["1e3"]), Some(PerfTarget::File("1e3".into())));
}
#[test]
fn framing_options() {
let framing = |out: &str, a: &[&str]| -> Result<Framing, clap::Error> {
let args: Vec<&str> = ["convert", "a.dxf", out].iter().chain(a).copied().collect();
let Command::Convert(c) = parse_args(&args)?.command else { panic!("not convert") };
Ok(c.framing.framing())
};
let b = |x0, y0, x1, y1| Bounds2::new(Vec2::new(x0, y0), Vec2::new(x1, y1));
assert_eq!(framing("b.png", &[]).unwrap(), Framing::Auto);
assert_eq!(framing("b.png", &["--view", "extents"]).unwrap(), Framing::Extents);
assert_eq!(framing("b.png", &["--view", "CURRENT"]).unwrap(), Framing::Current);
assert_eq!(framing("b.png", &["--window", "0,0,2,1"]).unwrap(), Framing::Window(b(0.0, 0.0, 2.0, 1.0)));
assert_eq!(framing("b.dxf", &[]).unwrap(), Framing::Auto);
let kind = |out: &str, a: &[&str]| framing(out, a).map(|_| ()).unwrap_err().kind();
assert_eq!(kind("b.png", &["--view", "selection"]), ErrorKind::InvalidValue);
assert_eq!(kind("b.png", &["--window", "0,0,2,1", "--view", "extents"]), ErrorKind::ArgumentConflict);
assert_eq!(kind("b.dxf", &["--view", "extents"]), ErrorKind::ArgumentConflict);
assert_eq!(kind("b.dxf", &["--window", "0,0,2,1"]), ErrorKind::ArgumentConflict);
}
#[test]
fn window_parses() {
let b = |x0, y0, x1, y1| Bounds2::new(Vec2::new(x0, y0), Vec2::new(x1, y1));
assert_eq!(parse_window("0,0,30,20").unwrap(), b(0.0, 0.0, 30.0, 20.0));
assert_eq!(parse_window(" -5.5, -1e3 ,2e3,4 ").unwrap(), b(-5.5, -1000.0, 2000.0, 4.0));
}
#[test]
fn hostile_window_is_an_error() {
for w in [
"",
"1,2,3",
"1,2,3,4,5",
"a,0,1,1",
"NaN,0,1,1",
"0,0,inf,1",
"0,0,1e400,1",
"0,0,0,1",
"0,0,1,0",
"5,0,1,1",
"0,5,1,1",
"-1e300,-1e300,1e300,1e300",
"0,0,1e-12,1e-12",
] {
assert!(parse_window(w).is_err(), "`{w}` should be rejected");
}
}
/// The subcommands that draw or run commands take the settings flags; the others refuse them.
#[test]
fn settings_flags() {
let Command::Convert(c) =
parse_args(&["convert", "a.dxf", "b.png", "--config-dir", "/p", "--fontalt", "Noto Sans", "--fontfallback="]).unwrap().command
else {
panic!("not convert")
};
assert_eq!(c.settings.config_dir, Some(PathBuf::from("/p")));
assert_eq!((c.settings.fontalt.as_deref(), c.settings.fontfallback.as_deref()), (Some("Noto Sans"), Some("")));
for empty in ["--config-dir=", "--config-dir= "] {
let Command::Run(r) = parse_args(&["run", "--sample", empty]).unwrap().command else { panic!("not run") };
assert_eq!(r.settings.settings_dir(), None, "`{empty}` is no settings folder");
}
let Command::Convert(c) = parse_args(&["convert", "a.dxf", "b.png", "--config-dir", "/p"]).unwrap().command else { panic!("not convert") };
assert_eq!(c.settings.settings_dir(), Some(PathBuf::from("/p")));
assert!(parse_args(&["sample", "bracket", "b.dxf", "--fontalt", "x"]).is_ok());
assert!(parse_args(&["mcp", "--config-dir", "/p"]).is_ok());
assert!(parse_args(&["info", "a.dxf", "--config-dir", "/p"]).is_err());
}
#[test]
fn blank_variables_count_as_unset_except_the_config_dir() {
let cmd = command(|_| Some("".into()));
for sub in cmd.get_subcommands().filter(|s| s.get_arguments().any(|a| a.get_id() == "fontalt")) {
let env = |id: &str| sub.get_arguments().find(|a| a.get_id() == id).and_then(|a| a.get_env());
assert_eq!((env("fontalt"), env("fontfallback")), (None, None), "{}", sub.get_name());
assert_eq!(env("config_dir"), Some(OsStr::new("CADCRAFT_CONFIG_DIR")), "{}", sub.get_name());
}
}
/// `--fontalt` falls back to `PATH` and rejects every value here, as a bad value from a
/// variable would; tests can't set a variable (`set_var` is unsafe).
#[test]
fn a_bad_value_from_a_variable_names_the_variable() {
// Cargo sets CARGO_PKG_NAME for every test binary, so the variable always has a value.
let reject = clap::builder::PossibleValuesParser::new(["never"]);
let cmd = || Cli::command().mut_subcommand("sample", |s| s.mut_arg("fontalt", |a| a.env("CARGO_PKG_NAME").value_parser(reject.clone())));
let args = |a: &[&str]| a.iter().map(OsString::from).collect::<Vec<_>>();
let text = parse_with(cmd(), &args(&["sample"])).err().map(|e| e.to_string()).unwrap_or_default();
assert!(text.contains("for 'CARGO_PKG_NAME'") && !text.contains("--fontalt"), "{text}");
let text = parse_with(cmd(), &args(&["sample", "--fontalt", "x"])).err().map(|e| e.to_string()).unwrap_or_default();
assert!(text.contains("--fontalt"), "{text}");
}
}
+17
View File
@@ -0,0 +1,17 @@
use super::args::CommandsArgs;
use super::error::Result;
use super::output::Streams;
use cadcraft_engine::Session;
use serde_json::Value;
pub(crate) fn commands(a: &CommandsArgs, io: &mut Streams) -> Result {
let s = Session::new();
let f = a.filter.as_ref().map(|x| x.to_ascii_lowercase());
let v: Vec<Value> = cadcraft_engine::command_specs()
.iter()
.filter(|c| f.as_ref().is_none_or(|f| c.id.contains(f.as_str()) || c.label.to_ascii_lowercase().contains(f.as_str())))
.map(|c| serde_json::to_value(c.info(&s)).unwrap_or_default())
.collect();
io.json(&Value::Array(v), a.compact);
Ok(())
}
+22
View File
@@ -0,0 +1,22 @@
use super::args::ConvertArgs;
use super::error::Result;
use super::output::{Streams, export_framed, export_window, skipped_message, undrawn_message};
use super::{open, unicode};
use cadcraft_engine::Session;
pub(crate) fn convert(a: &ConvertArgs, io: &mut Streams) -> Result {
let i = unicode(&a.input)?;
let framing = a.framing.framing();
let mut s = Session::empty();
open(&mut s, &a.input)?;
if let Some(m) = skipped_message(s.doc()?, i) {
io.errln(format_args!("{m}"));
}
if cadcraft_io::is_image(&a.output)
&& let Some(m) = undrawn_message(s.doc()?, i)
{
io.errln(format_args!("{m}"));
}
let window = export_window(&s, framing, None)?;
export_framed(&s, &a.output, window, io)
}
+29
View File
@@ -0,0 +1,29 @@
use crate::output::OutputError;
use crate::perf::PerfError;
use crate::run::RunError;
/// Why a subcommand failed: printed as `error: {e}`, exit 1.
#[derive(Debug, thiserror::Error)]
pub(crate) enum CliError {
#[error(transparent)]
Engine(#[from] cadcraft_engine::EngineError),
#[error(transparent)]
Doc(#[from] cadcraft_engine::doc::DocError),
#[error(transparent)]
Format(#[from] cadcraft_io::IoError),
#[error(transparent)]
Io(#[from] std::io::Error),
#[error(transparent)]
Run(#[from] RunError),
#[error(transparent)]
Output(#[from] OutputError),
#[error(transparent)]
Perf(#[from] PerfError),
/// The engine and io crates take file names as text.
#[error("{}: the file name is not valid Unicode", .0.display())]
NotUnicode(std::path::PathBuf),
#[error("cannot connect to {addr}: {source}")]
McpConnect { addr: String, source: std::io::Error },
}
pub(crate) type Result<T = (), E = CliError> = std::result::Result<T, E>;
+14
View File
@@ -0,0 +1,14 @@
use super::args::InfoArgs;
use super::error::Result;
use super::open;
use super::output::Streams;
use cadcraft_engine::Session;
use serde_json::json;
pub(crate) fn info(a: &InfoArgs, io: &mut Streams) -> Result {
let mut s = Session::empty();
open(&mut s, &a.file)?;
let v = s.execute("drawing.inspect", &json!({ "entities": false }))?;
io.json(&v, a.compact);
Ok(())
}
+315
View File
@@ -0,0 +1,315 @@
//! `cadcraft-cli`: headless CADCraft. The subcommands and their options are the clap types in
//! `args.rs`; `cadcraft-cli --help` lists them.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
use std::ffi::OsString;
use std::io::Write;
use cadcraft_engine::Session;
use serde_json::json;
mod args;
mod commands;
mod convert;
mod error;
mod info;
mod mcp;
mod output;
mod perf;
mod run;
mod sample;
#[cfg(test)]
use args::Framing;
use args::{Command, parse};
use clap::CommandFactory;
use commands::commands;
use convert::convert;
use error::Result;
use info::info;
use mcp::mcp;
use output::Streams;
#[cfg(test)]
use output::{export_window, model_views, write_atomic};
use perf::perf;
use run::run_cmds;
#[cfg(test)]
use run::{fill_last, returned_handle};
use sample::sample;
fn install_io() {
cadcraft_engine::cmd::file::set_io(cadcraft_engine::cmd::file::IoHooks {
read: |b, name| cadcraft_io::read(b, name).map_err(|e| e.to_string()),
write: |d, name| cadcraft_io::write(d, name).map_err(|e| e.to_string()),
plot: Some(|d, space, opts| cadcraft_io::plotter::plot(d, space, opts).map_err(|e| e.to_string())),
});
cadcraft_engine::cmd::file::set_write_as(|d, name, o| {
cadcraft_io::write_with(d, name, &cadcraft_io::SaveOptions { version: o.version, binary: o.binary }).map_err(|e| e.to_string())
});
// AutoLISP's entget, entmake and entmod see entities as DXF groups.
cadcraft_engine::cmd::file::set_entity_codec(cadcraft_engine::cmd::file::EntityCodec {
groups: cadcraft_io::entity_groups,
entity: cadcraft_io::entity_from_groups,
});
}
/// `path` as text, for the engine and io calls that take a file name as a string.
fn unicode(path: &std::path::Path) -> Result<&str> {
path.to_str().ok_or_else(|| error::CliError::NotUnicode(path.to_path_buf()))
}
fn open(s: &mut Session, path: &std::path::Path) -> Result {
s.execute("open", &json!({ "path": unicode(path)? }))?;
Ok(())
}
/// The command line's clap definition, for generating shell completions and man pages.
pub fn command() -> clap::Command {
args::Cli::command()
}
/// Run the CLI on its arguments (without the program name). Returns the process exit code (0 ok,
/// 1 failure, 2 usage). clap reads the arguments as they are: a value that is not valid Unicode
/// is a usage error, never a panic as with `std::env::args()`.
pub fn run<I, T>(args: I, out: &mut dyn Write, err: &mut dyn Write) -> i32
where
I: IntoIterator<Item = T>,
T: Into<OsString>,
{
let args: Vec<OsString> = args.into_iter().map(Into::into).collect();
// Help and `--version` (`help`, `-h`, `<subcommand> --help`, `-V`) come back as errors too,
// for `out`, exit 0: the subcommand never runs.
let cli = match parse(&args) {
Ok(cli) => cli,
Err(e) => {
let to: &mut dyn Write = if e.use_stderr() { err } else { out };
return match write!(to, "{}", e.render()) {
Err(w) if w.kind() != std::io::ErrorKind::BrokenPipe => {
let _ = writeln!(err, "error: can't write the output: {w}");
1
}
_ => e.exit_code(),
};
}
};
// The engine's hooks are set-once, so installing them on every call is harmless.
install_io();
match &cli.command {
Command::Convert(a) => a.settings.apply(),
Command::Run(a) => a.settings.apply(),
Command::Mcp(a) => a.settings.apply(),
Command::Sample(a) => a.settings.apply(),
Command::Info(_) | Command::Commands(_) | Command::Perf(_) => {}
}
let mut io = Streams::new(out, err);
let result = match &cli.command {
Command::Info(a) => info(a, &mut io),
Command::Convert(a) => convert(a, &mut io),
Command::Run(a) => run_cmds(a, &mut io),
Command::Commands(a) => commands(a, &mut io),
Command::Mcp(a) => mcp(a),
Command::Perf(a) => perf(a.target.as_ref(), &mut io),
Command::Sample(a) => sample(a, &mut io),
};
let mut code = 0;
if let Err(e) = result {
io.errln(format_args!("error: {e}"));
code = 1;
}
io.finish();
if io.stdout_failed() { 1 } else { code }
}
#[cfg(test)]
mod tests {
use super::*;
use cadcraft_engine::doc::Space;
use cadcraft_engine::geom::{Bounds2, Vec2};
fn scratch_dir(tag: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("cadcraft-cli-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("create scratch dir");
dir
}
struct FailingWriter(std::io::ErrorKind);
impl Write for FailingWriter {
fn write(&mut self, _: &[u8]) -> std::io::Result<usize> {
Err(self.0.into())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
#[test]
fn a_failed_write_of_help_exits_1_unless_the_pipe_is_closed() {
let mut err = Vec::new();
assert_eq!(run(["--help"], &mut FailingWriter(std::io::ErrorKind::StorageFull), &mut err), 1);
assert!(String::from_utf8_lossy(&err).starts_with("error: "));
assert_eq!(run(["--help"], &mut FailingWriter(std::io::ErrorKind::BrokenPipe), &mut Vec::new()), 0);
}
#[test]
fn write_atomic_replaces_file_and_leaves_no_staging_files() {
let dir = scratch_dir("replace");
let out = dir.join("a.dxf");
write_atomic(&out, b"old").expect("create");
write_atomic(&out, b"new").expect("overwrite");
assert_eq!(std::fs::read(&out).expect("read"), b"new");
assert_eq!(std::fs::read_dir(&dir).expect("list").count(), 1);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn write_atomic_failure_keeps_destination_and_cleans_up() {
let dir = scratch_dir("fail");
let out = dir.join("target");
std::fs::create_dir(&out).expect("dir as destination");
std::fs::write(out.join("keep"), b"x").expect("seed");
assert!(write_atomic(&out, b"new").is_err());
assert_eq!(std::fs::read(out.join("keep")).expect("kept"), b"x");
assert_eq!(std::fs::read_dir(&dir).expect("list").count(), 1);
let _ = std::fs::remove_dir_all(&dir);
}
fn b(x0: f64, y0: f64, x1: f64, y1: f64) -> Bounds2 {
Bounds2::new(Vec2::new(x0, y0), Vec2::new(x1, y1))
}
fn sample_session() -> Session {
let mut s = Session::empty();
s.open_drawing(cadcraft_engine::sample::default_sample(), "Bracket", None);
s
}
fn view_box(svg: &str) -> String {
let i = svg.find("viewBox=\"").map(|i| i + 9).unwrap_or(0);
svg[i..].split('"').next().unwrap_or("").to_string()
}
fn assert_near(a: Bounds2, e: Bounds2) {
let d = (a.min - e.min).len().max((a.max - e.max).len());
assert!(d < 1e-9, "{a:?} != {e:?}");
}
#[test]
fn last_placeholder_takes_the_previous_handle() {
let mut s = sample_session();
let r = s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap();
let h = returned_handle(&s, &r).expect("LINE returns its handle");
assert_eq!(fill_last(&json!({"handle": "{last}"}), Some(h)).unwrap(), json!({"handle": h.hex()}));
// Nested, as in a selection list.
assert_eq!(fill_last(&json!({"handles": ["{last}", "1"]}), Some(h)).unwrap(), json!({"handles": [h.hex(), "1"]}));
// Only the exact token: a name or a sentence that merely contains it stays as written.
for p in [json!("last"), json!("{last} "), json!({"name": "last"})] {
assert_eq!(fill_last(&p, Some(h)).unwrap(), p);
}
// Nothing returned yet: an error, not a token passed to the command as a bad handle.
assert!(fill_last(&json!({"handle": "{last}"}), None).unwrap_err().to_string().contains("{last}"));
}
/// Handles are read from a result by looking the strings up in the drawing: counts and names
/// are not mistaken for handles, and `mview`'s `viewports` works like `handle`/`handles`.
#[test]
fn returned_handle_reads_only_real_handles() {
let mut s = Session::empty();
s.new_drawing(false);
s.execute("layout.set", &json!({"name": "Layout1"})).unwrap();
let r = s.execute("mview", &json!({"p1": [20, 20], "p2": [200, 150]})).unwrap();
let h = returned_handle(&s, &r).expect("mview returns its viewport handle");
assert!(r["viewports"].as_array().is_some_and(|a| a.iter().any(|v| v.as_str() == Some(h.hex().as_str()))), "{r}");
assert_eq!(returned_handle(&s, &json!({"changed": 3, "name": "A1"})), None);
// The most recent of several.
let a = s.execute("line", &json!({"points": [[0, 0], [1, 0]]})).unwrap();
let b = s.execute("line", &json!({"points": [[0, 0], [2, 0]]})).unwrap();
let (ha, hb) = (returned_handle(&s, &a).unwrap(), returned_handle(&s, &b).unwrap());
assert_eq!(returned_handle(&s, &json!({"handles": [ha.hex(), hb.hex()]})), Some(hb));
}
#[test]
fn default_framing_is_extents() {
// convert: nothing ran, always the extents.
let s = sample_session();
assert_eq!(export_window(&s, Framing::Auto, None).unwrap(), None);
// run: commands that leave the view alone keep the extents framing.
let mut s = sample_session();
let before = model_views(&s);
s.script("LINE 0,0 10,10\n\n").unwrap();
assert_eq!(export_window(&s, Framing::Auto, Some(&before)).unwrap(), None);
// A fresh drawing too.
let mut s = Session::empty();
s.new_drawing(false);
let before = model_views(&s);
s.execute("circle", &json!({"center": [0, 0], "radius": 5})).unwrap();
assert_eq!(export_window(&s, Framing::Auto, Some(&before)).unwrap(), None);
}
#[test]
fn zoom_window_frames_export() {
let mut s = sample_session();
let before = model_views(&s);
s.execute("zoom", &json!({"mode": "window", "p1": [0, 0], "p2": [30, 20]})).unwrap();
let w = export_window(&s, Framing::Auto, Some(&before)).unwrap().unwrap();
// The CLI viewport is 3:2, so a 3:2 window is shown exactly.
assert_near(w, b(0.0, 0.0, 30.0, 20.0));
assert_eq!(export_window(&s, Framing::Current, Some(&before)).unwrap(), Some(w));
// --view extents and --window override the zoom.
assert_eq!(export_window(&s, Framing::Extents, Some(&before)).unwrap(), None);
let explicit = b(1.0, 1.0, 2.0, 2.0);
assert_eq!(export_window(&s, Framing::Window(explicit), Some(&before)).unwrap(), Some(explicit));
// A taller window keeps its height; the 3:2 image adds room left and right.
s.execute("zoom", &json!({"mode": "center", "center": [5, 5], "height": 4})).unwrap();
assert_near(export_window(&s, Framing::Auto, Some(&before)).unwrap().unwrap(), b(2.0, 3.0, 8.0, 7.0));
}
#[test]
fn zoom_object_frames_the_selection() {
let mut s = sample_session();
let before = model_views(&s);
// The smallest entity with an area, selected then framed with ZOOM Object.
let d = s.doc().unwrap();
let bounds = |e: &cadcraft_engine::doc::Entity| cadcraft_engine::doc::entity_bounds(d, e, 0);
let e = d
.model
.iter()
.filter(|e| bounds(e).width() > 0.0 && bounds(e).height() > 0.0)
.min_by(|a, b| bounds(a).width().total_cmp(&bounds(b).width()))
.unwrap();
let (h, eb, ext) = (e.handle, bounds(e), d.extents(&Space::Model));
s.set_selection(vec![h]);
s.execute("zoom", &json!({"mode": "object"})).unwrap();
let w = export_window(&s, Framing::Auto, Some(&before)).unwrap().unwrap();
assert!(w.contains(eb.min) && w.contains(eb.max));
assert!(w.width() < ext.width() && w.height() < ext.height(), "{w:?} vs extents {ext:?}");
}
#[test]
fn run_export_honours_zoom() {
let dir = std::env::temp_dir().join(format!("cadcraft-cli-165-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = |n: &str| dir.join(n).to_string_lossy().to_string();
let run_svg = |extra: &[&str], out: &str| -> String {
let mut args: Vec<String> = ["run", "--sample"].iter().chain(extra).map(|a| a.to_string()).collect();
args.extend(["--export".to_string(), path(out)]);
assert_eq!(run(args, &mut Vec::new(), &mut Vec::new()), 0);
std::fs::read_to_string(path(out)).unwrap()
};
let base = run_svg(&[], "base.svg");
let zoomed = run_svg(&["--cmd", r#"zoom {"mode":"window","p1":[0,0],"p2":[30,20]}"#], "zoom.svg");
let window = run_svg(&["--window", "0,0,30,20"], "window.svg");
let forced = run_svg(&["--cmd", r#"zoom {"mode":"window","p1":[0,0],"p2":[30,20]}"#, "--view", "extents"], "forced.svg");
assert_ne!(view_box(&zoomed), view_box(&base));
assert_eq!(view_box(&zoomed), "0 0 30.000000 20.000000");
assert_eq!(zoomed, window);
assert_eq!(forced, base);
// Hostile --window: a usage error, nothing written.
let mut err = Vec::new();
assert_eq!(run(["run", "--sample", "--window", "0,0,NaN,1", "--export", &path("bad.png")], &mut Vec::new(), &mut err), 2);
assert!(String::from_utf8_lossy(&err).contains("--window"));
assert!(!dir.join("bad.png").exists());
let _ = std::fs::remove_dir_all(&dir);
}
}
+5 -884
View File
@@ -1,887 +1,8 @@
//! `cadcraft-cli`: headless CADCraft.
//!
//! ```text
//! cadcraft-cli info FILE.dxf summary as JSON
//! cadcraft-cli convert IN.dxf OUT.(dxf|svg|png) [--window x0,y0,x1,y1 | --view extents|current]
//! cadcraft-cli run [FILE|--sample|--metric] [--script TEXT|--script-file F.scr] [--cmd 'id {json}']... [--save OUT] [--export OUT.png] [--window ... | --view ...]
//! cadcraft-cli commands [FILTER] command catalog (JSON)
//! cadcraft-cli mcp [--connect HOST:PORT [--lazy] [--launch]] MCP server on stdio (headless or bridged to the app)
//! cadcraft-cli perf [N] timing table on a synthetic N-entity drawing
//! cadcraft-cli perf FILE canvas display-list build of a drawing, zoomed in step by step
//! cadcraft-cli sample (bracket|floorplan) OUT write a built-in sample drawing
//! ```
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
/// `println!` that doesn't panic when stdout is closed (`cadcraft-cli commands | head`) with fn main() {
/// "failed printing to stdout: Broken pipe (os error 32)". Once stdout has failed, later output is // `args_os`, not `args`: a non-Unicode argument (a Latin-1 file name on Linux) must be
/// dropped and the work goes on: see `stdout_failed`. // reported as an error, not panic before anything is printed.
macro_rules! outln { let code = cadcraft_cli::run(std::env::args_os().skip(1), &mut std::io::stdout(), &mut std::io::stderr());
($($arg:tt)*) => {{ std::process::exit(code);
use std::io::Write as _;
if !$crate::stdout_gone() {
if let Err(e) = writeln!(std::io::stdout(), $($arg)*) {
$crate::stdout_failed(e);
}
}
}};
}
use std::process::ExitCode;
use cadcraft_engine::doc::Space;
use cadcraft_engine::geom::{Bounds2, Vec2};
use cadcraft_engine::{Session, View};
use serde_json::{Value, json};
use std::sync::atomic::{AtomicBool, Ordering};
/// Set once a write to stdout has failed; later output is dropped.
static STDOUT_GONE: AtomicBool = AtomicBool::new(false);
/// Set when stdout failed for a reason other than a closed reader: the exit status becomes 1.
static STDOUT_ERROR: AtomicBool = AtomicBool::new(false);
fn stdout_gone() -> bool {
STDOUT_GONE.load(Ordering::Relaxed)
}
/// stdout went away. The program doesn't stop there: the rest of `run` (later `--cmd` results,
/// `--save`, `--export`) still happens, so `cadcraft-cli run … --save out.dxf | head` keeps the
/// drawing. A reader that stopped early (a closed pipe) is not an error, as in ripgrep; any other
/// write error is reported and makes the exit status 1.
fn stdout_failed(e: std::io::Error) {
if STDOUT_GONE.swap(true, Ordering::Relaxed) || e.kind() == std::io::ErrorKind::BrokenPipe {
return;
}
eprintln!("cadcraft-cli: can't write to stdout: {e}");
STDOUT_ERROR.store(true, Ordering::Relaxed);
}
const USAGE: &str = "usage:
cadcraft-cli info FILE.dxf
cadcraft-cli convert IN.dxf OUT.(dxf|dwg|svg|png|pdf) [FRAMING]
cadcraft-cli run [FILE | --sample | --metric] [--script TEXT] [--script-file F.scr] [--cmd 'id {json}']... [--save OUT.dxf] [--export OUT.(png|svg|pdf)] [FRAMING]
cadcraft-cli commands [FILTER]
cadcraft-cli mcp [--connect HOST:PORT [--lazy] [--launch]]
cadcraft-cli perf [N | FILE]
cadcraft-cli sample (bracket|floorplan) OUT.(dxf|dwg|svg|png)
cadcraft-cli --version
image framing (png 2400x1600, svg, pdf; always model space):
default convert: fit the drawing extents.
run --export: the current view if the script/commands changed it
(zoom, pan, view.set...), otherwise fit the drawing extents.
--window x0,y0,x1,y1 show this rectangle (drawing units, x0<x1, y0<y1), centred and
scaled to fit the image without distortion.
--view extents always fit the drawing extents.
--view current the current view (as last set by zoom/pan/view.set; a freshly
opened drawing's view is its extents plus a 10% margin).";
/// What part of model space an image export shows.
#[derive(Clone, Copy, Debug, PartialEq)]
enum Framing {
/// `run`: the current view if the commands changed it, else the extents. `convert`: extents.
Auto,
Extents,
Current,
Window(Bounds2),
}
/// Parse `--window x0,y0,x1,y1` (min corner first). Hostile values are errors, never panics.
fn parse_window(text: &str) -> Result<Bounds2, String> {
let parts: Vec<&str> = text.split(',').map(str::trim).collect();
let [x0, y0, x1, y1] = parts.as_slice() else {
return Err(format!("--window needs four numbers x0,y0,x1,y1, got `{text}`"));
};
let num = |name: &str, v: &str| -> Result<f64, String> {
match v.parse::<f64>() {
Ok(n) if n.is_finite() => Ok(n),
_ => Err(format!("--window: {name} must be a finite number, got `{v}`")),
}
};
let (x0, y0, x1, y1) = (num("x0", x0)?, num("y0", y0)?, num("x1", x1)?, num("y1", y1)?);
if !(x0 < x1 && y0 < y1) {
return Err(format!("--window: need x0 < x1 and y0 < y1 (lower-left corner first), got {x0},{y0},{x1},{y1}"));
}
let w = Bounds2 { min: Vec2::new(x0, y0), max: Vec2::new(x1, y1) };
cadcraft_io::check_window(&w).map_err(|e| format!("--window: {e}"))?;
Ok(w)
}
/// Parse a framing option (`--window`, `--view`) at `a`, taking its value from `it`. `Ok(false)`
/// means `a` is not a framing option.
fn framing_option<'a>(a: &str, it: &mut impl Iterator<Item = &'a String>, framing: &mut Framing) -> Result<bool, String> {
let f = match a {
"--window" => Framing::Window(parse_window(it.next().ok_or("--window needs x0,y0,x1,y1")?)?),
"--view" => match it.next().map(|v| v.to_ascii_lowercase()).as_deref() {
Some("extents") => Framing::Extents,
Some("current") => Framing::Current,
_ => return Err("--view needs `extents` or `current`".into()),
},
_ => return Ok(false),
};
if *framing != Framing::Auto && std::mem::discriminant(framing) != std::mem::discriminant(&f) {
return Err("use only one of --window and --view".into());
}
*framing = f;
Ok(true)
}
/// The model-space view of every open drawing, by drawing id (to tell whether commands changed it).
fn model_views(s: &Session) -> Vec<(u64, Option<View>)> {
s.docs.iter().map(|st| (st.uid, st.views.iter().find(|(sp, _)| *sp == Space::Model).map(|(_, v)| *v))).collect()
}
/// The active drawing's current model-space view as a rectangle with the viewport's aspect ratio
/// (the CLI viewport is 3:2 like the PNG export, so the image shows exactly this view).
fn current_window(s: &Session) -> Option<Bounds2> {
let st = s.state().ok()?;
let v = st.views.iter().find(|(sp, _)| *sp == Space::Model).map(|(_, v)| *v)?;
let (w, h) = s.viewport_px;
let half = Vec2::new(v.height * w / h.max(1.0), v.height) * 0.5;
Some(Bounds2::new(v.center - half, v.center + half))
}
/// The window an image export shows, or `None` to fit the extents. `before` is [`model_views`]
/// taken before the commands ran (`None` for `convert`, where nothing ran).
fn export_window(s: &Session, framing: Framing, before: Option<&[(u64, Option<View>)]>) -> Result<Option<Bounds2>, String> {
match framing {
Framing::Window(w) => Ok(Some(w)),
Framing::Extents => Ok(None),
Framing::Current => current_window(s).map(Some).ok_or_else(|| "--view current: the drawing has no model-space view".into()),
Framing::Auto => {
let Some(before) = before else { return Ok(None) };
let Ok(st) = s.state() else { return Ok(None) };
let now = model_views(s);
let was = before.iter().find(|(uid, _)| *uid == st.uid);
let is = now.iter().find(|(uid, _)| *uid == st.uid);
// Only a view the commands changed counts; a drawing opened by the commands keeps
// the extents framing.
match (was, is) {
(Some(a), Some(b)) if a.1 != b.1 => Ok(current_window(s)),
_ => Ok(None),
}
}
}
}
fn install_io() {
cadcraft_engine::cmd::file::set_io(cadcraft_engine::cmd::file::IoHooks {
read: |b, name| cadcraft_io::read(b, name).map_err(|e| e.to_string()),
write: |d, name| cadcraft_io::write(d, name).map_err(|e| e.to_string()),
plot: Some(|d, space, opts| cadcraft_io::plotter::plot(d, space, opts).map_err(|e| e.to_string())),
});
cadcraft_engine::cmd::file::set_write_as(|d, name, o| {
cadcraft_io::write_with(d, name, &cadcraft_io::SaveOptions { version: o.version, binary: o.binary }).map_err(|e| e.to_string())
});
// AutoLISP's entget, entmake and entmod see entities as DXF groups.
cadcraft_engine::cmd::file::set_entity_codec(cadcraft_engine::cmd::file::EntityCodec {
groups: cadcraft_io::entity_groups,
entity: cadcraft_io::entity_from_groups,
});
}
fn open(s: &mut Session, path: &str) -> Result<(), String> {
s.execute("open", &json!({ "path": path })).map(|_| ()).map_err(|e| e.to_string())
}
#[derive(Clone, Copy)]
struct OptionSpec {
name: &'static str,
takes_value: bool,
}
fn validate_options(command: &str, args: &[String], specs: &[OptionSpec]) -> Result<(), String> {
let mut i = 0;
while let Some(arg) = args.get(i) {
if let Some(name) = arg.strip_prefix("--") {
let Some(spec) = specs.iter().find(|spec| spec.name == arg) else {
return Err(format!("{command}: unknown option --{name}"));
};
if spec.takes_value && args.get(i + 1).is_some_and(|value| !value.starts_with("--")) {
i += 2;
} else {
i += 1;
}
} else {
i += 1;
}
}
Ok(())
}
const NO_OPTIONS: &[OptionSpec] = &[];
fn info(path: &str) -> Result<(), String> {
let mut s = Session::empty();
open(&mut s, path)?;
let v = s.execute("drawing.inspect", &json!({ "entities": false })).map_err(|e| e.to_string())?;
outln!("{}", serde_json::to_string_pretty(&v).unwrap_or_default());
Ok(())
}
fn export(s: &Session, out: &str) -> Result<(), String> {
export_framed(s, out, None)
}
fn export_framed(s: &Session, out: &str, window: Option<Bounds2>) -> Result<(), String> {
let d = s.doc().map_err(|e| e.to_string())?;
let bytes = cadcraft_io::write_framed(d, out, window).map_err(|e| e.to_string())?;
std::fs::write(out, &bytes).map_err(|e| format!("{out}: {e}"))?;
eprintln!("wrote {out} ({} bytes)", bytes.len());
if cadcraft_engine::cmd::file::drops_unknown(out) {
let lost = cadcraft_engine::cmd::file::not_saved(d);
if let Some(m) = cadcraft_engine::cmd::file::not_saved_message(&lost) {
eprintln!("{m}");
}
}
Ok(())
}
fn sample(args: &[String]) -> Result<(), String> {
let (Some(which), Some(out)) = (args.first(), args.get(1)) else { return Err(USAGE.into()) };
let d = match which.as_str() {
"bracket" => cadcraft_engine::sample::bracket(),
"floorplan" | "floor" => cadcraft_engine::sample::floor_plan(),
other => return Err(format!("unknown sample `{other}` (bracket, floorplan)")),
};
let bytes = cadcraft_io::write(&d, out).map_err(|e| e.to_string())?;
std::fs::write(out, &bytes).map_err(|e| format!("{out}: {e}"))?;
eprintln!("wrote {out} ({} bytes)", bytes.len());
Ok(())
}
/// Reject an explicit framing option without an image output to apply it to.
fn check_framed_output(framing: Framing, out: Option<&str>) -> Result<(), String> {
match out {
_ if framing == Framing::Auto => Ok(()),
Some(o) if !cadcraft_io::is_image(o) => Err(format!("--window/--view only apply to image output (png, svg, pdf), not `{o}`")),
Some(_) => Ok(()),
None => Err("--window/--view need an image output (--export OUT.png|svg|pdf)".into()),
}
}
fn convert(args: &[String]) -> Result<(), String> {
let mut framing = Framing::Auto;
let mut files = Vec::new();
let mut it = args.iter();
while let Some(a) = it.next() {
if !framing_option(a, &mut it, &mut framing)? {
files.push(a);
}
}
let [i, o] = files.as_slice() else { return Err(USAGE.into()) };
check_framed_output(framing, Some(o))?;
let mut s = Session::empty();
open(&mut s, i)?;
if let Some(m) = skipped_message(s.doc().map_err(|e| e.to_string())?, i) {
eprintln!("{m}");
}
if cadcraft_io::is_image(o)
&& let Some(m) = undrawn_message(s.doc().map_err(|e| e.to_string())?, i)
{
eprintln!("{m}");
}
let window = export_window(&s, framing, None)?;
export_framed(&s, o, window)
}
/// The warning for the model-space entities of `path` an image can't show, by type (those kept
/// as unknown objects, `Proxy` in `info`), or `None` when there are none.
fn undrawn_message(d: &cadcraft_engine::doc::Drawing, path: &str) -> Option<String> {
let mut types: std::collections::BTreeMap<&str, usize> = std::collections::BTreeMap::new();
for e in d.model.iter() {
if let cadcraft_engine::doc::EntityKind::Unknown(u) = &e.kind {
let n = types.entry(u.dxf_type.as_str()).or_insert(0);
*n = n.saturating_add(1);
}
}
let n = types.values().fold(0usize, |a, n| a.saturating_add(*n));
if n == 0 {
return None;
}
let list: Vec<String> = types.iter().map(|(t, n)| format!("{t}: {n}")).collect();
let entities = if n == 1 { "entity" } else { "entities" };
Some(format!("{path}: {n} {entities} not drawn ({})", list.join(", ")))
}
/// The warning for the records of `path` the reader could not place, or `None` when there are none.
fn skipped_message(d: &cadcraft_engine::doc::Drawing, path: &str) -> Option<String> {
let n = d.skipped_records.values().fold(0usize, |a, n| a.saturating_add(*n));
if n == 0 {
return None;
}
let list: Vec<String> = d.skipped_records.iter().map(|(section, n)| format!("{section}: {n}")).collect();
let records = if n == 1 { "record" } else { "records" };
Some(format!("{path}: {n} {records} could not be read and are left out ({})", list.join(", ")))
}
fn run(args: &[String]) -> Result<(), String> {
let mut s = Session::empty();
let mut it = args.iter();
let mut save = None;
let mut exp: Option<String> = None;
let mut framing = Framing::Auto;
let mut steps: Vec<(String, String)> = Vec::new();
while let Some(a) = it.next() {
if framing_option(a, &mut it, &mut framing)? {
continue;
}
match a.as_str() {
"--sample" => {
s.open_drawing(cadcraft_engine::sample::default_sample(), "Bracket", None);
}
"--metric" => {
s.new_drawing(true);
}
"--script" => steps.push(("script".into(), it.next().ok_or("--script needs text")?.replace("\\n", "\n"))),
"--script-file" => {
let p = it.next().ok_or("--script-file needs a path")?;
steps.push(("script".into(), std::fs::read_to_string(p).map_err(|e| format!("{p}: {e}"))?));
}
"--cmd" => steps.push(("cmd".into(), it.next().ok_or("--cmd needs `id {json}`")?.clone())),
"--save" => save = Some(it.next().ok_or("--save needs a path")?.clone()),
"--export" => exp = Some(it.next().ok_or("--export needs a path")?.clone()),
f if !f.starts_with("--") => open(&mut s, f)?,
other => return Err(format!("unknown option {other}")),
}
}
if s.docs.is_empty() {
s.new_drawing(false);
}
check_framed_output(framing, exp.as_deref())?;
let before = model_views(&s);
for (kind, body) in steps {
match kind.as_str() {
"script" => s.script(&body).map_err(|e| e.to_string())?,
_ => {
let (id, json) = body.split_once(' ').unwrap_or((&body, "{}"));
let params: Value = serde_json::from_str(json).map_err(|e| format!("--cmd {id}: {e}"))?;
let r = s.execute(id, &params).map_err(|e| e.to_string())?;
if !r.is_null() {
outln!("{r}");
}
}
}
}
for l in &s.log {
eprintln!("{l}");
}
if let Some(p) = save {
export(&s, &p)?;
}
if let Some(p) = exp {
let window = if cadcraft_io::is_image(&p) { export_window(&s, framing, Some(&before))? } else { None };
export_framed(&s, &p, window)?;
}
Ok(())
}
/// Small deterministic generator (xorshift64*) so perf runs are repeatable without a dependency.
struct Rng(u64);
impl Rng {
fn next(&mut self) -> f64 {
self.0 ^= self.0 >> 12;
self.0 ^= self.0 << 25;
self.0 ^= self.0 >> 27;
(self.0.wrapping_mul(0x2545_F491_4F6C_DD1D) >> 11) as f64 / (1u64 << 53) as f64
}
fn range(&mut self, lo: f64, hi: f64) -> f64 {
lo + (hi - lo) * self.next()
}
}
/// A synthetic drawing with `n` mixed entities (lines, circles, arcs, polylines, text) spread
/// over a square whose side grows with sqrt(n), so density stays constant.
fn synthetic(n: usize, side: f64) -> Result<cadcraft_engine::doc::Drawing, String> {
use cadcraft_engine::doc::{self as d, Common, EntityKind, Space};
use cadcraft_engine::geom::{PolyVertex, Vec2, Vec3};
let mut dr = d::Drawing::new_imperial();
let mut r = Rng(0x9E37_79B9_7F4A_7C15);
let v3 = |x: f64, y: f64| Vec3::new(x, y, 0.0);
for i in 0..n {
let (x, y) = (r.range(0.0, side), r.range(0.0, side));
let s = r.range(0.5, 8.0);
let kind = match i % 5 {
0 => EntityKind::Line(d::Line { a: v3(x, y), b: v3(x + r.range(-s, s), y + r.range(-s, s)) }),
1 => EntityKind::Circle(d::Circle { center: v3(x, y), radius: s * 0.5 }),
2 => EntityKind::Arc(d::Arc { center: v3(x, y), radius: s * 0.5, start: r.range(0.0, 3.0), end: r.range(3.2, 6.2) }),
3 => EntityKind::LwPolyline(d::LwPolyline {
vertices: (0..6)
.map(|k| PolyVertex {
p: Vec2::new(x + f64::from(k) * s * 0.3, y + r.range(-s, s) * 0.3),
bulge: if k == 2 { 0.4 } else { 0.0 },
start_width: 0.0,
end_width: 0.0,
})
.collect(),
closed: i % 2 == 0,
const_width: 0.0,
elevation: 0.0,
plinegen: false,
}),
_ => EntityKind::Text(d::Text {
insert: v3(x, y),
align_pt: None,
height: s * 0.2,
value: format!("T{i}"),
rotation: 0.0,
width_factor: 1.0,
oblique: 0.0,
style: "Standard".into(),
halign: d::HAlign::Left,
valign: d::VAlign::Baseline,
}),
};
dr.add(&Space::Model, Common::default(), kind).map_err(|e| e.to_string())?;
}
Ok(dr)
}
/// `perf FILE`: the display list built as the canvas builds it (a 1600 x 1000 px view at the
/// drawing's centre: tolerance, dash level of detail and view window from the zoom band), at
/// zoom extents and zoomed in 4x at a time, next to the full-detail build plots use.
fn perf_file(path: &str) -> Result<(), String> {
use cadcraft_engine::geom::{Bounds2, Vec2};
use cadcraft_engine::render::{Options, build};
use std::time::Instant;
let ms = |t: Instant| t.elapsed().as_secs_f64() * 1000.0;
let mut s = Session::empty();
let t = Instant::now();
open(&mut s, path)?;
let open_ms = ms(t);
let st = s.state().map_err(|e| e.to_string())?;
let (d, space) = (&st.doc, &st.space);
let ext = d.extents(space);
let size = ext.width().max(ext.height());
if ext.is_empty() || !(size.is_finite() && size > 0.0) {
return Err("perf: the drawing is empty".into());
}
outln!("open {open_ms:.0} ms, {} entities, extents {:.1} x {:.1}", d.entity_count(), ext.width(), ext.height());
outln!("{:>8} {:>10} {:>10}", "zoom", "build ms", "prims");
let mut finest = Options::default();
for k in 0..5 {
let zoom = 4f64.powi(k);
let h = size / zoom;
let pixel = 2f64.powi((h / 1000.0).max(1e-300).log2().floor() as i32);
let half = Vec2::new(h * 0.8, h * 0.5);
let visible = Bounds2::new(ext.center() - half, ext.center() + half);
let screen = Options { tolerance: pixel * 0.5, min_dash: pixel * 2.0, view: Some(visible.expand(h * 0.8)), ..Default::default() };
let t = Instant::now();
let n = build(d, space, &screen).prims.len();
outln!("{:>7}x {:>10.1} {n:>10}", zoom, ms(t));
finest = screen;
}
// Plots: every pattern line, everywhere (last, so its large list doesn't weigh on the above).
let t = Instant::now();
let n = build(d, space, &Options { min_dash: 0.0, view: None, ..finest }).prims.len();
outln!("{:>8} {:>10.1} {n:>10}", "full", ms(t));
Ok(())
}
fn perf(args: &[String]) -> Result<(), String> {
use cadcraft_engine::doc::Space;
use cadcraft_engine::geom::{Bounds2, Vec2};
use cadcraft_engine::{select, snap};
use std::time::Instant;
if let Some(path) = args.first().filter(|a| a.parse::<usize>().is_err()) {
return perf_file(path);
}
let n: usize = match args.first() {
Some(a) => a.parse().map_err(|_| format!("perf: bad entity count {a}"))?,
None => 200_000,
};
let n = n.clamp(1, 20_000_000);
let side = (n as f64).sqrt() * 10.0;
let mut rows: Vec<(String, f64, String)> = Vec::new();
let ms = |t: Instant| t.elapsed().as_secs_f64() * 1000.0;
let t = Instant::now();
let d = synthetic(n, side)?;
rows.push(("build synthetic drawing".into(), ms(t), format!("{n} entities")));
// Display list at a 1600 px wide "zoom extents" view (tolerance = half a pixel).
let px = side / 1600.0;
let opts = cadcraft_engine::render::Options { tolerance: px * 0.5, min_dash: px * 2.0, ..Default::default() };
let t = Instant::now();
let list = cadcraft_engine::render::build(&d, &Space::Model, &opts);
rows.push(("display list build".into(), ms(t), format!("{} prims, {} segments", list.prims.len(), list.segment_count())));
// Hit testing on a zoomed-in view (aperture = 5 px at 0.02 units/px).
let ap = 0.1;
let mut r = Rng(42);
let pts: Vec<Vec2> = (0..1000).map(|_| Vec2::new(r.range(0.0, side), r.range(0.0, side))).collect();
let t = Instant::now();
let first = select::pick(&d, &Space::Model, pts.first().copied().unwrap_or(Vec2::ZERO), ap);
rows.push(("first pick (incl. index build)".into(), ms(t), format!("{first:?}")));
let t = Instant::now();
let hits = pts.iter().filter(|p| select::pick(&d, &Space::Model, **p, ap).is_some()).count();
let total = ms(t);
rows.push(("pick x1000".into(), total, format!("{hits} hits, {:.3} ms/pick", total / 1000.0)));
// One edit, then a pick: only the touched chunk is re-indexed.
let mut edited = d.clone();
if let Some(h) = edited.model.last().map(|e| e.handle) {
edited.model.modify(h, |e| e.common.layer = "0".into());
}
let t = Instant::now();
let _ = select::pick(&edited, &Space::Model, pts.first().copied().unwrap_or(Vec2::ZERO), ap);
rows.push(("pick after one edit (incremental re-index)".into(), ms(t), String::new()));
let mut sel = 0usize;
let t = Instant::now();
for k in 0..20 {
let c = Vec2::new(r.range(0.0, side), r.range(0.0, side));
let half = side * 0.05;
sel += select::select_window(&d, &Space::Model, Bounds2::new(c - Vec2::new(half, half), c + Vec2::new(half, half)), k % 2 == 1).len();
}
rows.push(("window/crossing select x20 (10% side)".into(), ms(t), format!("{sel} selected")));
let t = Instant::now();
let fence: Vec<Vec2> = (0..4).map(|_| Vec2::new(r.range(0.0, side), r.range(0.0, side))).collect();
let fsel = select::select_fence(&d, &Space::Model, &fence).len();
rows.push(("fence select (3 legs)".into(), ms(t), format!("{fsel} selected")));
let osmode = snap::mode::END | snap::mode::MID | snap::mode::CEN | snap::mode::QUA | snap::mode::INT;
let t = Instant::now();
let snaps = pts.iter().filter(|p| snap::osnap(&d, &Space::Model, **p, ap * 2.0, osmode, None, false).is_some()).count();
let total = ms(t);
rows.push(("osnap x1000".into(), total, format!("{snaps} hits, {:.3} ms/query", total / 1000.0)));
let t = Instant::now();
let bytes = cadcraft_io::write(&d, "perf.dxf").map_err(|e| e.to_string())?;
rows.push(("DXF write".into(), ms(t), format!("{:.1} MB", bytes.len() as f64 / 1e6)));
let t = Instant::now();
let back = cadcraft_io::read(&bytes, "perf.dxf").map_err(|e| e.to_string())?;
rows.push(("DXF read".into(), ms(t), format!("{} entities", back.entity_count())));
outln!("{:<40} {:>12} notes", "operation", "ms");
outln!("{}", "-".repeat(80));
for (name, t, note) in rows {
outln!("{name:<40} {t:>12.2} {note}");
}
Ok(())
}
/// The backend `cadcraft-cli mcp` serves from.
#[derive(Debug, PartialEq, Eq)]
enum McpTarget {
/// An in-process drawing session (no `--connect`).
Headless,
/// The running app's control channel at `HOST:PORT`; `lazy`: reached at the first call;
/// `launch`: opened then if it isn't running.
Connect { addr: String, lazy: bool, launch: bool },
}
/// Parses the arguments after `mcp`. `--connect` must be followed by a `HOST:PORT`; a missing,
/// empty or flag-like value is an error, never a silent fall back to a headless session (#103).
fn parse_mcp_args(args: &[String]) -> Result<McpTarget, String> {
let mut addr: Option<String> = None;
let (mut lazy, mut launch) = (false, false);
let mut it = args.iter();
while let Some(a) = it.next() {
match a.as_str() {
"--connect" => {
let a = it
.next()
.map(|v| v.trim())
.filter(|v| !v.is_empty() && !v.starts_with('-'))
.ok_or("--connect needs HOST:PORT (the app's --control port, e.g. 127.0.0.1:7979)")?;
if addr.is_some() {
return Err("--connect given more than once".into());
}
addr = Some(a.to_string());
}
"--lazy" => lazy = true,
"--launch" => launch = true,
other => return Err(format!("mcp: unknown argument {other}\n{USAGE}")),
}
}
match addr {
// `--launch` connects at the first call too (the app opens then).
Some(addr) => Ok(McpTarget::Connect { addr, lazy: lazy || launch, launch }),
None if lazy || launch => Err("--lazy and --launch need --connect HOST:PORT".into()),
None => Ok(McpTarget::Headless),
}
}
fn mcp(args: &[String]) -> Result<(), String> {
let backend: Box<dyn cadcraft_mcp::Backend> = match parse_mcp_args(args)? {
McpTarget::Connect { addr, lazy: true, launch } => Box::new(cadcraft_mcp::Remote::lazy(&addr, launch)),
McpTarget::Connect { addr, .. } => match cadcraft_mcp::Remote::connect(&addr) {
Ok(r) => Box::new(r),
Err(e) => return Err(format!("cadcraft-cli: cannot connect to {addr}: {e}")),
},
McpTarget::Headless => Box::new(cadcraft_mcp::Headless::default()),
};
let stdin = std::io::stdin();
let stdout = std::io::stdout();
cadcraft_mcp::Server::new(backend).serve(stdin.lock(), stdout.lock()).map_err(|e| e.to_string())
}
#[cfg(test)]
mod mcp_args_tests {
use super::*;
fn args(a: &[&str]) -> Vec<String> {
a.iter().map(|s| s.to_string()).collect()
}
#[test]
fn mcp_without_connect_is_headless() {
assert_eq!(parse_mcp_args(&args(&[])), Ok(McpTarget::Headless));
}
#[test]
fn mcp_connect_with_address_selects_remote() {
let to = |addr: &str, lazy, launch| Ok(McpTarget::Connect { addr: addr.into(), lazy, launch });
assert_eq!(parse_mcp_args(&args(&["--connect", "127.0.0.1:7979"])), to("127.0.0.1:7979", false, false));
assert_eq!(parse_mcp_args(&args(&["--connect", "localhost:7979"])), to("localhost:7979", false, false));
assert_eq!(parse_mcp_args(&args(&["--lazy", "--connect", "localhost:7979"])), to("localhost:7979", true, false));
assert_eq!(parse_mcp_args(&args(&["--connect", "localhost:7979", "--launch"])), to("localhost:7979", true, true));
// Without an app to reach they mean nothing: never a headless session.
assert!(parse_mcp_args(&args(&["--lazy"])).is_err());
assert!(parse_mcp_args(&args(&["--launch"])).is_err());
}
#[test]
fn mcp_connect_without_address_is_an_error_not_headless() {
for bad in [&["--connect"][..], &["--connect", ""], &["--connect", " "], &["--connect", "--foo"], &["--connect", "-v"]] {
let r = parse_mcp_args(&args(bad));
let Err(e) = r else { panic!("{bad:?} should be rejected, got {r:?}") };
assert!(e.contains("--connect needs HOST:PORT"), "{bad:?}: {e}");
}
}
#[test]
fn mcp_rejects_unknown_and_repeated_arguments() {
assert!(parse_mcp_args(&args(&["--conect", "127.0.0.1:7979"])).is_err());
assert!(parse_mcp_args(&args(&["127.0.0.1:7979"])).is_err());
assert!(parse_mcp_args(&args(&["--connect", "127.0.0.1:1", "--connect", "127.0.0.1:2"])).is_err());
}
}
fn main() -> ExitCode {
let code = cli();
if STDOUT_ERROR.load(Ordering::Relaxed) { ExitCode::FAILURE } else { code }
}
fn cli() -> ExitCode {
install_io();
let args: Vec<String> = std::env::args().skip(1).collect();
let rest: Vec<String> = args.iter().skip(1).cloned().collect();
let r = match args.first().map(String::as_str) {
Some("info") => validate_options("info", &rest, NO_OPTIONS).and_then(|_| rest.first().ok_or_else(|| USAGE.to_string())).and_then(|p| info(p)),
Some("convert") => convert(&rest),
Some("run") => run(&rest),
Some("perf") => validate_options("perf", &rest, NO_OPTIONS).and_then(|_| perf(&rest)),
Some("sample") => validate_options("sample", &rest, NO_OPTIONS).and_then(|_| sample(&rest)),
Some("commands") => validate_options("commands", &rest, NO_OPTIONS).map(|_| {
let s = Session::new();
let f = rest.first().map(|x| x.to_ascii_lowercase());
let v: Vec<Value> = cadcraft_engine::command_specs()
.iter()
.filter(|c| f.as_ref().is_none_or(|f| c.id.contains(f.as_str()) || c.label.to_ascii_lowercase().contains(f.as_str())))
.map(|c| serde_json::to_value(c.info(&s)).unwrap_or_default())
.collect();
outln!("{}", serde_json::to_string_pretty(&v).unwrap_or_default());
}),
Some("mcp") => mcp(&rest),
Some("--version" | "-V") => {
outln!("cadcraft-cli {}", env!("CARGO_PKG_VERSION"));
Ok(())
}
Some("--help" | "-h" | "help") => {
outln!("{USAGE}");
Ok(())
}
_ => Err(USAGE.into()),
};
match r {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("{e}");
ExitCode::FAILURE
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn b(x0: f64, y0: f64, x1: f64, y1: f64) -> Bounds2 {
Bounds2::new(Vec2::new(x0, y0), Vec2::new(x1, y1))
}
fn sample_session() -> Session {
let mut s = Session::empty();
s.open_drawing(cadcraft_engine::sample::default_sample(), "Bracket", None);
s
}
fn view_box(svg: &str) -> String {
let i = svg.find("viewBox=\"").map(|i| i + 9).unwrap_or(0);
svg[i..].split('"').next().unwrap_or("").to_string()
}
fn assert_near(a: Bounds2, e: Bounds2) {
let d = (a.min - e.min).len().max((a.max - e.max).len());
assert!(d < 1e-9, "{a:?} != {e:?}");
}
#[test]
fn window_parses() {
assert_eq!(parse_window("0,0,30,20"), Ok(b(0.0, 0.0, 30.0, 20.0)));
assert_eq!(parse_window(" -5.5, -1e3 ,2e3,4 "), Ok(b(-5.5, -1000.0, 2000.0, 4.0)));
}
#[test]
fn hostile_window_is_an_error() {
for w in [
"",
"1,2,3",
"1,2,3,4,5",
"a,0,1,1",
"NaN,0,1,1",
"0,0,inf,1",
"0,0,1e400,1",
"0,0,0,1",
"0,0,1,0",
"5,0,1,1",
"0,5,1,1",
"-1e300,-1e300,1e300,1e300",
"0,0,1e-12,1e-12",
] {
assert!(parse_window(w).is_err(), "`{w}` should be rejected");
}
}
#[test]
fn framing_options() {
let parse = |args: &[&str]| -> Result<Framing, String> {
let args: Vec<String> = args.iter().map(|a| a.to_string()).collect();
let mut f = Framing::Auto;
let mut it = args.iter();
while let Some(a) = it.next() {
assert!(framing_option(a, &mut it, &mut f)?, "{a} is a framing option");
}
Ok(f)
};
assert_eq!(parse(&["--view", "extents"]), Ok(Framing::Extents));
assert_eq!(parse(&["--view", "CURRENT"]), Ok(Framing::Current));
assert_eq!(parse(&["--window", "0,0,2,1"]), Ok(Framing::Window(b(0.0, 0.0, 2.0, 1.0))));
assert!(parse(&["--view"]).is_err());
assert!(parse(&["--view", "selection"]).is_err());
assert!(parse(&["--window"]).is_err());
assert!(parse(&["--window", "0,0,2,1", "--view", "extents"]).is_err());
assert!(check_framed_output(Framing::Extents, Some("a.dxf")).is_err());
assert!(check_framed_output(Framing::Extents, None).is_err());
assert!(check_framed_output(Framing::Extents, Some("a.png")).is_ok());
assert!(check_framed_output(Framing::Auto, Some("a.dxf")).is_ok());
}
#[test]
fn default_framing_is_extents() {
// convert: nothing ran, always the extents.
let s = sample_session();
assert_eq!(export_window(&s, Framing::Auto, None), Ok(None));
// run: commands that leave the view alone keep the extents framing.
let mut s = sample_session();
let before = model_views(&s);
s.script("LINE 0,0 10,10\n\n").unwrap();
assert_eq!(export_window(&s, Framing::Auto, Some(&before)), Ok(None));
// A fresh drawing too.
let mut s = Session::empty();
s.new_drawing(false);
let before = model_views(&s);
s.execute("circle", &json!({"center": [0, 0], "radius": 5})).unwrap();
assert_eq!(export_window(&s, Framing::Auto, Some(&before)), Ok(None));
}
#[test]
fn zoom_window_frames_export() {
let mut s = sample_session();
let before = model_views(&s);
s.execute("zoom", &json!({"mode": "window", "p1": [0, 0], "p2": [30, 20]})).unwrap();
let w = export_window(&s, Framing::Auto, Some(&before)).unwrap().unwrap();
// The CLI viewport is 3:2, so a 3:2 window is shown exactly.
assert_near(w, b(0.0, 0.0, 30.0, 20.0));
assert_eq!(export_window(&s, Framing::Current, Some(&before)), Ok(Some(w)));
// --view extents and --window override the zoom.
assert_eq!(export_window(&s, Framing::Extents, Some(&before)), Ok(None));
let explicit = b(1.0, 1.0, 2.0, 2.0);
assert_eq!(export_window(&s, Framing::Window(explicit), Some(&before)), Ok(Some(explicit)));
// A taller window keeps its height; the 3:2 image adds room left and right.
s.execute("zoom", &json!({"mode": "center", "center": [5, 5], "height": 4})).unwrap();
assert_near(export_window(&s, Framing::Auto, Some(&before)).unwrap().unwrap(), b(2.0, 3.0, 8.0, 7.0));
}
#[test]
fn zoom_object_frames_the_selection() {
let mut s = sample_session();
let before = model_views(&s);
// The smallest entity with an area, selected then framed with ZOOM Object.
let d = s.doc().unwrap();
let bounds = |e: &cadcraft_engine::doc::Entity| cadcraft_engine::doc::entity_bounds(d, e, 0);
let e = d
.model
.iter()
.filter(|e| bounds(e).width() > 0.0 && bounds(e).height() > 0.0)
.min_by(|a, b| bounds(a).width().total_cmp(&bounds(b).width()))
.unwrap();
let (h, eb, ext) = (e.handle, bounds(e), d.extents(&Space::Model));
s.set_selection(vec![h]);
s.execute("zoom", &json!({"mode": "object"})).unwrap();
let w = export_window(&s, Framing::Auto, Some(&before)).unwrap().unwrap();
assert!(w.contains(eb.min) && w.contains(eb.max));
assert!(w.width() < ext.width() && w.height() < ext.height(), "{w:?} vs extents {ext:?}");
}
#[test]
fn run_export_honours_zoom() {
let dir = std::env::temp_dir().join(format!("cadcraft-cli-165-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = |n: &str| dir.join(n).to_string_lossy().to_string();
let run_svg = |extra: &[&str], out: &str| -> String {
let mut args: Vec<String> = ["--sample"].iter().chain(extra).map(|a| a.to_string()).collect();
args.extend(["--export".to_string(), path(out)]);
run(&args).unwrap();
std::fs::read_to_string(path(out)).unwrap()
};
let base = run_svg(&[], "base.svg");
let zoomed = run_svg(&["--cmd", r#"zoom {"mode":"window","p1":[0,0],"p2":[30,20]}"#], "zoom.svg");
let window = run_svg(&["--window", "0,0,30,20"], "window.svg");
let forced = run_svg(&["--cmd", r#"zoom {"mode":"window","p1":[0,0],"p2":[30,20]}"#, "--view", "extents"], "forced.svg");
assert_ne!(view_box(&zoomed), view_box(&base));
assert_eq!(view_box(&zoomed), "0 0 30.000000 20.000000");
assert_eq!(zoomed, window);
assert_eq!(forced, base);
// Hostile --window: a clear error, nothing written.
let bad: Vec<String> = ["--sample", "--window", "0,0,NaN,1", "--export", &path("bad.png")].iter().map(|a| a.to_string()).collect();
assert!(run(&bad).unwrap_err().contains("--window"));
assert!(!dir.join("bad.png").exists());
let _ = std::fs::remove_dir_all(&dir);
}
fn args(values: &[&str]) -> Vec<String> {
values.iter().map(|value| (*value).into()).collect()
}
#[test]
fn validators_accept_only_the_documented_options() {
assert!(validate_options("info", &args(&["file.dxf"]), NO_OPTIONS).is_ok());
assert!(validate_options("perf", &args(&["100"]), NO_OPTIONS).is_ok());
assert!(validate_options("sample", &args(&["bracket", "out.dxf"]), NO_OPTIONS).is_ok());
assert!(validate_options("commands", &args(&["line"]), NO_OPTIONS).is_ok());
for (command, specs) in [("info", NO_OPTIONS), ("perf", NO_OPTIONS), ("sample", NO_OPTIONS), ("commands", NO_OPTIONS)] {
assert!(validate_options(command, &args(&["--typo"]), specs).is_err());
}
}
#[test]
fn unknown_options_are_rejected_before_side_effects() {
assert_eq!(validate_options("info", &args(&["missing.dxf", "--typo"]), NO_OPTIONS), Err("info: unknown option --typo".into()));
assert_eq!(validate_options("perf", &args(&["1", "--typo"]), NO_OPTIONS), Err("perf: unknown option --typo".into()));
assert_eq!(validate_options("sample", &args(&["bracket", "out.dxf", "--typo"]), NO_OPTIONS), Err("sample: unknown option --typo".into()));
}
} }
+17
View File
@@ -0,0 +1,17 @@
use super::args::{McpArgs, McpTarget};
use super::error::{CliError, Result};
pub(crate) fn mcp(a: &McpArgs) -> Result {
let backend: Box<dyn cadcraft_mcp::Backend> = match a.target() {
McpTarget::Connect { addr, lazy: true, launch } => Box::new(cadcraft_mcp::Remote::lazy(&addr, launch)),
McpTarget::Connect { addr, .. } => match cadcraft_mcp::Remote::connect(&addr) {
Ok(r) => Box::new(r),
Err(source) => return Err(CliError::McpConnect { addr, source }),
},
McpTarget::Headless => Box::new(cadcraft_mcp::Headless::default()),
};
let stdin = std::io::stdin();
let stdout = std::io::stdout();
cadcraft_mcp::Server::new(backend).serve(stdin.lock(), stdout.lock())?;
Ok(())
}
+177
View File
@@ -0,0 +1,177 @@
use super::args::Framing;
use super::error::Result;
use cadcraft_engine::doc::Space;
use cadcraft_engine::geom::{Bounds2, Vec2};
use cadcraft_engine::{Session, View};
use std::io::Write;
use std::path::{Path, PathBuf};
/// The process's output streams. A write to stdout that fails never stops the program: once
/// stdout has failed, later output is dropped and the work goes on, so the rest of `run` (later
/// `--cmd` results, `--save`, `--export`) still happens and `cadcraft-cli run … --save out.dxf |
/// head` keeps the drawing. A reader that stopped early (a closed pipe, `cadcraft-cli commands |
/// head`) is not an error, as in ripgrep; any other write error is reported once on stderr and
/// makes the exit status 1.
pub(crate) struct Streams<'a> {
out: &'a mut dyn Write,
err: &'a mut dyn Write,
/// Set once a write to `out` has failed; later output is dropped.
gone: bool,
/// Set when `out` failed for a reason other than a closed reader.
failed: bool,
}
impl<'a> Streams<'a> {
pub(crate) fn new(out: &'a mut dyn Write, err: &'a mut dyn Write) -> Self {
Streams { out, err, gone: false, failed: false }
}
/// Write a line to stdout.
pub(crate) fn outln(&mut self, args: std::fmt::Arguments) {
if !self.gone
&& let Err(e) = writeln!(self.out, "{args}")
{
self.out_failed(e);
}
}
/// Write `v` to stdout: pretty-printed, or on one line with `compact` (`--compact`).
pub(crate) fn json(&mut self, v: &serde_json::Value, compact: bool) {
if compact {
self.outln(format_args!("{v}"));
} else {
self.outln(format_args!("{v:#}"));
}
}
/// Write a line to stderr. A failing stderr has nowhere to report to, so it is ignored.
pub(crate) fn errln(&mut self, args: std::fmt::Arguments) {
let _ = writeln!(self.err, "{args}");
}
/// Flush stdout (a pipe that closed during buffered output surfaces here).
pub(crate) fn finish(&mut self) {
if !self.gone
&& let Err(e) = self.out.flush()
{
self.out_failed(e);
}
}
/// Whether stdout failed for a reason other than a closed reader: the exit status is 1.
pub(crate) fn stdout_failed(&self) -> bool {
self.failed
}
fn out_failed(&mut self, e: std::io::Error) {
if std::mem::replace(&mut self.gone, true) || e.kind() == std::io::ErrorKind::BrokenPipe {
return;
}
self.errln(format_args!("cadcraft-cli: can't write to stdout: {e}"));
self.failed = true;
}
}
/// Why writing or framing an output failed.
#[derive(Debug, thiserror::Error)]
pub(crate) enum OutputError {
#[error("--view current: the drawing has no model-space view")]
NoModelView,
#[error("{}: {source}", path.display())]
Write { path: PathBuf, source: std::io::Error },
}
/// The model-space view of every open drawing, by drawing id (to tell whether commands changed it).
pub(crate) fn model_views(s: &Session) -> Vec<(u64, Option<View>)> {
s.docs.iter().map(|st| (st.uid, st.views.iter().find(|(sp, _)| *sp == Space::Model).map(|(_, v)| *v))).collect()
}
/// The active drawing's current model-space view as a rectangle with the viewport's aspect ratio
/// (the CLI viewport is 3:2 like the PNG export, so the image shows exactly this view).
fn current_window(s: &Session) -> Option<Bounds2> {
let st = s.state().ok()?;
let v = st.views.iter().find(|(sp, _)| *sp == Space::Model).map(|(_, v)| *v)?;
let (w, h) = s.viewport_px;
let half = Vec2::new(v.height * w / h.max(1.0), v.height) * 0.5;
Some(Bounds2::new(v.center - half, v.center + half))
}
/// The window an image export shows, or `None` to fit the extents. `before` is [`model_views`]
/// taken before the commands ran (`None` for `convert`, where nothing ran).
pub(crate) fn export_window(s: &Session, framing: Framing, before: Option<&[(u64, Option<View>)]>) -> Result<Option<Bounds2>, OutputError> {
match framing {
Framing::Window(w) => Ok(Some(w)),
Framing::Extents => Ok(None),
Framing::Current => current_window(s).map(Some).ok_or(OutputError::NoModelView),
Framing::Auto => {
let Some(before) = before else { return Ok(None) };
let Ok(st) = s.state() else { return Ok(None) };
let now = model_views(s);
let was = before.iter().find(|(uid, _)| *uid == st.uid);
let is = now.iter().find(|(uid, _)| *uid == st.uid);
// Only a view the commands changed counts; a drawing opened by the commands keeps
// the extents framing.
match (was, is) {
(Some(a), Some(b)) if a.1 != b.1 => Ok(current_window(s)),
_ => Ok(None),
}
}
}
}
/// Write `bytes` to `out` through the engine's atomic writer (an exclusively created sibling file
/// renamed over `out`), so a failed or interrupted write leaves the previous file untouched. An
/// existing `out` is resolved first: the rename would otherwise replace a symlink with a file and
/// leave the file it points to (`--in-place` through a link) unchanged.
pub(crate) fn write_atomic(out: &Path, bytes: &[u8]) -> Result<(), OutputError> {
let target = std::fs::canonicalize(out).unwrap_or_else(|_| out.to_path_buf());
cadcraft_engine::atomic_write::write_atomic(&target, bytes).map_err(|source| OutputError::Write { path: out.to_path_buf(), source })
}
pub(crate) fn export(s: &Session, out: &Path, io: &mut Streams) -> Result {
export_framed(s, out, None, io)
}
pub(crate) fn export_framed(s: &Session, out: &Path, window: Option<Bounds2>, io: &mut Streams) -> Result {
let d = s.doc()?;
let bytes = cadcraft_io::write_framed(d, out, window)?;
write_atomic(out, &bytes)?;
io.errln(format_args!("wrote {} ({} bytes)", out.display(), bytes.len()));
if cadcraft_engine::cmd::file::drops_unknown(out) {
let lost = cadcraft_engine::cmd::file::not_saved(d);
if let Some(m) = cadcraft_engine::cmd::file::not_saved_message(&lost) {
io.errln(format_args!("{m}"));
}
}
Ok(())
}
/// The warning for the model-space entities of `path` an image can't show, by type (those kept
/// as unknown objects, `Proxy` in `info`), or `None` when there are none.
pub(crate) fn undrawn_message(d: &cadcraft_engine::doc::Drawing, path: &str) -> Option<String> {
let mut types: std::collections::BTreeMap<&str, usize> = std::collections::BTreeMap::new();
for e in d.model.iter() {
if let cadcraft_engine::doc::EntityKind::Unknown(u) = &e.kind {
let n = types.entry(u.dxf_type.as_str()).or_insert(0);
*n = n.saturating_add(1);
}
}
let n = types.values().fold(0usize, |a, n| a.saturating_add(*n));
if n == 0 {
return None;
}
let list: Vec<String> = types.iter().map(|(t, n)| format!("{t}: {n}")).collect();
let entities = if n == 1 { "entity" } else { "entities" };
Some(format!("{path}: {n} {entities} not drawn ({})", list.join(", ")))
}
/// The warning for the records of `path` the reader could not place, or `None` when there are none.
pub(crate) fn skipped_message(d: &cadcraft_engine::doc::Drawing, path: &str) -> Option<String> {
let n = d.skipped_records.values().fold(0usize, |a, n| a.saturating_add(*n));
if n == 0 {
return None;
}
let list: Vec<String> = d.skipped_records.iter().map(|(section, n)| format!("{section}: {n}")).collect();
let records = if n == 1 { "record" } else { "records" };
Some(format!("{path}: {n} {records} could not be read and are left out ({})", list.join(", ")))
}
+196
View File
@@ -0,0 +1,196 @@
use super::args::PerfTarget;
use super::error::Result;
use super::open;
use super::output::Streams;
use cadcraft_engine::Session;
/// Why `perf` cannot run.
#[derive(Debug, thiserror::Error)]
pub(crate) enum PerfError {
#[error("perf: the drawing is empty")]
EmptyDrawing,
}
/// Small deterministic generator (xorshift64*) so perf runs are repeatable without a dependency.
struct Rng(u64);
impl Rng {
fn next(&mut self) -> f64 {
self.0 ^= self.0 >> 12;
self.0 ^= self.0 << 25;
self.0 ^= self.0 >> 27;
(self.0.wrapping_mul(0x2545_F491_4F6C_DD1D) >> 11) as f64 / (1u64 << 53) as f64
}
fn range(&mut self, lo: f64, hi: f64) -> f64 {
lo + (hi - lo) * self.next()
}
}
/// A synthetic drawing with `n` mixed entities (lines, circles, arcs, polylines, text) spread
/// over a square whose side grows with sqrt(n), so density stays constant.
fn synthetic(n: usize, side: f64) -> Result<cadcraft_engine::doc::Drawing, cadcraft_engine::doc::DocError> {
use cadcraft_engine::doc::{self as d, Common, EntityKind, Space};
use cadcraft_engine::geom::{PolyVertex, Vec2, Vec3};
let mut dr = d::Drawing::new_imperial();
let mut r = Rng(0x9E37_79B9_7F4A_7C15);
let v3 = |x: f64, y: f64| Vec3::new(x, y, 0.0);
for i in 0..n {
let (x, y) = (r.range(0.0, side), r.range(0.0, side));
let s = r.range(0.5, 8.0);
let kind = match i % 5 {
0 => EntityKind::Line(d::Line { a: v3(x, y), b: v3(x + r.range(-s, s), y + r.range(-s, s)) }),
1 => EntityKind::Circle(d::Circle { center: v3(x, y), radius: s * 0.5 }),
2 => EntityKind::Arc(d::Arc { center: v3(x, y), radius: s * 0.5, start: r.range(0.0, 3.0), end: r.range(3.2, 6.2) }),
3 => EntityKind::LwPolyline(d::LwPolyline {
vertices: (0..6)
.map(|k| PolyVertex {
p: Vec2::new(x + f64::from(k) * s * 0.3, y + r.range(-s, s) * 0.3),
bulge: if k == 2 { 0.4 } else { 0.0 },
start_width: 0.0,
end_width: 0.0,
})
.collect(),
closed: i % 2 == 0,
const_width: 0.0,
elevation: 0.0,
plinegen: false,
}),
_ => EntityKind::Text(d::Text {
insert: v3(x, y),
align_pt: None,
height: s * 0.2,
value: format!("T{i}"),
rotation: 0.0,
width_factor: 1.0,
oblique: 0.0,
style: "Standard".into(),
halign: d::HAlign::Left,
valign: d::VAlign::Baseline,
}),
};
dr.add(&Space::Model, Common::default(), kind)?;
}
Ok(dr)
}
/// `perf FILE`: the display list built as the canvas builds it (a 1600 x 1000 px view at the
/// drawing's centre: tolerance, dash level of detail and view window from the zoom band), at
/// zoom extents and zoomed in 4x at a time, next to the full-detail build plots use.
fn perf_file(path: &std::path::Path, io: &mut Streams) -> Result {
use cadcraft_engine::geom::{Bounds2, Vec2};
use cadcraft_engine::render::{Options, build};
use std::time::Instant;
let ms = |t: Instant| t.elapsed().as_secs_f64() * 1000.0;
let mut s = Session::empty();
let t = Instant::now();
open(&mut s, path)?;
let open_ms = ms(t);
let st = s.state()?;
let (d, space) = (&st.doc, &st.space);
let ext = d.extents(space);
let size = ext.width().max(ext.height());
if ext.is_empty() || !(size.is_finite() && size > 0.0) {
return Err(PerfError::EmptyDrawing.into());
}
io.outln(format_args!("open {open_ms:.0} ms, {} entities, extents {:.1} x {:.1}", d.entity_count(), ext.width(), ext.height()));
io.outln(format_args!("{:>8} {:>10} {:>10}", "zoom", "build ms", "prims"));
let mut finest = Options::default();
for k in 0..5 {
let zoom = 4f64.powi(k);
let h = size / zoom;
let pixel = 2f64.powi((h / 1000.0).max(1e-300).log2().floor() as i32);
let half = Vec2::new(h * 0.8, h * 0.5);
let visible = Bounds2::new(ext.center() - half, ext.center() + half);
let screen = Options { tolerance: pixel * 0.5, min_dash: pixel * 2.0, view: Some(visible.expand(h * 0.8)), ..Default::default() };
let t = Instant::now();
let n = build(d, space, &screen).prims.len();
io.outln(format_args!("{:>7}x {:>10.1} {n:>10}", zoom, ms(t)));
finest = screen;
}
// Plots: every pattern line, everywhere (last, so its large list doesn't weigh on the above).
let t = Instant::now();
let n = build(d, space, &Options { min_dash: 0.0, view: None, ..finest }).prims.len();
io.outln(format_args!("{:>8} {:>10.1} {n:>10}", "full", ms(t)));
Ok(())
}
pub(crate) fn perf(target: Option<&PerfTarget>, io: &mut Streams) -> Result {
use cadcraft_engine::doc::Space;
use cadcraft_engine::geom::{Bounds2, Vec2};
use cadcraft_engine::{select, snap};
use std::time::Instant;
let n = match target {
Some(PerfTarget::File(path)) => return perf_file(path, io),
Some(PerfTarget::Count(n)) => *n,
None => 200_000,
};
let side = (n as f64).sqrt() * 10.0;
let mut rows: Vec<(String, f64, String)> = Vec::new();
let ms = |t: Instant| t.elapsed().as_secs_f64() * 1000.0;
let t = Instant::now();
let d = synthetic(n, side)?;
rows.push(("build synthetic drawing".into(), ms(t), format!("{n} entities")));
// Display list at a 1600 px wide "zoom extents" view (tolerance = half a pixel).
let px = side / 1600.0;
let opts = cadcraft_engine::render::Options { tolerance: px * 0.5, min_dash: px * 2.0, ..Default::default() };
let t = Instant::now();
let list = cadcraft_engine::render::build(&d, &Space::Model, &opts);
rows.push(("display list build".into(), ms(t), format!("{} prims, {} segments", list.prims.len(), list.segment_count())));
// Hit testing on a zoomed-in view (aperture = 5 px at 0.02 units/px).
let ap = 0.1;
let mut r = Rng(42);
let pts: Vec<Vec2> = (0..1000).map(|_| Vec2::new(r.range(0.0, side), r.range(0.0, side))).collect();
let t = Instant::now();
let first = select::pick(&d, &Space::Model, pts.first().copied().unwrap_or(Vec2::ZERO), ap);
rows.push(("first pick (incl. index build)".into(), ms(t), format!("{first:?}")));
let t = Instant::now();
let hits = pts.iter().filter(|p| select::pick(&d, &Space::Model, **p, ap).is_some()).count();
let total = ms(t);
rows.push(("pick x1000".into(), total, format!("{hits} hits, {:.3} ms/pick", total / 1000.0)));
// One edit, then a pick: only the touched chunk is re-indexed.
let mut edited = d.clone();
if let Some(h) = edited.model.last().map(|e| e.handle) {
edited.model.modify(h, |e| e.common.layer = "0".into());
}
let t = Instant::now();
let _ = select::pick(&edited, &Space::Model, pts.first().copied().unwrap_or(Vec2::ZERO), ap);
rows.push(("pick after one edit (incremental re-index)".into(), ms(t), String::new()));
let mut sel = 0usize;
let t = Instant::now();
for k in 0..20 {
let c = Vec2::new(r.range(0.0, side), r.range(0.0, side));
let half = side * 0.05;
sel += select::select_window(&d, &Space::Model, Bounds2::new(c - Vec2::new(half, half), c + Vec2::new(half, half)), k % 2 == 1).len();
}
rows.push(("window/crossing select x20 (10% side)".into(), ms(t), format!("{sel} selected")));
let t = Instant::now();
let fence: Vec<Vec2> = (0..4).map(|_| Vec2::new(r.range(0.0, side), r.range(0.0, side))).collect();
let fsel = select::select_fence(&d, &Space::Model, &fence).len();
rows.push(("fence select (3 legs)".into(), ms(t), format!("{fsel} selected")));
let osmode = snap::mode::END | snap::mode::MID | snap::mode::CEN | snap::mode::QUA | snap::mode::INT;
let t = Instant::now();
let snaps = pts.iter().filter(|p| snap::osnap(&d, &Space::Model, **p, ap * 2.0, osmode, None, false).is_some()).count();
let total = ms(t);
rows.push(("osnap x1000".into(), total, format!("{snaps} hits, {:.3} ms/query", total / 1000.0)));
let t = Instant::now();
let bytes = cadcraft_io::write(&d, "perf.dxf")?;
rows.push(("DXF write".into(), ms(t), format!("{:.1} MB", bytes.len() as f64 / 1e6)));
let t = Instant::now();
let back = cadcraft_io::read(&bytes, "perf.dxf")?;
rows.push(("DXF read".into(), ms(t), format!("{} entities", back.entity_count())));
io.outln(format_args!("{:<40} {:>12} notes", "operation", "ms"));
io.outln(format_args!("{}", "-".repeat(80)));
for (name, t, note) in rows {
io.outln(format_args!("{name:<40} {t:>12.2} {note}"));
}
Ok(())
}
+223
View File
@@ -0,0 +1,223 @@
use super::args::{CmdStep, Drawing, RunArgs, Step};
use super::error::Result;
use super::open;
use super::output::{Streams, export, export_framed, export_window, model_views};
use cadcraft_engine::Session;
use cadcraft_engine::doc::Handle;
use serde_json::Value;
/// A `--cmd` parameter value of exactly `{last}` is filled in with the last object handle an
/// earlier `--cmd` in the same run returned: `mview` answers `{"viewports":["100"]}`, so
/// `viewport.set {"handle":"{last}","scale":1}` changes viewport 100. Any other string is passed
/// through unchanged, and `--script` text is never rewritten.
const LAST_HANDLE: &str = "{last}";
/// Why a `run` step fails.
#[derive(Debug, thiserror::Error)]
pub(crate) enum RunError {
#[error("{}: {source}", .path.display())]
ReadScript { path: std::path::PathBuf, source: std::io::Error },
#[error("--cmd {id}: {source}")]
CmdLast { id: String, source: NoLastHandle },
}
/// A `{last}` placeholder with no handle to fill in.
#[derive(Debug, PartialEq, thiserror::Error)]
#[error("`{{last}}` needs an earlier --cmd in this run to have returned a handle")]
pub(crate) struct NoLastHandle;
/// How deep [`result_handles`] walks a command result (results are the engine's own JSON).
const RESULT_DEPTH: usize = 16;
/// Result keys whose values are object references: the engine's result convention for handles.
/// A handle can be a string (`handle`, `viewport`, `insert`…) or a list of them (`handles`,
/// `viewports`, `members`…). Every other field may hold a user-defined name, a message or a
/// count, and is never read as a handle — a group named `100` is a name, not object `100`.
fn handle_field(key: &str) -> bool {
matches!(
key,
"handle"
| "handles"
| "viewport"
| "viewports"
| "boundary"
| "clip"
| "insert"
| "members"
| "source"
| "objects"
| "entities"
| "selection"
| "parts"
| "refs"
| "matches"
| "line"
| "arc"
| "blocks"
)
}
/// Collect the handles of `v`, a value under a handle field: a handle string, an array of them,
/// or objects that carry handle fields of their own (`entities`, `parts`).
fn collect_result_handles(s: &Session, v: &Value, depth: usize, out: &mut Vec<Handle>) {
if depth > RESULT_DEPTH {
return;
}
match v {
Value::String(t) => {
if let Some(h) = Handle::parse_hex(t)
&& s.doc().is_ok_and(|d| d.entity(h).is_some())
{
out.push(h);
}
}
Value::Array(a) => a.iter().for_each(|x| collect_result_handles(s, x, depth + 1, out)),
Value::Object(m) => {
for (k, x) in m {
if handle_field(k) {
collect_result_handles(s, x, depth + 1, out);
} else {
find_result_handles(s, x, depth + 1, out);
}
}
}
_ => {}
}
}
/// Find handle fields nested in containers that are not handles themselves (`groups` in
/// `groups.list`, whose members are handles): strings here are names, never references.
fn find_result_handles(s: &Session, v: &Value, depth: usize, out: &mut Vec<Handle>) {
if depth > RESULT_DEPTH {
return;
}
match v {
Value::Array(a) => a.iter().for_each(|x| find_result_handles(s, x, depth + 1, out)),
Value::Object(m) => {
for (k, x) in m {
if handle_field(k) {
collect_result_handles(s, x, depth + 1, out);
} else {
find_result_handles(s, x, depth + 1, out);
}
}
}
_ => {}
}
}
/// The object handles a command result mentions, in the order it lists them. A handle-only result
/// (a handle string or an array of them) counts as it is; otherwise only handle fields do.
fn result_handles(s: &Session, v: &Value, depth: usize, out: &mut Vec<Handle>) {
match v {
Value::String(_) => collect_result_handles(s, v, depth, out),
Value::Array(a) if a.iter().all(Value::is_string) => collect_result_handles(s, v, depth, out),
Value::Array(_) | Value::Object(_) => find_result_handles(s, v, depth, out),
_ => {}
}
}
/// The last handle `r` returned: what a later `{last}` stands for.
pub(crate) fn returned_handle(s: &Session, r: &Value) -> Option<Handle> {
let mut hs = Vec::new();
result_handles(s, r, 0, &mut hs);
hs.last().copied()
}
/// The active drawing's identity ([`cadcraft_engine::DocState::uid`]): a handle is only meaningful
/// in the drawing it came from, and `new`, `open`, `document.switch` and `close` can make another
/// drawing current.
fn active_drawing(s: &Session) -> Option<u64> {
s.docs.get(s.active).map(|st| st.uid)
}
/// Every `{last}` in `p`, at any depth, replaced by `h`. With no handle to fill in it is an
/// error, never a token reaching the command as a (bad) handle.
pub(crate) fn fill_last(p: &Value, h: Option<Handle>) -> Result<Value, NoLastHandle> {
match p {
Value::String(t) if t.as_str() == LAST_HANDLE => match h {
Some(h) => Ok(Value::String(h.hex())),
None => Err(NoLastHandle),
},
Value::Array(a) => a.iter().map(|x| fill_last(x, h)).collect::<Result<Vec<_>, _>>().map(Value::Array),
Value::Object(m) => {
m.iter().map(|(k, x)| Ok((k.clone(), fill_last(x, h)?))).collect::<Result<serde_json::Map<String, Value>, _>>().map(Value::Object)
}
other => Ok(other.clone()),
}
}
/// A step ready to run: script text, or a command.
enum Ready<'a> {
Script(String),
Cmd(&'a CmdStep),
}
pub(crate) fn run_cmds(a: &RunArgs, io: &mut Streams) -> Result {
let framing = a.framing.framing();
let (save, export_to) = (a.save.as_deref(), a.export.as_deref());
let mut s = Session::empty();
for d in &a.drawings {
match d {
Drawing::File(f) => open(&mut s, f)?,
Drawing::Sample => {
s.open_drawing(cadcraft_engine::sample::default_sample(), "Bracket", None);
}
Drawing::Metric => {
s.new_drawing(true);
}
}
}
let mut steps: Vec<Ready> = Vec::new();
for step in &a.steps {
steps.push(match step {
Step::Script(text) => Ready::Script(text.replace("\\n", "\n")),
Step::ScriptFile(p) => Ready::Script(std::fs::read_to_string(p).map_err(|source| RunError::ReadScript { path: p.clone(), source })?),
Step::Cmd(c) => Ready::Cmd(c),
});
}
if s.docs.is_empty() {
s.new_drawing(false);
}
let before = model_views(&s);
let mut last_handle: Option<Handle> = None;
for step in steps {
// A handle belongs to the drawing that returned it: if this step makes another drawing
// current (`new`, `open`, `document.switch`, `close`, or a script running one of them),
// the remembered handle is stale. A handle the step itself returns is taken afterwards,
// so it is a handle of the drawing it made current.
let drawing = active_drawing(&s);
match step {
Ready::Script(body) => {
s.script(&body)?;
if active_drawing(&s) != drawing {
last_handle = None;
}
}
Ready::Cmd(CmdStep { id, params }) => {
let params = fill_last(params, last_handle).map_err(|source| RunError::CmdLast { id: id.clone(), source })?;
let r = s.execute(id, &params)?;
if active_drawing(&s) != drawing {
last_handle = None;
}
if let Some(h) = returned_handle(&s, &r) {
last_handle = Some(h);
}
if !r.is_null() {
io.outln(format_args!("{r}"));
}
}
}
}
for l in &s.log {
io.errln(format_args!("{l}"));
}
if let Some(p) = save {
export(&s, p, io)?;
}
if let Some(p) = export_to {
let window = if cadcraft_io::is_image(p) { export_window(&s, framing, Some(&before))? } else { None };
export_framed(&s, p, window, io)?;
}
Ok(())
}
+14
View File
@@ -0,0 +1,14 @@
use super::args::{SampleArgs, SampleName};
use super::error::Result;
use super::output::{Streams, write_atomic};
pub(crate) fn sample(a: &SampleArgs, io: &mut Streams) -> Result {
let d = match a.name {
SampleName::Bracket => cadcraft_engine::sample::bracket(),
SampleName::Floorplan => cadcraft_engine::sample::floor_plan(),
};
let bytes = cadcraft_io::write(&d, &a.output)?;
write_atomic(&a.output, &bytes)?;
io.errln(format_args!("wrote {} ({} bytes)", a.output.display(), bytes.len()));
Ok(())
}
+225
View File
@@ -0,0 +1,225 @@
//! A `--cmd` can act on a handle an earlier `--cmd` in the same run returned (#1663).
//!
//! `mview` answers `{"viewports":["100"]}`, but a following `viewport.set` could not use that
//! handle: it had to be guessed, selected by point, or activated with `mspace`. A `--cmd` parameter
//! value of exactly `{last}` is now filled in with the last handle an earlier `--cmd` returned.
use std::process::{Command, Output};
fn run(args: &[&str]) -> Output {
Command::new(env!("CARGO_BIN_EXE_cadcraft-cli"))
.args(args)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.output()
.expect("run cadcraft-cli")
}
/// The issue's sequence: `viewport.set` straight after `mview`, addressing the new viewport by the
/// handle the runner remembers.
#[test]
fn viewport_set_after_mview_uses_the_returned_handle() {
let out = run(&[
"run",
"--metric",
"--cmd",
r#"layout.set {"name":"Layout1"}"#,
"--cmd",
r#"mview {"p1":[20,20],"p2":[200,150]}"#,
"--cmd",
r#"viewport.set {"handle":"{last}","scale":1}"#,
]);
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(out.status.success(), "exit {:?}\nstdout: {stdout}\nstderr: {stderr}", out.status);
let mview = stdout.lines().find(|l| l.contains("\"viewports\"")).unwrap_or_else(|| panic!("no mview result in {stdout:?}"));
let created: serde_json::Value = serde_json::from_str(mview).expect("mview result is JSON");
let handle = created["viewports"].as_array().and_then(|a| a.first()).and_then(|h| h.as_str()).expect("a viewport handle");
assert!(handle.eq_ignore_ascii_case("100"), "{mview}");
let set = stdout.lines().find(|l| l.contains("\"viewHeight\"")).unwrap_or_else(|| panic!("no viewport.set result in {stdout:?}"));
let vp: serde_json::Value = serde_json::from_str(set).expect("viewport.set result is JSON");
assert_eq!(vp[0]["handle"].as_str(), Some(handle), "viewport.set changed the viewport `{handle}`: {set}");
assert_eq!(vp[0]["scale"].as_f64(), Some(1.0), "{set}");
}
/// `{last}` with no handle to fill in is a clear error, not the token reaching the command.
#[test]
fn last_without_an_earlier_handle_is_an_error() {
let out = run(&["run", "--metric", "--cmd", r#"viewport.set {"handle":"{last}","scale":1}"#]);
let stderr = String::from_utf8_lossy(&out.stderr);
assert_eq!(out.status.code(), Some(1), "stdout: {:?}\nstderr: {stderr}", String::from_utf8_lossy(&out.stdout));
assert!(stderr.contains("{last}"), "the error names the placeholder: {stderr}");
assert!(!stderr.contains("panicked"), "{stderr}");
}
/// The last JSON value a run printed: command results are one JSON value a line (`null` is not
/// printed).
fn last_json(stdout: &str) -> serde_json::Value {
let line = stdout
.lines()
.rev()
.find(|l| {
let t = l.trim_start();
t.starts_with('{') || t.starts_with('[')
})
.unwrap_or_else(|| panic!("no JSON result in {stdout:?}"));
serde_json::from_str(line).expect("command result is JSON")
}
/// `{last}` was not replaced: the error names the placeholder, and the run fails cleanly.
fn assert_needs_an_earlier_handle(out: &Output) {
let stderr = String::from_utf8_lossy(&out.stderr);
assert_eq!(out.status.code(), Some(1), "stdout: {:?}\nstderr: {stderr}", String::from_utf8_lossy(&out.stdout));
assert!(stderr.contains("{last}"), "the error names the placeholder: {stderr}");
assert!(!stderr.contains("panicked"), "{stderr}");
}
/// A user-defined name that happens to look like a handle is not a returned handle. `group`
/// answers `{"name":"100","members":1}`: `100` is the first line's handle, but the name is not an
/// object reference and must not replace the last handle the `line` command returned (`101`).
#[test]
fn a_group_name_that_looks_like_a_handle_is_not_last() {
let out = run(&[
"run",
"--metric",
"--cmd",
r#"line {"points":[[0,0],[10,0]]}"#,
"--cmd",
r#"line {"points":[[0,10],[10,10]]}"#,
"--cmd",
r#"group {"name":"100","handles":["101"]}"#,
"--cmd",
r#"erase {"handles":["{last}"]}"#,
"--cmd",
r#"entities {"type":"LINE"}"#,
]);
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(out.status.success(), "exit {:?}\nstdout: {stdout}\nstderr: {stderr}", out.status);
let r = last_json(&stdout);
assert_eq!(r["count"].as_u64(), Some(1), "one line is left: {stdout}");
let left: Vec<&str> = r["entities"].as_array().into_iter().flatten().filter_map(|e| e["handle"].as_str()).collect();
assert_eq!(left, ["100"], "the group name 100 did not replace the handle 101 the line command returned: {stdout}");
}
/// `new` makes another drawing current: the handle an earlier `--cmd` returned in the old drawing
/// is not a handle of the new one.
#[test]
fn last_is_cleared_when_new_switches_drawings() {
let out = run(&[
"run",
"--metric",
"--cmd",
r#"line {"points":[[0,0],[10,0]]}"#,
"--cmd",
r#"new {"metric":true}"#,
"--cmd",
r#"erase {"handles":["{last}"]}"#,
]);
assert_needs_an_earlier_handle(&out);
}
/// `open` makes the opened file the current drawing: a handle from the drawing before it is stale.
#[test]
fn last_is_cleared_when_open_switches_drawings() {
let dir = std::env::temp_dir().join(format!("cc-cli-last-open-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("temp dir");
let dxf = dir.join("opened.dxf");
let sample = run(&["sample", "bracket", dxf.to_str().expect("temp path")]);
assert!(sample.status.success(), "sample failed: {}", String::from_utf8_lossy(&sample.stderr));
let open = format!(r#"open {{"path":{}}}"#, serde_json::to_string(dxf.to_str().expect("temp path")).expect("path JSON"));
let out = run(&["run", "--metric", "--cmd", r#"line {"points":[[0,0],[10,0]]}"#, "--cmd", &open, "--cmd", r#"erase {"handles":["{last}"]}"#]);
assert_needs_an_earlier_handle(&out);
let _ = std::fs::remove_dir_all(&dir);
}
/// `document.switch` changes the current drawing without adding one: a handle remembered from the
/// drawing left behind must not be used in the one switched to.
#[test]
fn last_is_cleared_when_document_switch_changes_drawings() {
let out = run(&[
"run",
"--metric",
"--cmd",
r#"line {"points":[[0,0],[10,0]]}"#,
"--cmd",
r#"new {"metric":true}"#,
"--cmd",
r#"line {"points":[[0,10],[10,10]]}"#,
"--cmd",
r#"document.switch {"index":0}"#,
"--cmd",
r#"erase {"handles":["{last}"]}"#,
]);
assert_needs_an_earlier_handle(&out);
}
/// A handle a command returns after a switch belongs to the drawing now current, so it is kept.
#[test]
fn a_handle_returned_after_a_switch_is_last() {
let out = run(&[
"run",
"--metric",
"--cmd",
r#"line {"points":[[0,0],[10,0]]}"#,
"--cmd",
r#"new {"metric":true}"#,
"--cmd",
r#"document.switch {"index":0}"#,
"--cmd",
r#"line {"points":[[0,10],[10,10]]}"#,
"--cmd",
r#"erase {"handles":["{last}"]}"#,
"--cmd",
r#"entities {"type":"LINE"}"#,
]);
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(out.status.success(), "exit {:?}\nstdout: {stdout}\nstderr: {stderr}", out.status);
let r = last_json(&stdout);
assert_eq!(r["count"].as_u64(), Some(1), "one line is left: {stdout}");
let left: Vec<&str> = r["entities"].as_array().into_iter().flatten().filter_map(|e| e["handle"].as_str()).collect();
assert_eq!(left, ["100"], "the line created after the switch was erased: {stdout}");
}
/// A script can change the current drawing too (`new {"metric":true}` at a script line), so the
/// remembered handle is cleared after a script that changed it.
#[test]
fn last_is_cleared_by_a_script_that_changes_drawings() {
let out = run(&[
"run",
"--metric",
"--cmd",
r#"line {"points":[[0,0],[10,0]]}"#,
"--script",
r#"new {"metric":true}"#,
"--cmd",
r#"erase {"handles":["{last}"]}"#,
]);
assert_needs_an_earlier_handle(&out);
}
/// Only the exact token is a placeholder: ordinary JSON strings are left alone.
#[test]
fn ordinary_values_are_not_placeholders() {
let dir = std::env::temp_dir().join(format!("cc-cli-last-placeholder-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("temp dir");
let out_dxf = dir.join("text.dxf");
let out = run(&[
"run",
"--metric",
"--cmd",
r#"layout.set {"name":"Layout1"}"#,
"--cmd",
r#"mview {"p1":[20,20],"p2":[200,150]}"#,
"--cmd",
r#"text {"at":[0,0],"height":2,"text":"last and {last}"}"#,
"--save",
out_dxf.to_str().expect("temp path"),
]);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(out.status.success(), "exit {:?}\nstderr: {stderr}", out.status);
let saved = std::fs::read_to_string(&out_dxf).expect("saved DXF");
assert!(saved.contains("last and {last}"), "the text is stored literally");
let _ = std::fs::remove_dir_all(&dir);
}
@@ -1,23 +1,23 @@
//! `--help` is a request, not a failure (#1525): it goes to stdout with exit 0, like `--version`. //! `--help` is a request, not a failure (#1525): it goes to stdout with exit 0, like `--version`.
//! Bare `cadcraft-cli` keeps reporting on stderr with exit 1. //! Bare `cadcraft-cli` is a usage error: the help on stderr, exit 2.
use std::process::Command; use std::process::Command;
#[test] #[test]
fn help_flags_print_usage_to_stdout_and_exit_zero() { fn help_flags_print_help_to_stdout_and_exit_zero() {
for args in [&["--help"][..], &["-h"], &["help"]] { for args in [&["--help"][..], &["-h"], &["help"]] {
let out = Command::new(env!("CARGO_BIN_EXE_cadcraft-cli")).args(args).output().expect("run cadcraft-cli"); let out = Command::new(env!("CARGO_BIN_EXE_cadcraft-cli")).args(args).output().expect("run cadcraft-cli");
assert!(out.status.success(), "{args:?}: {:?}", out.status); assert!(out.status.success(), "{args:?}: {:?}", out.status);
let stdout = String::from_utf8_lossy(&out.stdout); let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.starts_with("usage:"), "{args:?}: {stdout:?}"); assert!(stdout.starts_with("cadcraft-cli: headless CADCraft\n"), "{args:?}: {stdout:?}");
assert!(String::from_utf8_lossy(&out.stderr).is_empty(), "{args:?}: stderr should be empty"); assert!(String::from_utf8_lossy(&out.stderr).is_empty(), "{args:?}: stderr should be empty");
} }
} }
#[test] #[test]
fn bare_invocation_still_reports_on_stderr() { fn bare_invocation_reports_on_stderr() {
let out = Command::new(env!("CARGO_BIN_EXE_cadcraft-cli")).output().expect("run cadcraft-cli"); let out = Command::new(env!("CARGO_BIN_EXE_cadcraft-cli")).output().expect("run cadcraft-cli");
assert_eq!(out.status.code(), Some(1)); assert_eq!(out.status.code(), Some(2));
assert!(out.stdout.is_empty(), "bare invocation keeps its usage on stderr"); assert!(out.stdout.is_empty(), "bare invocation keeps its help on stderr");
assert!(String::from_utf8_lossy(&out.stderr).starts_with("usage:")); assert!(String::from_utf8_lossy(&out.stderr).starts_with("cadcraft-cli: headless CADCraft\n"));
} }
+7
View File
@@ -0,0 +1,7 @@
mod cli;
mod drafting_spacing;
mod help;
mod mcp_connect;
mod skipped_records;
mod undrawn_entities;
mod usage;
@@ -33,12 +33,15 @@ fn replies(out: &Output) -> Vec<Value> {
#[test] #[test]
fn connect_without_address_fails_before_serving() { fn connect_without_address_fails_before_serving() {
let input = format!("{INIT}\n{CIRCLE}\n"); let input = format!("{INIT}\n{CIRCLE}\n");
for args in [&["--connect"][..], &["--connect", ""], &["--connect", "--foo"]] { // clap names an unknown flag rather than the value it left missing.
for (args, named) in
[(&["--connect"][..], "--connect <HOST:PORT>"), (&["--connect", ""], "--connect <HOST:PORT>"), (&["--connect", "--foo"], "'--foo'")]
{
let out = mcp(args, &input); let out = mcp(args, &input);
assert!(!out.status.success(), "{args:?} must exit non-zero"); assert_eq!(out.status.code(), Some(2), "{args:?} is a usage error");
assert!(out.stdout.is_empty(), "{args:?} must not answer MCP requests: {}", String::from_utf8_lossy(&out.stdout)); assert!(out.stdout.is_empty(), "{args:?} must not answer MCP requests: {}", String::from_utf8_lossy(&out.stdout));
let err = String::from_utf8_lossy(&out.stderr); let err = String::from_utf8_lossy(&out.stderr);
assert!(err.contains("--connect needs HOST:PORT"), "{args:?}: {err}"); assert!(err.contains(named), "{args:?}: {err}");
} }
} }
+950
View File
@@ -0,0 +1,950 @@
//! The command line's behaviour: exit codes, streams and messages for help requests, unknown and
//! malformed options, value flags in both spellings, repeats and arity. Every case goes through
//! `invoke`, so the table rows are the record of what each input does. Usage errors are clap's
//! (exit 2, stderr); a subcommand's own failures are `error: …` with exit 1.
use std::path::{Path, PathBuf};
struct Invoke {
code: i32,
stdout: String,
stderr: String,
}
fn invoke(args: &[&str]) -> Invoke {
let (mut out, mut err) = (Vec::new(), Vec::new());
let code = cadcraft_cli::run(args, &mut out, &mut err);
Invoke { code, stdout: String::from_utf8_lossy(&out).into_owned(), stderr: String::from_utf8_lossy(&err).into_owned() }
}
/// What a stream holds.
#[derive(Clone, Copy)]
enum Expect {
Empty,
/// The first line equals this.
Line(&'static str),
/// The first line starts with this.
Starts(&'static str),
}
use Expect::{Empty, Line, Starts};
struct Case {
name: &'static str,
args: &'static [&'static str],
code: i32,
stdout: Expect,
stderr: Expect,
}
const fn case(name: &'static str, args: &'static [&'static str], code: i32, stdout: Expect, stderr: Expect) -> Case {
Case { name, args, code, stdout, stderr }
}
/// A scratch directory holding `b.dxf` (the bracket sample) and `s.scr` (a one-line script).
struct Scratch(PathBuf);
impl Scratch {
fn new(tag: &str) -> Scratch {
let dir = std::env::temp_dir().join(format!("cc-cli-usage-{tag}-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("create scratch dir");
let b = dir.join("b.dxf");
let r = invoke(&["sample", "bracket", b.to_str().expect("utf-8 path")]);
assert_eq!(r.code, 0, "{}", r.stderr);
std::fs::write(dir.join("s.scr"), "LINE 0,0 1,1\n").expect("write script");
Scratch(dir)
}
/// Replaces `{dir}` with the scratch directory and `{b}` with the sample drawing.
fn expand(&self, text: &str) -> String {
let dir = self.0.to_str().expect("utf-8 path");
text.replace("{b}", &Path::new(dir).join("b.dxf").to_string_lossy()).replace("{dir}", dir)
}
}
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn check(what: &str, stream: &str, got: &str, expect: Expect, scratch: &Scratch) {
let first = got.lines().next().unwrap_or("");
match expect {
Empty => assert!(got.is_empty(), "{what}: {stream} should be empty, got {got:?}"),
Line(l) => assert_eq!(first, scratch.expand(l), "{what}: {stream}"),
Starts(p) => assert!(first.starts_with(&scratch.expand(p)), "{what}: {stream} should start with {p:?}, got {first:?}"),
}
}
fn run_cases(tag: &str, cases: &[Case]) {
let scratch = Scratch::new(tag);
for c in cases {
let args: Vec<String> = c.args.iter().map(|a| scratch.expand(a)).collect();
let args: Vec<&str> = args.iter().map(String::as_str).collect();
let what = format!("{}: {args:?}", c.name);
let r = invoke(&args);
assert_eq!(r.code, c.code, "{what}: exit code (stderr {:?})", r.stderr);
check(&what, "stdout", &r.stdout, c.stdout, &scratch);
check(&what, "stderr", &r.stderr, c.stderr, &scratch);
}
}
#[test]
fn top_level_requests() {
run_cases(
"top",
&[
case("--help", &["--help"], 0, Line("cadcraft-cli: headless CADCraft"), Empty),
case("-h", &["-h"], 0, Line("cadcraft-cli: headless CADCraft"), Empty),
case("help", &["help"], 0, Line("cadcraft-cli: headless CADCraft"), Empty),
case("--version", &["--version"], 0, Starts("cadcraft-cli "), Empty),
case("-V", &["-V"], 0, Starts("cadcraft-cli "), Empty),
case("version is not a subcommand", &["version"], 2, Empty, Line("error: unrecognized subcommand 'version'")),
case("bare", &[], 2, Empty, Line("cadcraft-cli: headless CADCraft")),
case("unknown subcommand", &["foo"], 2, Empty, Line("error: unrecognized subcommand 'foo'")),
case("unknown top-level flag", &["--bogus"], 2, Empty, Line("error: unexpected argument '--bogus' found")),
case("empty subcommand", &[""], 2, Empty, Line("error: unrecognized subcommand ''")),
],
);
}
/// Each subcommand's help, from `--help`, `-h` or `help <subcommand>`: its first line is the
/// subcommand's summary.
#[test]
fn subcommand_help_requests() {
run_cases(
"subhelp",
&[
case("info --help", &["info", "--help"], 0, Line("Print a drawing's summary as JSON"), Empty),
case("info -h", &["info", "-h"], 0, Line("Print a drawing's summary as JSON"), Empty),
case(
"convert --help",
&["convert", "--help"],
0,
Line("Convert a drawing; OUT's extension picks the format (dxf, dwg, dwt, svg, png, pdf)"),
Empty,
),
case(
"convert -h",
&["convert", "-h"],
0,
Line("Convert a drawing; OUT's extension picks the format (dxf, dwg, dwt, svg, png, pdf)"),
Empty,
),
case(
"run --help",
&["run", "--help"],
0,
Line("Open drawings (or start a new one), run scripts and commands in order, save or export the result"),
Empty,
),
case(
"run -h",
&["run", "-h"],
0,
Line("Open drawings (or start a new one), run scripts and commands in order, save or export the result"),
Empty,
),
case("commands --help", &["commands", "--help"], 0, Line("Print the command catalog as JSON"), Empty),
case(
"mcp --help",
&["mcp", "--help"],
0,
Line("Run the MCP server on stdio: a headless drawing session, or a bridge to the running app"),
Empty,
),
case(
"perf --help",
&["perf", "--help"],
0,
Line(
"Time the engine on a synthetic N-entity drawing (default 200000), or the canvas display-list build of a drawing, zoomed in step by step",
),
Empty,
),
case("sample --help", &["sample", "--help"], 0, Line("Write a built-in sample drawing"), Empty),
case(
"help run",
&["help", "run"],
0,
Line("Open drawings (or start a new one), run scripts and commands in order, save or export the result"),
Empty,
),
case("help info", &["help", "info"], 0, Line("Print a drawing's summary as JSON"), Empty),
case(
"help after a flag missing its value",
&["run", "--sample", "--script", "--help"],
0,
Line("Open drawings (or start a new one), run scripts and commands in order, save or export the result"),
Empty,
),
case(
"help after an unknown flag",
&["run", "--bogus", "--help"],
0,
Line("Open drawings (or start a new one), run scripts and commands in order, save or export the result"),
Empty,
),
case("help after an extra argument", &["info", "{b}", "extra", "-h"], 0, Line("Print a drawing's summary as JSON"), Empty),
case("--help after -- is a file name", &["run", "--", "--help"], 1, Empty, Starts("error: invalid parameters for `open`: --help:")),
case("run --version", &["run", "--version"], 2, Empty, Line("error: unexpected argument '--version' found")),
case("info --version", &["info", "--version"], 2, Empty, Line("error: unexpected argument '--version' found")),
],
);
}
#[test]
fn unknown_flags_on_every_subcommand() {
run_cases(
"unknown",
&[
case("info", &["info", "{b}", "--bogus"], 2, Empty, Line("error: unexpected argument '--bogus' found")),
case("info, flag first", &["info", "--bogus", "{b}"], 2, Empty, Line("error: unexpected argument '--bogus' found")),
case("info, short flag", &["info", "-x"], 2, Empty, Line("error: unexpected argument '-x' found")),
case("info, a file name starting with - after --", &["info", "--", "-x"], 1, Empty, Starts("error: invalid parameters for `open`: -x:")),
case("convert", &["convert", "{b}", "{dir}/o.svg", "--bogus"], 2, Empty, Line("error: unexpected argument '--bogus' found")),
case("run", &["run", "--sample", "--bogus"], 2, Empty, Line("error: unexpected argument '--bogus' found")),
case("run, flag first", &["run", "--bogus"], 2, Empty, Line("error: unexpected argument '--bogus' found")),
case("commands", &["commands", "--bogus"], 2, Empty, Line("error: unexpected argument '--bogus' found")),
case("commands -- ends the options", &["commands", "--"], 0, Line("["), Empty),
case("mcp", &["mcp", "--bogus"], 2, Empty, Line("error: unexpected argument '--bogus' found")),
case("mcp positional", &["mcp", "extra"], 2, Empty, Line("error: unexpected argument 'extra' found")),
case("perf", &["perf", "--bogus"], 2, Empty, Line("error: unexpected argument '--bogus' found")),
case("sample", &["sample", "--bogus"], 2, Empty, Line("error: unexpected argument '--bogus' found")),
case("sample after name", &["sample", "bracket", "--bogus"], 2, Empty, Line("error: unexpected argument '--bogus' found")),
],
);
}
/// `--x=v` is a spelling of `--x v`, and the one a value starting with `-` needs.
#[test]
fn value_flags_in_both_forms() {
run_cases(
"forms",
&[
case("--window v", &["convert", "{b}", "{dir}/w1.svg", "--window", "0,0,100,100"], 0, Empty, Starts("wrote {dir}/w1.svg (")),
case("--window=v", &["convert", "{b}", "{dir}/w2.svg", "--window=0,0,100,100"], 0, Empty, Starts("wrote {dir}/w2.svg (")),
case(
"run --window v",
&["run", "--sample", "--window", "0,0,100,100", "--export", "{dir}/w3.svg"],
0,
Empty,
Starts("wrote {dir}/w3.svg ("),
),
case(
"run --window=v",
&["run", "--sample", "--window=0,0,100,100", "--export", "{dir}/w4.svg"],
0,
Empty,
Starts("wrote {dir}/w4.svg ("),
),
case("--view v", &["convert", "{b}", "{dir}/v1.svg", "--view", "extents"], 0, Empty, Starts("wrote {dir}/v1.svg (")),
case("--view=v", &["convert", "{b}", "{dir}/v2.svg", "--view=extents"], 0, Empty, Starts("wrote {dir}/v2.svg (")),
case("run --view v", &["run", "--sample", "--view", "current", "--export", "{dir}/v3.svg"], 0, Empty, Starts("wrote {dir}/v3.svg (")),
case("run --view=v", &["run", "--sample", "--view=extents", "--export={dir}/v4.svg"], 0, Empty, Starts("wrote {dir}/v4.svg (")),
case("--script v", &["run", "--sample", "--script", "LINE 0,0 1,1"], 0, Empty, Line("Command: LINE")),
case("--script=v", &["run", "--sample", "--script=LINE 0,0 1,1"], 0, Empty, Line("Command: LINE")),
case("--script-file v", &["run", "--sample", "--script-file", "{dir}/s.scr"], 0, Empty, Starts("Command: LINE")),
case("--script-file=v", &["run", "--sample", "--script-file={dir}/s.scr"], 0, Empty, Starts("Command: LINE")),
case("--cmd v", &["run", "--sample", "--cmd", "nosuch"], 1, Empty, Line("error: unknown command `nosuch`")),
case("--cmd=v", &["run", "--sample", "--cmd=nosuch"], 1, Empty, Line("error: unknown command `nosuch`")),
case("--cmd=-v", &["run", "--sample", "--cmd=-x"], 1, Empty, Line("error: unknown command `-x`")),
case("--save v", &["run", "--sample", "--save", "{dir}/s1.dxf"], 0, Empty, Starts("wrote {dir}/s1.dxf (")),
case("--save=v", &["run", "--sample", "--save={dir}/s2.dxf"], 0, Empty, Starts("wrote {dir}/s2.dxf (")),
case("--export v", &["run", "--sample", "--export", "{dir}/e1.svg"], 0, Empty, Starts("wrote {dir}/e1.svg (")),
case("--export=v", &["run", "--sample", "--export={dir}/e2.svg"], 0, Empty, Starts("wrote {dir}/e2.svg (")),
case(
"--window=-v: negative coordinates need =",
&["convert", "{b}", "{dir}/w5.svg", "--window=-1000,-1000,1000,1000"],
0,
Empty,
Starts("wrote {dir}/w5.svg ("),
),
case(
"--window -v: a value starting with - is a flag",
&["convert", "{b}", "{dir}/w6.svg", "--window", "-1000,-1000,1000,1000"],
2,
Empty,
Line("error: unexpected argument '-1' found"),
),
case("--connect v", &["mcp", "--connect", "127.0.0.1:1"], 1, Empty, Starts("error: cannot connect to 127.0.0.1:1:")),
case("--connect=v", &["mcp", "--connect=127.0.0.1:1"], 1, Empty, Starts("error: cannot connect to 127.0.0.1:1:")),
],
);
}
/// A value flag at the end of the arguments, or followed by another flag, is missing its value:
/// a flag is never taken as a value.
#[test]
fn flags_missing_their_value() {
run_cases(
"missing",
&[
case(
"convert --window last",
&["convert", "{b}", "{dir}/o.svg", "--window"],
2,
Empty,
Line("error: a value is required for '--window <x0,y0,x1,y1>' but none was supplied"),
),
case(
"convert --view last",
&["convert", "{b}", "{dir}/o.svg", "--view"],
2,
Empty,
Line("error: a value is required for '--view <extents|current>' but none was supplied"),
),
case(
"convert --window then flag",
&["convert", "{b}", "{dir}/o.svg", "--window", "--view", "extents"],
2,
Empty,
Line("error: a value is required for '--window <x0,y0,x1,y1>' but none was supplied"),
),
case(
"convert --view then flag",
&["convert", "{b}", "{dir}/o.svg", "--view", "--window", "0,0,1,1"],
2,
Empty,
Line("error: a value is required for '--view <extents|current>' but none was supplied"),
),
case(
"run --window last",
&["run", "--sample", "--window"],
2,
Empty,
Line("error: a value is required for '--window <x0,y0,x1,y1>' but none was supplied"),
),
case(
"run --view last",
&["run", "--sample", "--view"],
2,
Empty,
Line("error: a value is required for '--view <extents|current>' but none was supplied"),
),
case(
"run --script last",
&["run", "--sample", "--script"],
2,
Empty,
Line("error: a value is required for '--script <TEXT>' but none was supplied"),
),
case(
"run --script then flag",
&["run", "--sample", "--script", "--sample"],
2,
Empty,
Line("error: a value is required for '--script <TEXT>' but none was supplied"),
),
case(
"run --script-file last",
&["run", "--script-file"],
2,
Empty,
Line("error: a value is required for '--script-file <F.scr>' but none was supplied"),
),
case(
"run --script-file then flag",
&["run", "--sample", "--script-file", "--sample"],
2,
Empty,
Line("error: a value is required for '--script-file <F.scr>' but none was supplied"),
),
case(
"run --cmd last",
&["run", "--sample", "--cmd"],
2,
Empty,
Line("error: a value is required for '--cmd <id {json}>' but none was supplied"),
),
case(
"run --cmd then flag",
&["run", "--sample", "--cmd", "--sample"],
2,
Empty,
Line("error: a value is required for '--cmd <id {json}>' but none was supplied"),
),
case(
"run --save last",
&["run", "--sample", "--save"],
2,
Empty,
Line("error: a value is required for '--save <OUT.dxf>' but none was supplied"),
),
case(
"run --export last",
&["run", "--sample", "--export"],
2,
Empty,
Line("error: a value is required for '--export <OUT.png>' but none was supplied"),
),
case(
"mcp --connect last",
&["mcp", "--connect"],
2,
Empty,
Line("error: a value is required for '--connect <HOST:PORT>' but none was supplied"),
),
case(
"mcp --connect then flag",
&["mcp", "--connect", "--lazy"],
2,
Empty,
Line("error: a value is required for '--connect <HOST:PORT>' but none was supplied"),
),
case(
"mcp --connect empty",
&["mcp", "--connect", " "],
2,
Empty,
Line("error: invalid value ' ' for '--connect <HOST:PORT>': expected HOST:PORT (the app's --control port, e.g. 127.0.0.1:7979)"),
),
case(
"mcp --connect without a port",
&["mcp", "--connect", "example.com"],
2,
Empty,
Line(
"error: invalid value 'example.com' for '--connect <HOST:PORT>': expected HOST:PORT (the app's --control port, e.g. 127.0.0.1:7979)",
),
),
case(
"mcp --connect port out of range",
&["mcp", "--connect", "host:99999"],
2,
Empty,
Line(
"error: invalid value 'host:99999' for '--connect <HOST:PORT>': expected HOST:PORT (the app's --control port, e.g. 127.0.0.1:7979)",
),
),
case(
"mcp --connect=-v",
&["mcp", "--connect=-v"],
2,
Empty,
Line("error: invalid value '-v' for '--connect <HOST:PORT>': expected HOST:PORT (the app's --control port, e.g. 127.0.0.1:7979)"),
),
],
);
}
#[test]
fn repeated_and_combined_flags() {
run_cases(
"repeat",
&[
case(
"--save twice",
&["run", "--sample", "--save", "{dir}/a.dxf", "--save", "{dir}/b2.dxf"],
2,
Empty,
Line("error: the argument '--save <OUT.dxf>' cannot be used multiple times"),
),
case(
"--export twice",
&["run", "--sample", "--export", "{dir}/x1.svg", "--export", "{dir}/x2.svg"],
2,
Empty,
Line("error: the argument '--export <OUT.png>' cannot be used multiple times"),
),
case(
"--window twice",
&["run", "--sample", "--window", "0,0,1,1", "--window", "0,0,2,2", "--export", "{dir}/ww.svg"],
2,
Empty,
Line("error: the argument '--window <x0,y0,x1,y1>' cannot be used multiple times"),
),
case(
"convert --window twice",
&["convert", "{b}", "{dir}/cw.svg", "--window", "0,0,1,1", "--window", "0,0,2,2"],
2,
Empty,
Line("error: the argument '--window <x0,y0,x1,y1>' cannot be used multiple times"),
),
case(
"--view twice, different",
&["run", "--sample", "--view", "extents", "--view", "current", "--export", "{dir}/vv.svg"],
2,
Empty,
Line("error: the argument '--view <extents|current>' cannot be used multiple times"),
),
case(
"--view twice, same",
&["run", "--sample", "--view", "extents", "--view", "extents", "--export", "{dir}/vs.svg"],
2,
Empty,
Line("error: the argument '--view <extents|current>' cannot be used multiple times"),
),
case("--sample twice", &["run", "--sample", "--sample"], 2, Empty, Line("error: the argument '--sample' cannot be used multiple times")),
case("--metric twice", &["run", "--metric", "--metric"], 2, Empty, Line("error: the argument '--metric' cannot be used multiple times")),
case(
"--lazy twice",
&["mcp", "--connect", "127.0.0.1:1", "--lazy", "--lazy"],
2,
Empty,
Line("error: the argument '--lazy' cannot be used multiple times"),
),
case(
"--launch twice",
&["mcp", "--connect", "127.0.0.1:1", "--launch", "--launch"],
2,
Empty,
Line("error: the argument '--launch' cannot be used multiple times"),
),
case(
"--script, --script-file and --cmd repeat",
&[
"run",
"--sample",
"--script",
"LINE 0,0 1,1",
"--script",
"LINE 0,0 2,2",
"--script-file",
"{dir}/s.scr",
"--script-file",
"{dir}/s.scr",
],
0,
Empty,
Starts("Command: LINE"),
),
case(
"--window and --view",
&["run", "--sample", "--window", "0,0,1,1", "--view", "extents", "--export", "{dir}/wv.svg"],
2,
Empty,
Line("error: the argument '--window <x0,y0,x1,y1>' cannot be used with '--view <extents|current>'"),
),
case(
"convert --window and --view",
&["convert", "{b}", "{dir}/cwv.svg", "--window", "0,0,1,1", "--view", "extents"],
2,
Empty,
Line("error: the argument '--window <x0,y0,x1,y1>' cannot be used with '--view <extents|current>'"),
),
case(
"--view is case-insensitive",
&["run", "--sample", "--view", "EXTENTS", "--export", "{dir}/vu.svg"],
0,
Empty,
Starts("wrote {dir}/vu.svg ("),
),
case(
"--connect twice",
&["mcp", "--connect", "127.0.0.1:1", "--connect", "127.0.0.1:2"],
2,
Empty,
Line("error: the argument '--connect <HOST:PORT>' cannot be used multiple times"),
),
case("--lazy without --connect", &["mcp", "--lazy"], 2, Empty, Line("error: the following required arguments were not provided:")),
case("--launch without --connect", &["mcp", "--launch"], 2, Empty, Line("error: the following required arguments were not provided:")),
case("--sample --metric: both accepted", &["run", "--sample", "--metric"], 0, Empty, Empty),
case("--sample and a file", &["run", "--sample", "{b}"], 0, Empty, Empty),
case("two files", &["run", "{b}", "{b}"], 0, Empty, Empty),
case(
"--window without export",
&["run", "--sample", "--window", "0,0,1,1"],
2,
Empty,
Line("error: the following required arguments were not provided:"),
),
case(
"--view without export",
&["run", "--sample", "--view", "current"],
2,
Empty,
Line("error: the following required arguments were not provided:"),
),
case(
"--window with a dxf output",
&["convert", "{b}", "{dir}/o.dxf", "--window", "0,0,1,1"],
2,
Empty,
Line("error: --window only applies to image output (png, svg, pdf), not `{dir}/o.dxf`"),
),
case(
"--view with a dxf output",
&["convert", "{b}", "{dir}/o.dxf", "--view", "extents"],
2,
Empty,
Line("error: --view only applies to image output (png, svg, pdf), not `{dir}/o.dxf`"),
),
case(
"run --window with a dxf export",
&["run", "--sample", "--window", "0,0,1,1", "--export", "{dir}/o.dxf"],
2,
Empty,
Line("error: --window only applies to image output (png, svg, pdf), not `{dir}/o.dxf`"),
),
case(
"run --view with a dxf export",
&["run", "--sample", "--view", "current", "--export", "{dir}/o.dxf"],
2,
Empty,
Line("error: --view only applies to image output (png, svg, pdf), not `{dir}/o.dxf`"),
),
],
);
}
#[test]
fn arity_and_positional_arguments() {
run_cases(
"arity",
&[
case("info without a file", &["info"], 2, Empty, Line("error: the following required arguments were not provided:")),
case("info with two files", &["info", "{b}", "extra"], 2, Empty, Line("error: unexpected argument 'extra' found")),
case("info missing file", &["info", "missing.dxf"], 1, Empty, Starts("error: invalid parameters for `open`: missing.dxf:")),
case("convert with no files", &["convert"], 2, Empty, Line("error: the following required arguments were not provided:")),
case("convert with one file", &["convert", "{b}"], 2, Empty, Line("error: the following required arguments were not provided:")),
case(
"convert with three files",
&["convert", "{b}", "{dir}/o.svg", "{dir}/x.svg"],
2,
Empty,
Line("error: unexpected argument '{dir}/x.svg' found"),
),
case("run missing file", &["run", "missing.dxf"], 1, Empty, Starts("error: invalid parameters for `open`: missing.dxf:")),
case("commands, no match", &["commands", "zzzznomatch"], 0, Line("[]"), Empty),
case(
"commands with an extra argument",
&["commands", "zzzznomatch", "extra"],
2,
Empty,
Line("error: unexpected argument 'extra' found"),
),
case("sample without arguments", &["sample"], 2, Empty, Line("error: the following required arguments were not provided:")),
case("sample without an output", &["sample", "bracket"], 2, Empty, Line("error: the following required arguments were not provided:")),
case(
"sample with a bad name",
&["sample", "nosuch", "{dir}/o.dxf"],
2,
Empty,
Line("error: invalid value 'nosuch' for '<bracket|floorplan>'"),
),
case(
"sample names are case-sensitive",
&["sample", "Bracket", "{dir}/o.dxf"],
2,
Empty,
Line("error: invalid value 'Bracket' for '<bracket|floorplan>'"),
),
case(
"sample with an empty output",
&["sample", "bracket", ""],
2,
Empty,
Line("error: a value is required for '<OUT>' but none was supplied"),
),
case("info with an empty file name", &["info", ""], 2, Empty, Line("error: a value is required for '<FILE>' but none was supplied")),
case("perf with an empty file name", &["perf", ""], 2, Empty, Line("error: a value is required for '[N|FILE]' but none was supplied")),
case(
"sample with an extra argument",
&["sample", "bracket", "{dir}/sx.dxf", "extra"],
2,
Empty,
Line("error: unexpected argument 'extra' found"),
),
case("sample floor alias", &["sample", "floor", "{dir}/sf.dxf"], 0, Empty, Starts("wrote {dir}/sf.dxf (")),
case("sample with a bad extension", &["sample", "bracket", "{dir}/o.xyz"], 1, Empty, Line("error: unsupported file type `xyz`")),
case("perf with a word", &["perf", "abc"], 1, Empty, Starts("error: invalid parameters for `open`: abc:")),
case("perf with a negative number", &["perf", "-5"], 2, Empty, Line("error: unexpected argument '-5' found")),
case("perf with a missing file", &["perf", "missing.dxf"], 1, Empty, Starts("error: invalid parameters for `open`: missing.dxf:")),
case("perf 1", &["perf", "1"], 0, Starts("operation"), Empty),
case("perf 0 is raised to 1", &["perf", "0"], 0, Starts("operation"), Empty),
case("perf with two counts", &["perf", "1", "2"], 2, Empty, Line("error: unexpected argument '2' found")),
],
);
}
#[test]
fn bad_values() {
run_cases(
"values",
&[
case(
"--view sideways",
&["convert", "{b}", "{dir}/o.svg", "--view", "sideways"],
2,
Empty,
Line("error: invalid value 'sideways' for '--view <extents|current>'"),
),
case(
"run --view sideways",
&["run", "--sample", "--view", "sideways"],
2,
Empty,
Line("error: invalid value 'sideways' for '--view <extents|current>'"),
),
case(
"--view sideways before the output check",
&["convert", "{b}", "{dir}/o.dxf", "--view", "sideways"],
2,
Empty,
Line("error: invalid value 'sideways' for '--view <extents|current>'"),
),
case(
"--window 3 numbers",
&["convert", "{b}", "{dir}/o.svg", "--window", "1,2,3"],
2,
Empty,
Line("error: invalid value '1,2,3' for '--window <x0,y0,x1,y1>': expected four numbers x0,y0,x1,y1"),
),
case(
"--window 5 numbers",
&["convert", "{b}", "{dir}/o.svg", "--window", "1,2,3,4,5"],
2,
Empty,
Line("error: invalid value '1,2,3,4,5' for '--window <x0,y0,x1,y1>': expected four numbers x0,y0,x1,y1"),
),
case(
"--window reversed",
&["convert", "{b}", "{dir}/o.svg", "--window", "3,3,1,1"],
2,
Empty,
Line("error: invalid value '3,3,1,1' for '--window <x0,y0,x1,y1>': need x0 < x1 and y0 < y1 (lower-left corner first)"),
),
case(
"--window not numbers",
&["convert", "{b}", "{dir}/o.svg", "--window", "a,b,c,d"],
2,
Empty,
Line("error: invalid value 'a,b,c,d' for '--window <x0,y0,x1,y1>': x0 must be a finite number, got `a`"),
),
case(
"--window NaN",
&["convert", "{b}", "{dir}/o.svg", "--window", "NaN,0,1,1"],
2,
Empty,
Line("error: invalid value 'NaN,0,1,1' for '--window <x0,y0,x1,y1>': x0 must be a finite number, got `NaN`"),
),
case(
"--window too large",
&["convert", "{b}", "{dir}/o.svg", "--window", "0,0,1e13,1"],
2,
Empty,
Line("error: invalid value '0,0,1e13,1' for '--window <x0,y0,x1,y1>': export window: coordinates must lie within ±1e12"),
),
case(
"run --window 3 numbers",
&["run", "--sample", "--window", "1,2,3", "--export", "{dir}/o.svg"],
2,
Empty,
Line("error: invalid value '1,2,3' for '--window <x0,y0,x1,y1>': expected four numbers x0,y0,x1,y1"),
),
case(
"run --window reversed",
&["run", "--sample", "--window", "3,3,1,1", "--export", "{dir}/o.svg"],
2,
Empty,
Line("error: invalid value '3,3,1,1' for '--window <x0,y0,x1,y1>': need x0 < x1 and y0 < y1 (lower-left corner first)"),
),
case(
"--cmd bad json",
&["run", "--sample", "--cmd", "line {bad json"],
2,
Empty,
Line("error: invalid value 'line {bad json' for '--cmd <id {json}>': key must be a string at line 1 column 2"),
),
case(
"--cmd bad json after a good step: nothing runs",
&["run", "--sample", "--cmd", "line {\"points\":[[0,0],[1,1]]}", "--cmd", "line {"],
2,
Empty,
Starts("error: invalid value 'line {' for '--cmd <id {json}>': "),
),
case(
"--cmd without an id",
&["run", "--sample", "--cmd", " {}"],
2,
Empty,
Line("error: invalid value ' {}' for '--cmd <id {json}>': expected a command id, then optionally its JSON parameters"),
),
case(
"--cmd empty",
&["run", "--sample", "--cmd="],
2,
Empty,
Line("error: invalid value '' for '--cmd <id {json}>': expected a command id, then optionally its JSON parameters"),
),
case("--cmd unknown id", &["run", "--sample", "--cmd", "nosuch"], 1, Empty, Line("error: unknown command `nosuch`")),
case(
"--cmd {last} with no earlier handle",
&["run", "--sample", "--cmd", "erase {\"handles\":[\"{last}\"]}"],
1,
Empty,
Line("error: --cmd erase: `{last}` needs an earlier --cmd in this run to have returned a handle"),
),
case("--script-file missing", &["run", "--sample", "--script-file", "missing.scr"], 1, Empty, Starts("error: missing.scr:")),
],
);
}
/// `mcp` serves on the process's real stdin and stdout, so the headless server runs through the
/// binary, with a closed stdin.
#[test]
fn mcp_headless_with_a_closed_stdin() {
let out = std::process::Command::new(env!("CARGO_BIN_EXE_cadcraft-cli"))
.arg("mcp")
.stdin(std::process::Stdio::null())
.output()
.expect("run cadcraft-cli");
assert_eq!(out.status.code(), Some(0));
assert!(out.stdout.is_empty() && out.stderr.is_empty());
}
/// A drawing to open whose name isn't valid Unicode reaches the handler as a path; the engine opens
/// files by text name, so it is refused there (exit 1), naming the file. Text that isn't Unicode is
/// a usage error.
#[cfg(unix)]
#[test]
fn non_unicode_arguments() {
use std::ffi::OsString;
use std::os::unix::ffi::OsStringExt;
let invoke_os = |args: Vec<OsString>| {
let (mut out, mut err) = (Vec::new(), Vec::new());
let code = cadcraft_cli::run(args, &mut out, &mut err);
assert!(out.is_empty());
(code, String::from_utf8_lossy(&err).into_owned())
};
let latin1 = || OsString::from_vec(b"caf\xe9.dxf".to_vec());
let (code, err) = invoke_os(vec![OsString::from("info"), latin1()]);
assert_eq!(code, 1);
assert_eq!(err.lines().next(), Some("error: caf\u{FFFD}.dxf: the file name is not valid Unicode"));
let (code, err) = invoke_os(vec![OsString::from("run"), OsString::from("--sample"), OsString::from("--cmd"), latin1()]);
assert_eq!(code, 2);
assert!(err.starts_with("error: invalid UTF-8 was detected"), "{err}");
}
/// An output name that isn't valid Unicode is written as given: only its extension picks the format.
#[cfg(unix)]
#[test]
fn non_unicode_output_names() {
use std::ffi::OsString;
use std::os::unix::ffi::OsStringExt;
let s = Scratch::new("nonunicode-out");
let latin1 = |ext: &str| {
let mut n = b"caf\xe9.".to_vec();
n.extend_from_slice(ext.as_bytes());
s.0.join(OsString::from_vec(n))
};
let run = |args: Vec<OsString>| {
let (mut out, mut err) = (Vec::new(), Vec::new());
let code = cadcraft_cli::run(args, &mut out, &mut err);
(code, String::from_utf8_lossy(&err).into_owned())
};
let b = s.0.join("b.dxf");
let cases: [(Vec<OsString>, std::path::PathBuf); 3] = [
(vec!["sample".into(), "bracket".into(), latin1("dxf").into()], latin1("dxf")),
(vec!["convert".into(), b.clone().into(), latin1("svg").into()], latin1("svg")),
(vec!["run".into(), "--sample".into(), "--save".into(), latin1("dwg").into()], latin1("dwg")),
];
for (args, path) in cases {
let (code, err) = run(args.clone());
assert_eq!(code, 0, "{args:?}: {err}");
assert!(path.exists(), "{path:?}");
assert!(std::fs::metadata(&path).expect("stat output").len() > 0, "{path:?}");
}
// An input with such a name is still refused.
let (code, err) = run(vec!["info".into(), latin1("dxf").into()]);
assert_eq!(code, 1);
assert!(err.contains("not valid Unicode"), "{err}");
}
/// An output that is an input file is refused before any work (exit 2, the input untouched) unless
/// `--in-place` allows it; the same file under another spelling counts, another existing file doesn't.
#[test]
fn output_that_replaces_an_input() {
let s = Scratch::new("inplace");
let dir = s.0.to_str().expect("utf-8 path").to_string();
let b = format!("{dir}/b.dxf");
let original = std::fs::read(&b).expect("read input");
std::fs::create_dir_all(format!("{dir}/sub")).expect("create sub");
let dotted = format!("{dir}/sub/../b.dxf");
#[allow(unused_mut)]
let mut spellings = vec![b.clone(), dotted];
#[cfg(unix)]
{
let link = format!("{dir}/link.dxf");
std::os::unix::fs::symlink(&b, &link).expect("symlink");
spellings.push(link);
}
for out in &spellings {
for args in [vec!["convert", &b, out], vec!["run", &b, "--save", out], vec!["run", &b, "--export", out]] {
let r = invoke(&args);
assert_eq!(r.code, 2, "{args:?}: {}", r.stderr);
assert!(r.stdout.is_empty(), "{args:?}");
assert!(r.stderr.starts_with(&format!("error: {out} is an input file")), "{args:?}: {}", r.stderr);
assert!(r.stderr.contains("--in-place"), "{args:?}: {}", r.stderr);
assert_eq!(std::fs::read(&b).expect("read input"), original, "{args:?}: input changed");
}
}
// Another existing file is replaced as before.
let other = format!("{dir}/other.dxf");
std::fs::write(&other, "x").expect("write other");
let r = invoke(&["convert", &b, &other]);
assert_eq!(r.code, 0, "{}", r.stderr);
assert_ne!(std::fs::read(&other).expect("read other"), b"x");
// --in-place allows it, for each spelling and flag: the input itself is rewritten, and a
// symlink stays a link to it.
let line = "line {\"points\":[[0,0],[1,1]]}";
let lines = |p: &str| std::fs::read_to_string(p).expect("read input").lines().filter(|l| l.trim_end() == "LINE").count();
let before = lines(&b);
for out in &spellings {
std::fs::write(&b, &original).expect("restore input");
let r = invoke(&["convert", &b, out, "--in-place"]);
assert_eq!(r.code, 0, "convert {out}: {}", r.stderr);
assert_eq!(lines(&b), before, "convert {out} keeps the drawing");
for flag in ["--save", "--export"] {
std::fs::write(&b, &original).expect("restore input");
let r = invoke(&["run", &b, "--cmd", line, "--in-place", flag, out]);
assert_eq!(r.code, 0, "{flag} {out}: {}", r.stderr);
assert_eq!(lines(&b), before + 1, "{flag} {out}: the input gains the line");
}
}
#[cfg(unix)]
{
let link = format!("{dir}/link.dxf");
assert!(std::fs::symlink_metadata(&link).expect("stat link").file_type().is_symlink(), "{link} is still a symlink");
}
// An output that doesn't exist yet is never an input.
let new = format!("{dir}/new.dxf");
let r = invoke(&["run", &b, "--save", &new]);
assert_eq!(r.code, 0, "{}", r.stderr);
assert!(std::path::Path::new(&new).is_file(), "{new} written");
}
/// JSON results are pretty-printed; `--compact` puts each on one line (RFC-0012 rule 1.3). `run`
/// prints one line per `--cmd` result either way.
#[test]
fn json_output_is_pretty_unless_compact() {
let s = Scratch::new("compact");
let b = format!("{}/b.dxf", s.0.to_str().expect("utf-8 path"));
for args in [&["info", b.as_str()][..], &["commands", "ellipse"]] {
let help = invoke(&[args[0], "--help"]);
assert!(
help.stdout.contains("--compact") && help.stdout.contains("One line of JSON instead of pretty-printed"),
"{} --help: {}",
args[0],
help.stdout
);
let pretty = invoke(args);
assert_eq!(pretty.code, 0, "{args:?}: {}", pretty.stderr);
assert!(pretty.stdout.lines().count() > 1, "{args:?} is pretty-printed: {}", pretty.stdout);
let compact = invoke(&[args, &["--compact"]].concat());
assert_eq!(compact.code, 0, "{args:?} --compact: {}", compact.stderr);
assert_eq!(compact.stdout.lines().count(), 1, "{args:?} --compact is one line: {}", compact.stdout);
let a: serde_json::Value = serde_json::from_str(&pretty.stdout).expect("pretty JSON");
let c: serde_json::Value = serde_json::from_str(&compact.stdout).expect("compact JSON");
assert_eq!(a, c, "{args:?}: same JSON either way");
}
let r = invoke(&["run", "--sample", "--cmd", "drawing.inspect", "--cmd", "drawing.inspect"]);
assert_eq!(r.code, 0, "{}", r.stderr);
assert_eq!(r.stdout.lines().count(), 2, "one line per --cmd result: {}", r.stdout);
assert_eq!(invoke(&["run", "--sample", "--compact"]).code, 2, "run takes no --compact");
}
@@ -0,0 +1,30 @@
//! The clap definition is consistent, and `examples/man-and-completions.rs` writes every shell's
//! completions and every man page under the names `packaging/linux/package.sh` installs.
// The example itself, so the test runs its code; its `main` is unused here.
#[allow(dead_code)]
#[path = "../examples/man-and-completions.rs"]
mod example;
#[test]
fn command_is_consistent() {
cadcraft_cli::command().debug_assert();
}
#[test]
fn the_example_writes_the_files_the_packages_install() {
let dir = std::path::Path::new(env!("CARGO_TARGET_TMPDIR")).join(format!("man-and-completions-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
example::generate(&dir).unwrap();
let mut written: Vec<String> = std::fs::read_dir(&dir).unwrap().map(|e| e.unwrap().file_name().into_string().unwrap()).collect();
written.sort();
let completions = ["cadcraft-cli.bash", "_cadcraft-cli", "cadcraft-cli.fish", "cadcraft-cli.elv", "_cadcraft-cli.ps1"];
let subcommands = ["info", "convert", "run", "commands", "mcp", "perf", "sample"];
let mut expected: Vec<String> = completions.map(String::from).into();
expected.push("cadcraft-cli.1".into());
expected.extend(subcommands.iter().map(|s| format!("cadcraft-cli-{s}.1")));
expected.sort();
assert_eq!(written, expected);
std::fs::remove_dir_all(&dir).unwrap();
}
+2 -1
View File
@@ -4,6 +4,7 @@ description = "CADCraft in the browser (eframe web runner, WebGPU with WebGL2 fa
version.workspace = true version.workspace = true
edition.workspace = true edition.workspace = true
license.workspace = true license.workspace = true
rust-version = "1.95"
publish = false publish = false
[lints] [lints]
@@ -26,7 +27,7 @@ serde_json = { workspace = true }
web-sys = { version = "0.3", features = ["HtmlInputElement", "FileList", "File"] } web-sys = { version = "0.3", features = ["HtmlInputElement", "FileList", "File"] }
[target.'cfg(target_arch = "wasm32")'.dependencies] [target.'cfg(target_arch = "wasm32")'.dependencies]
eframe = { workspace = true, features = ["persistence", "default_fonts", "wgpu", "accesskit"] } eframe = { workspace = true, features = ["persistence", "wgpu", "accesskit"] }
wasm-bindgen = "0.2" wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4" wasm-bindgen-futures = "0.4"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Location", "Navigator", "Blob", "BlobPropertyBag", "Url", "MessageEvent", "UrlSearchParams", "MediaQueryList"] } web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Location", "Navigator", "Blob", "BlobPropertyBag", "Url", "MessageEvent", "UrlSearchParams", "MediaQueryList"] }
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>CADCraft</title> <title>CADCraft</title>
<link data-trunk rel="rust" data-bin="cadcraft-web" data-wasm-opt="z" data-weak-refs /> <link data-trunk rel="rust" data-bin="cadcraft-web" data-wasm-opt="z" data-weak-refs />
<style> <style>
+2 -1
View File
@@ -95,7 +95,8 @@ impl Bridge {
} }
} }
/// After each frame: tell the parent when unsaved changes appear or go away. /// After each logic callback (including a hidden tab's): tell the parent when unsaved changes
/// appear or go away. `DirtyReport` suppresses duplicates, so this is idempotent.
pub fn report_dirty(&self, app: &CadApp, reported: &DirtyReport) { pub fn report_dirty(&self, app: &CadApp, reported: &DirtyReport) {
let dirty = app.session.docs.iter().any(|d| d.is_dirty()); let dirty = app.session.docs.iter().any(|d| d.is_dirty());
if reported.0.get() != Some(dirty) && self.send(&[("type", "cadcraft:dirty".into()), ("dirty", dirty.into())]) { if reported.0.get() != Some(dirty) && self.send(&[("type", "cadcraft:dirty".into()), ("dirty", dirty.into())]) {
+133 -12
View File
@@ -24,22 +24,39 @@ mod web {
const LOADING_ID: &str = "cadcraft_loading"; const LOADING_ID: &str = "cadcraft_loading";
/// The app, the files File > Open… and drag and drop read for it in the background, the /// The app, the files File > Open… and drag and drop read for it in the background, the
/// page embedding it (`?host=parent`), and the browser's `prefers-reduced-motion` (it stays /// page embedding it (`?host=parent`), the browser's `prefers-reduced-motion` (it stays
/// live, so View ▸ Accessibility ▸ Animation Effects ▸ Use System Setting follows a change). /// live, so View ▸ Accessibility ▸ Animation Effects ▸ Use System Setting follows a change),
struct Shell(CadApp, crate::upload::Inbox, Option<(crate::host::Bridge, crate::host::DirtyReport)>, Option<web_sys::MediaQueryList>); /// and the document title last written to the page.
struct Shell(
CadApp,
crate::upload::Inbox,
Option<(crate::host::Bridge, crate::host::DirtyReport)>,
Option<web_sys::MediaQueryList>,
Option<String>,
);
impl eframe::App for Shell { impl eframe::App for Shell {
/// Runs one frame: takes in files dropped on or picked for the page, drains the background
/// uploads, follows the browser's reduced-motion setting, runs the app, writes the browser
/// document title when it changed (so the tab and an embedding page name the open
/// drawing), and reports unsaved changes to that page.
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
self.1.take_dropped(ctx); // Background reads are consume-based and run on hidden ticks too. Dropped files are
// input and are read from `ui` only.
self.1.drain(&mut self.0); self.1.drain(&mut self.0);
self.0.system_animations = self.3.as_ref().map(|reduced| !reduced.matches()); self.0.system_animations = self.3.as_ref().map(|reduced| !reduced.matches());
self.0.logic(ctx); self.0.logic(ctx);
let drawing = self.0.session.state().ok();
crate::push_title(&mut self.4, drawing.map(|d| d.title.as_str()), drawing.is_some_and(|d| d.is_dirty()), set_page_title);
// `DirtyReport` suppresses duplicates, so reporting after every logic callback
// (including a hidden tab's) is idempotent and keeps the parent's guard current.
if let Some((bridge, reported)) = &self.2 { if let Some((bridge, reported)) = &self.2 {
bridge.report_dirty(&self.0, reported); bridge.report_dirty(&self.0, reported);
} }
} }
/// Forward raw input to the web `CadApp` and capture Tab at the command line.
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
self.0.raw_input_hook(raw); self.0.raw_input_hook(ctx, raw);
cadcraft_ui_egui::cmdline::capture_tab(ctx, raw); cadcraft_ui_egui::cmdline::capture_tab(ctx, raw);
} }
/// Preferences (interface theme and language) survive reloads; egui state is not kept. /// Preferences (interface theme and language) survive reloads; egui state is not kept.
@@ -50,11 +67,25 @@ mod web {
fn persist_egui_memory(&self) -> bool { fn persist_egui_memory(&self) -> bool {
false false
} }
/// On the first pass consume files dropped on the page, then delegate drawing to `CadApp::ui`.
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
self.0.ui(ui); let first_pass = ui.ctx().current_pass_index() == 0;
if let Some((bridge, reported)) = &self.2 { if first_pass {
bridge.report_dirty(&self.0, reported); // `take_dropped` consumes the raw list; a second pass or hidden tick cannot
// re-read the same file.
self.1.take_dropped(ui.ctx());
} }
self.0.ui(ui);
}
}
/// Writes the browser's document title, which names the tab and is what an embedding page
/// (`?host=parent`) reads. eframe's web runner ignores [`egui::ViewportCommand::Title`], so
/// the title has to reach the DOM directly.
fn set_page_title(title: &str) {
match web_sys::window().and_then(|window| window.document()) {
Some(document) => document.set_title(title),
None => log::warn!("cannot set the page title to {title:?}: there is no document"),
} }
} }
@@ -67,9 +98,11 @@ mod web {
} }
/// No file picker on the web: Save / Save As from the menu (and the close prompt) download the /// No file picker on the web: Save / Save As from the menu (and the close prompt) download the
/// drawing under its current name; `saveas {"path": "name.dxf"}` picks another name. /// drawing under its current name; `saveas {"path": "name.dxf"}` picks another name. There is
/// no file system either, so a dropped file is opened from its contents and APPLOAD says to
/// drop a `.lsp` file rather than asking for a name it cannot read.
fn services() -> Services { fn services() -> Services {
Services { pick_save: Some(Box::new(|name: &str| Some(name.to_string()))), ..Services::default() } Services { pick_save: Some(Box::new(|name: &str| Some(name.to_string()))), no_file_system: true, ..Services::default() }
} }
pub fn start() { pub fn start() {
@@ -152,7 +185,7 @@ mod web {
(bridge, crate::host::DirtyReport::default()) (bridge, crate::host::DirtyReport::default())
}); });
let reduced_motion = web_sys::window().and_then(|w| w.match_media("(prefers-reduced-motion: reduce)").ok().flatten()); let reduced_motion = web_sys::window().and_then(|w| w.match_media("(prefers-reduced-motion: reduce)").ok().flatten());
Ok(Box::new(Shell(app, inbox, host, reduced_motion))) Ok(Box::new(Shell(app, inbox, host, reduced_motion, None)))
}), }),
) )
.await; .await;
@@ -177,6 +210,44 @@ fn wants_cpu(query: &str) -> bool {
query.trim_start_matches('?').split('&').any(|p| p == "cpu" || p.starts_with("cpu=")) query.trim_start_matches('?').split('&').any(|p| p == "cpu" || p.starts_with("cpu="))
} }
/// The browser tab title: the open drawing's file name, a bullet once it has unsaved changes,
/// and the app name (`drawing.dwg • — CADCraft`), or just the app name with no drawing. An
/// embedding page (`?host=parent`) reads it to tell which drawing opened and whether it is dirty.
#[cfg(any(target_arch = "wasm32", test))]
fn document_title(name: Option<&str>, dirty: bool) -> String {
match name.filter(|n| !n.trim().is_empty()) {
Some(name) => format!("{name}{} — CADCraft", if dirty { " •" } else { "" }),
None => "CADCraft".to_string(),
}
}
/// The title to write this frame, or `None` while the page already shows it: the browser needs
/// the document title only when it changes, not on every frame.
#[cfg(any(target_arch = "wasm32", test))]
fn title_to_send(sent: &mut Option<String>, name: Option<&str>, dirty: bool) -> Option<String> {
let title = document_title(name, dirty);
if sent.as_deref() == Some(title.as_str()) {
return None;
}
*sent = Some(title.clone());
Some(title)
}
/// Writes a changed title through `write` (on the web, the browser's document title) and reports
/// whether it wrote one. Split from [`title_to_send`] so the change decision and the write are
/// both testable: eframe's web backend ignores `egui::ViewportCommand::Title`, so a title that is
/// computed but not written leaves the tab on `CADCraft`.
#[cfg(any(target_arch = "wasm32", test))]
fn push_title(sent: &mut Option<String>, name: Option<&str>, dirty: bool, write: impl FnOnce(&str)) -> bool {
match title_to_send(sent, name, dirty) {
Some(title) => {
write(&title);
true
}
None => false,
}
}
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
mod upload; mod upload;
@@ -192,8 +263,10 @@ fn main() {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::wants_cpu; use super::{document_title, push_title, title_to_send, wants_cpu};
/// Guards the `?cpu` URL flag: only a whole `cpu` query parameter (bare or with a value)
/// selects the CPU canvas, so `?sample` and `?gpucpu` stay on the GPU path.
#[test] #[test]
fn cpu_flag_is_detected() { fn cpu_flag_is_detected() {
assert!(wants_cpu("?cpu")); assert!(wants_cpu("?cpu"));
@@ -204,4 +277,52 @@ mod tests {
assert!(!wants_cpu("?webgl&sample")); assert!(!wants_cpu("?webgl&sample"));
assert!(!wants_cpu("?gpucpu")); assert!(!wants_cpu("?gpucpu"));
} }
/// Guards the tab title text: a drawing contributes its name and, when dirty, a trailing
/// bullet; a blank or missing name leaves the app name alone and never shows a bullet.
#[test]
fn viewport_title_names_the_document_and_marks_unsaved_changes() {
assert_eq!(document_title(Some("drawing.dwg"), false), "drawing.dwg — CADCraft");
assert_eq!(document_title(Some("drawing.dwg"), true), "drawing.dwg • — CADCraft");
assert_eq!(document_title(Some("Drawing1"), false), "Drawing1 — CADCraft");
assert_eq!(document_title(None, false), "CADCraft");
assert_eq!(document_title(None, true), "CADCraft");
assert_eq!(document_title(Some(" "), true), "CADCraft");
}
/// Guards the change signal: a title is sent on open, rename, dirty change and close, but
/// never twice in a row, so an idle tab is not repainted by a repeated title command.
#[test]
fn viewport_title_follows_open_rename_and_dirty_changes_only() {
let mut sent = None;
assert_eq!(title_to_send(&mut sent, Some("drawing.dwg"), false).as_deref(), Some("drawing.dwg — CADCraft"));
// The page already has it: send nothing.
assert_eq!(title_to_send(&mut sent, Some("drawing.dwg"), false), None);
// Unsaved changes add the bullet.
assert_eq!(title_to_send(&mut sent, Some("drawing.dwg"), true).as_deref(), Some("drawing.dwg • — CADCraft"));
// A rename shows the new name.
assert_eq!(title_to_send(&mut sent, Some("plan.dwg"), true).as_deref(), Some("plan.dwg • — CADCraft"));
// Saving clears it again.
assert_eq!(title_to_send(&mut sent, Some("plan.dwg"), false).as_deref(), Some("plan.dwg — CADCraft"));
// Closing the last drawing leaves the app name only.
assert_eq!(title_to_send(&mut sent, None, false).as_deref(), Some("CADCraft"));
assert_eq!(title_to_send(&mut sent, None, false), None);
}
/// Guards the title write: every changed title reaches the writer (on the web, the browser
/// document title) exactly once, and an unchanged one is not written again. Without the write
/// the tab and an embedding page stay on `CADCraft` however the drawing changes.
#[test]
fn changed_title_reaches_the_document_title_writer_once() {
let mut sent = None;
let mut written: Vec<String> = Vec::new();
assert!(push_title(&mut sent, Some("drawing.dwg"), false, |title| written.push(title.to_owned())));
// Unchanged: nothing is written again.
assert!(!push_title(&mut sent, Some("drawing.dwg"), false, |title| written.push(title.to_owned())));
// Unsaved changes are written.
assert!(push_title(&mut sent, Some("drawing.dwg"), true, |title| written.push(title.to_owned())));
// Closing the drawing writes the app name.
assert!(push_title(&mut sent, None, false, |title| written.push(title.to_owned())));
assert_eq!(written, ["drawing.dwg — CADCraft", "drawing.dwg • — CADCraft", "CADCraft"]);
}
} }
+2 -1
View File
@@ -68,8 +68,9 @@ impl Inbox {
} }
/// Read the files dropped on the page this frame (eframe hands them over unread on the web). /// Read the files dropped on the page this frame (eframe hands them over unread on the web).
/// The list is taken, not cloned: a later pass or hidden tick must not read the same drop.
pub fn take_dropped(&self, ctx: &egui::Context) { pub fn take_dropped(&self, ctx: &egui::Context) {
for f in ctx.input(|i| i.raw.dropped_files.clone()) { for f in ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files)) {
if let Some(file) = f.web_file() { if let Some(file) = f.web_file() {
self.read(file.clone()); self.read(file.clone());
} }
+13 -1
View File
@@ -4,6 +4,7 @@ description = "CADCraft desktop app"
version.workspace = true version.workspace = true
edition.workspace = true edition.workspace = true
license.workspace = true license.workspace = true
rust-version = "1.95"
publish = false publish = false
default-run = "cadcraft" default-run = "cadcraft"
@@ -14,10 +15,15 @@ workspace = true
cadcraft-engine = { workspace = true } cadcraft-engine = { workspace = true }
cadcraft-ui-egui = { workspace = true } cadcraft-ui-egui = { workspace = true }
cadcraft-io = { workspace = true } cadcraft-io = { workspace = true }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "links", "persistence"] } # For the shared per-session control-token helper (control_server.rs authentication).
cadcraft-mcp = { workspace = true }
eframe = { workspace = true, features = ["clipboard", "wgpu", "accesskit", "wayland", "x11", "links", "persistence"] }
egui = { workspace = true } egui = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
log = { workspace = true } log = { workspace = true }
clap = { workspace = true }
thiserror = { workspace = true }
chrono = { workspace = true }
[build-dependencies] [build-dependencies]
winresource = "0.1" winresource = "0.1"
@@ -30,10 +36,16 @@ winit = { version = "0.30", default-features = false, features = ["x11"] }
zbus = { version = "=5.19.0", default-features = false, features = ["async-io", "blocking-api"] } zbus = { version = "=5.19.0", default-features = false, features = ["async-io", "blocking-api"] }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies] [target.'cfg(not(target_arch = "wasm32"))'.dependencies]
# Floating panels open in their own OS windows on the desktop; the web build keeps them in the page.
craft-ui = { workspace = true, features = ["native-windows"] }
craft-update = { workspace = true }
rfd = { workspace = true } rfd = { workspace = true }
sys-locale = "0.3" sys-locale = "0.3"
[target.'cfg(target_os = "macos")'.dependencies] [target.'cfg(target_os = "macos")'.dependencies]
# `title_bar::sync_movable`: AppKit's title-band drag leaves the window still under the row's
# controls and under floating panels.
craft-window = { workspace = true }
muda = { version = "0.21", default-features = false } muda = { version = "0.21", default-features = false }
# View ▸ Accessibility ▸ Animation Effects ▸ Use System Setting: the system's Reduce motion # View ▸ Accessibility ▸ Animation Effects ▸ Use System Setting: the system's Reduce motion
# (system_animations.rs). Already in Cargo.lock through muda. # (system_animations.rs). Already in Cargo.lock through muda.
+107 -13
View File
@@ -48,11 +48,26 @@ impl Default for Limits {
} }
} }
/// Start the loopback server with a fresh session token and a bounded request queue.
/// Binding or token-issuance failure returns a disconnected receiver.
pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> { pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
match TcpListener::bind(("127.0.0.1", port)) { match TcpListener::bind(("127.0.0.1", port)) {
Ok(l) => { Ok(l) => {
log::info!("control server listening on 127.0.0.1:{port}"); // Loopback TCP cannot tell local users apart, so issue an unguessable per-session
serve_listener(l, Limits::default(), ctx) // token in an owner-only file and require it on every request (CWE-306). A client
// (the MCP bridge) reads it from the file, which needs the desktop user's filesystem
// access; another local account cannot.
let token = match cadcraft_mcp::control_token::issue(port) {
Ok(t) => Arc::new(t),
Err(e) => {
log::error!("control server not started: could not write the control token: {e}");
return sync_channel::<ControlRequest>(0).1;
}
};
if let Ok(path) = cadcraft_mcp::control_token::token_path(port) {
log::info!("control server listening on 127.0.0.1:{port} (token in {})", path.display());
}
serve_listener(l, Limits::default(), ctx, token)
} }
Err(e) => { Err(e) => {
log::error!("control server failed to bind 127.0.0.1:{port}: {e}"); log::error!("control server failed to bind 127.0.0.1:{port}: {e}");
@@ -61,10 +76,20 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
} }
} }
/// How long the accepting thread waits after a failed accept. Accepting can keep failing, for
/// example while the process has no file descriptors left, and retrying at once would keep a
/// core busy until it recovers.
const ACCEPT_RETRY: Duration = Duration::from_millis(100);
/// The connections `incoming` accepts, calling `wait` after each failed accept.
fn accepted<T>(incoming: impl Iterator<Item = std::io::Result<T>>, mut wait: impl FnMut()) -> impl Iterator<Item = T> {
incoming.filter_map(move |stream| stream.map_err(|_| wait()).ok())
}
/// Accept connections on `listener` under `limits`; requests arrive on the returned receiver. /// Accept connections on `listener` under `limits`; requests arrive on the returned receiver.
fn serve_listener(listener: TcpListener, limits: Limits, ctx: egui::Context) -> Receiver<ControlRequest> { fn serve_listener(listener: TcpListener, limits: Limits, ctx: egui::Context, token: Arc<String>) -> Receiver<ControlRequest> {
let (tx, rx) = sync_channel::<ControlRequest>(limits.queue.max(1)); let (tx, rx) = sync_channel::<ControlRequest>(limits.queue.max(1));
std::thread::spawn(move || accept_loop(listener, tx, ctx, limits)); std::thread::spawn(move || accept_loop(listener, tx, ctx, limits, token));
rx rx
} }
@@ -78,9 +103,10 @@ impl Drop for Slot {
} }
} }
fn accept_loop(listener: TcpListener, tx: SyncSender<ControlRequest>, ctx: egui::Context, limits: Limits) { /// Accept clients within the connection limit and give each worker the same session token.
fn accept_loop(listener: TcpListener, tx: SyncSender<ControlRequest>, ctx: egui::Context, limits: Limits, token: Arc<String>) {
let open = Arc::new(AtomicUsize::new(0)); let open = Arc::new(AtomicUsize::new(0));
for mut stream in listener.incoming().flatten() { for mut stream in accepted(listener.incoming(), || std::thread::sleep(ACCEPT_RETRY)) {
let _ = stream.set_write_timeout(Some(limits.write_timeout)); let _ = stream.set_write_timeout(Some(limits.write_timeout));
if open.fetch_add(1, Ordering::SeqCst) >= limits.max_clients { if open.fetch_add(1, Ordering::SeqCst) >= limits.max_clients {
open.fetch_sub(1, Ordering::SeqCst); open.fetch_sub(1, Ordering::SeqCst);
@@ -90,9 +116,10 @@ fn accept_loop(listener: TcpListener, tx: SyncSender<ControlRequest>, ctx: egui:
let slot = Slot(Arc::clone(&open)); let slot = Slot(Arc::clone(&open));
let tx = tx.clone(); let tx = tx.clone();
let ctx = ctx.clone(); let ctx = ctx.clone();
let token = Arc::clone(&token);
let _ = std::thread::Builder::new().spawn(move || { let _ = std::thread::Builder::new().spawn(move || {
let _slot = slot; let _slot = slot;
serve(stream, &tx, &ctx, limits); serve(stream, &tx, &ctx, limits, &token);
}); });
} }
} }
@@ -131,12 +158,14 @@ enum Line {
id: Value, id: Value,
method: String, method: String,
params: Value, params: Value,
token: Option<String>,
}, },
Blank, Blank,
/// Not a request: reply with this error and close the connection. /// Not a request: reply with this error and close the connection.
Reject(String), Reject(String),
} }
/// Parse a UTF-8 JSON request and its optional token, distinguishing blank lines from rejected input.
fn parse_line(bytes: &[u8]) -> Line { fn parse_line(bytes: &[u8]) -> Line {
let Ok(text) = std::str::from_utf8(bytes) else { let Ok(text) = std::str::from_utf8(bytes) else {
return Line::Reject("not a control request (invalid UTF-8); closing the connection".into()); return Line::Reject("not a control request (invalid UTF-8); closing the connection".into());
@@ -162,6 +191,7 @@ fn parse_line(bytes: &[u8]) -> Line {
id: obj.get("id").cloned().unwrap_or(Value::Null), id: obj.get("id").cloned().unwrap_or(Value::Null),
method: method.to_string(), method: method.to_string(),
params: obj.get("params").cloned().unwrap_or(json!({})), params: obj.get("params").cloned().unwrap_or(json!({})),
token: obj.get("token").and_then(Value::as_str).map(str::to_string),
} }
} }
@@ -172,7 +202,9 @@ fn is_http_request_line(text: &str) -> bool {
|| text.rsplit_once(' ').is_some_and(|(_, version)| version.starts_with("HTTP/")) || text.rsplit_once(' ').is_some_and(|(_, version)| version.starts_with("HTTP/"))
} }
fn serve(stream: TcpStream, tx: &SyncSender<ControlRequest>, ctx: &egui::Context, limits: Limits) { /// Serve bounded JSON lines, authenticating each request before enqueueing it for the UI.
/// Invalid credentials close the connection; queue and reply limits report whether a request ran.
fn serve(stream: TcpStream, tx: &SyncSender<ControlRequest>, ctx: &egui::Context, limits: Limits, token: &str) {
if stream.set_read_timeout(Some(limits.idle_timeout)).is_err() || stream.set_write_timeout(Some(limits.write_timeout)).is_err() { if stream.set_read_timeout(Some(limits.idle_timeout)).is_err() || stream.set_write_timeout(Some(limits.write_timeout)).is_err() {
return; return;
} }
@@ -190,7 +222,18 @@ fn serve(stream: TcpStream, tx: &SyncSender<ControlRequest>, ctx: &egui::Context
Ok(true) => match parse_line(&buf) { Ok(true) => match parse_line(&buf) {
Line::Blank => continue, Line::Blank => continue,
Line::Reject(e) => e, Line::Reject(e) => e,
Line::Request { id, method, params } => { Line::Request { id, method, params, token: given } => {
// Authenticate the peer before dispatching: loopback TCP does not identify the
// OS user, so only a client holding the owner-only session token may drive the
// desktop user's commands (CWE-306).
if !given.map(|g| cadcraft_mcp::control_token::matches(token, &g)).unwrap_or(false) {
let mut r = json!({"ok": false, "error": "unauthorized: missing or invalid control token", "state": "not-run"});
if let Some(o) = r.as_object_mut() {
o.insert("id".into(), id);
}
let _ = writeln!(out, "{r}");
break;
}
let (req, pending) = ControlRequest::new(method, params); let (req, pending) = ControlRequest::new(method, params);
let mut r = match tx.try_send(req) { let mut r = match tx.try_send(req) {
Ok(()) => { Ok(()) => {
@@ -222,6 +265,18 @@ fn serve(stream: TcpStream, tx: &SyncSender<ControlRequest>, ctx: &egui::Context
mod tests { mod tests {
use super::*; use super::*;
/// A failed accept is followed by a wait before the next one, so a listener that keeps
/// failing does not spin, and the connections around the failures still come through.
#[test]
fn a_failed_accept_waits_before_the_next() {
let failed = || Err(std::io::Error::other("no file descriptors left"));
let incoming = vec![failed(), Ok(1), failed(), failed(), Ok(2)];
let mut waits = 0;
let connections: Vec<i32> = super::accepted(incoming.into_iter(), || waits += 1).collect();
assert_eq!(connections, [1, 2]);
assert_eq!(waits, 3, "one wait per failed accept");
}
#[test] #[test]
fn lines_are_read_up_to_the_limit() { fn lines_are_read_up_to_the_limit() {
let mut buf = Vec::new(); let mut buf = Vec::new();
@@ -255,11 +310,14 @@ mod tests {
} }
} }
/// A fixed token for tests; real tokens are random (see `cadcraft_mcp::control_token`).
const TEST_TOKEN: &str = "test-control-token";
/// The control server on a loopback port under `limits`; requests arrive on the receiver. /// The control server on a loopback port under `limits`; requests arrive on the receiver.
fn serve_with(limits: Limits) -> (std::net::SocketAddr, Receiver<ControlRequest>) { fn serve_with(limits: Limits) -> (std::net::SocketAddr, Receiver<ControlRequest>) {
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
let addr = listener.local_addr().unwrap(); let addr = listener.local_addr().unwrap();
(addr, serve_listener(listener, limits, egui::Context::default())) (addr, serve_listener(listener, limits, egui::Context::default(), Arc::new(TEST_TOKEN.to_string())))
} }
/// The control server on a loopback port, with a stand-in UI that echoes each method. /// The control server on a loopback port, with a stand-in UI that echoes each method.
@@ -273,6 +331,11 @@ mod tests {
addr addr
} }
/// A request line carrying the test token, so it authenticates.
fn req_line(id: i64, method: &str) -> String {
json!({"id": id, "method": method, "token": TEST_TOKEN}).to_string() + "\n"
}
/// Send `input` on a new connection and read reply lines until the server closes it. /// Send `input` on a new connection and read reply lines until the server closes it.
fn exchange(addr: std::net::SocketAddr, input: &[u8]) -> Vec<Value> { fn exchange(addr: std::net::SocketAddr, input: &[u8]) -> Vec<Value> {
let mut c = TcpStream::connect(addr).unwrap(); let mut c = TcpStream::connect(addr).unwrap();
@@ -328,16 +391,17 @@ mod tests {
assert!(t.elapsed() < Duration::from_secs(4)); assert!(t.elapsed() < Duration::from_secs(4));
} }
/// Authenticated requests rejected by a full queue retain their IDs and report that they did not run.
#[test] #[test]
fn a_full_queue_answers_busy() { fn a_full_queue_answers_busy() {
let (addr, rx) = serve_with(short()); let (addr, rx) = serve_with(short());
// No UI drains the one-place queue: the first request fills it, the second finds it full. // No UI drains the one-place queue: the first request fills it, the second finds it full.
let first = std::thread::spawn(move || exchange(addr, b"{\"id\":1,\"method\":\"m\"}\n")); let first = std::thread::spawn(move || exchange(addr, req_line(1, "m").as_bytes()));
let deadline = std::time::Instant::now() + Duration::from_secs(4); let deadline = std::time::Instant::now() + Duration::from_secs(4);
let busy = loop { let busy = loop {
let mut c = TcpStream::connect(addr).unwrap(); let mut c = TcpStream::connect(addr).unwrap();
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
c.write_all(b"{\"id\":2,\"method\":\"m\"}\n").unwrap(); c.write_all(req_line(2, "m").as_bytes()).unwrap();
let mut line = String::new(); let mut line = String::new();
BufReader::new(c).read_line(&mut line).unwrap(); BufReader::new(c).read_line(&mut line).unwrap();
let v: Value = serde_json::from_str(&line).unwrap(); let v: Value = serde_json::from_str(&line).unwrap();
@@ -350,12 +414,14 @@ mod tests {
drop(rx); drop(rx);
} }
/// Replies on one authenticated connection preserve the ID of each request.
#[test] #[test]
fn requests_are_answered_with_their_ids() { fn requests_are_answered_with_their_ids() {
let addr = serve_echo(); let addr = serve_echo();
let mut c = TcpStream::connect(addr).unwrap(); let mut c = TcpStream::connect(addr).unwrap();
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
c.write_all(b"{\"id\":3,\"method\":\"ping\"}\r\n\n{\"id\":4,\"method\":\"pong\"}\n").unwrap(); let input = format!("{}\n{}", req_line(3, "ping").trim_end(), req_line(4, "pong"));
c.write_all(input.as_bytes()).unwrap();
let mut r = BufReader::new(c); let mut r = BufReader::new(c);
for (id, method) in [(3, "ping"), (4, "pong")] { for (id, method) in [(3, "ping"), (4, "pong")] {
let mut line = String::new(); let mut line = String::new();
@@ -365,6 +431,34 @@ mod tests {
} }
} }
/// Missing and incorrect session tokens are rejected before dispatch.
#[test]
fn a_request_without_the_token_is_unauthorized() {
// A local peer that does not hold the session token cannot drive the app (CWE-306).
let addr = serve_echo();
let replies = exchange(addr, b"{\"id\":9,\"method\":\"ping\"}\n");
assert_eq!(replies.len(), 1, "{replies:?}");
assert_eq!(replies[0]["ok"], false);
assert!(replies[0]["error"].as_str().unwrap().contains("unauthorized"), "{replies:?}");
// A wrong token is also refused.
let wrong = exchange(addr, b"{\"id\":9,\"method\":\"ping\",\"token\":\"nope\"}\n");
assert_eq!(wrong[0]["ok"], false);
assert!(wrong[0]["error"].as_str().unwrap().contains("unauthorized"), "{wrong:?}");
}
/// A request carrying the session token reaches the control dispatcher.
#[test]
fn a_request_with_the_token_is_dispatched() {
let addr = serve_echo();
let mut c = TcpStream::connect(addr).unwrap();
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
c.write_all(req_line(10, "ping").as_bytes()).unwrap();
let mut line = String::new();
BufReader::new(c).read_line(&mut line).unwrap();
let v: Value = serde_json::from_str(&line).unwrap();
assert_eq!((v["ok"].as_bool(), v["method"].as_str()), (Some(true), Some("ping")), "{line}");
}
#[test] #[test]
fn extra_connections_are_refused() { fn extra_connections_are_refused() {
let addr = serve_echo(); let addr = serve_echo();
+172
View File
@@ -0,0 +1,172 @@
//! The `CADCRAFT_*` variables of the desktop app, read once at startup into [`Env`] and passed on
//! from there; no library crate reads them. `--help` lists them from [`VARS`]. `--control`'s
//! fallback, `CADCRAFT_CONTROL_PORT`, is clap's (see `launch_args`).
use std::ffi::OsString;
use std::fmt::Write as _;
use std::path::PathBuf;
use cadcraft_ui_egui::switch::Switch;
const CONFIG_DIR: &str = "CADCRAFT_CONFIG_DIR";
const LOCALE: &str = "CADCRAFT_LOCALE";
const VSYNC: &str = "CADCRAFT_VSYNC";
const WAYLAND: &str = "CADCRAFT_WAYLAND";
const NO_NATIVE_MENU: &str = "CADCRAFT_NO_NATIVE_MENU";
const SYSTEM_CURSOR: &str = "CADCRAFT_SYSTEM_CURSOR";
const FONTALT: &str = "CADCRAFT_FONTALT";
const FONTFALLBACK: &str = "CADCRAFT_FONTFALLBACK";
/// Every variable [`Env`] reads, with its `--help` line.
pub(crate) const VARS: &[(&str, &str)] = &[
(CONFIG_DIR, "the settings folder (logs, autosave, workspace, plotters); empty means no settings folder"),
(LOCALE, "the interface language, instead of the system's"),
(VSYNC, "1 presents with vsync, 0 without it (default: without on Linux and BSD)"),
(WAYLAND, "1 keeps the native Wayland backend instead of XWayland; 0 or unset is off"),
(NO_NATIVE_MENU, "macOS: 1 keeps the menus inside the window; 0 or unset is off"),
(SYSTEM_CURSOR, "1 uses the system cursor instead of the drawn crosshair; 0 or unset is off"),
(FONTALT, "the font that replaces a missing one"),
(FONTFALLBACK, "the fonts tried, in order, for characters a font lacks"),
];
/// The process environment, read once at startup. A blank variable counts as unset, except
/// `CADCRAFT_CONFIG_DIR`.
#[derive(Debug, PartialEq)]
pub(crate) struct Env {
/// The settings folder (logs, autosave, workspace, plotters): `CADCRAFT_CONFIG_DIR`; else, when
/// that is unset, the platform's (`cadcraft_engine::settings_dir::platform`). `None`: no
/// folder, because the variable is empty or blank (the way to run without one) or the
/// platform has none.
pub(crate) config_dir: Option<PathBuf>,
/// The interface language, instead of the system's.
pub(crate) locale: Option<String>,
/// Present with vsync (default: off on Linux and BSD, on elsewhere).
pub(crate) vsync: Switch,
/// Keep the native Wayland backend instead of XWayland.
#[cfg_attr(not(all(unix, not(target_os = "macos"))), allow(dead_code))]
pub(crate) wayland: Switch,
/// macOS: keep the menus inside the window.
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
pub(crate) no_native_menu: Switch,
/// Use the system cursor instead of the drawn crosshair.
pub(crate) system_cursor: Switch,
/// FONTALT at startup.
pub(crate) font_alt: Option<String>,
/// FONTFALLBACK at startup.
pub(crate) font_fallback: Option<String>,
}
impl Env {
pub(crate) fn read() -> Env {
Env::from_vars(|k| std::env::var_os(k))
}
/// [`Env::read`] with `var` looking a variable up.
pub(crate) fn from_vars(var: impl Fn(&str) -> Option<OsString>) -> Env {
let text = |k: &str| var(k).and_then(|v| v.into_string().ok()).filter(|v| !v.trim().is_empty());
let switch = |k: &str| Switch::parse(k, var(k).map(|v| v.to_string_lossy().into_owned()).as_deref());
Env {
config_dir: match var(CONFIG_DIR) {
Some(dir) if dir.to_str().is_some_and(|d| d.trim().is_empty()) => None,
Some(dir) => Some(PathBuf::from(dir)),
None => cadcraft_engine::settings_dir::platform(&var),
},
locale: text(LOCALE),
vsync: switch(VSYNC),
wayland: switch(WAYLAND),
no_native_menu: switch(NO_NATIVE_MENU),
system_cursor: switch(SYSTEM_CURSOR),
font_alt: text(FONTALT),
font_fallback: text(FONTFALLBACK),
}
}
}
/// The `--help` section that lists [`VARS`].
pub(crate) fn help() -> String {
let width = VARS.iter().map(|(name, _)| name.len()).max().unwrap_or(0) + 3;
VARS.iter().fold(String::from("Environment:"), |mut help, (name, line)| {
let _ = write!(help, "\n {name:<width$}{line}");
help
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::RefCell;
fn env<'a>(vars: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<OsString> + 'a {
move |k| vars.iter().find(|(n, _)| *n == k).map(|(_, v)| OsString::from(v))
}
#[test]
fn every_variable_read_is_in_the_help_and_every_listed_one_is_read() {
let asked = RefCell::new(Vec::new());
Env::from_vars(|k| {
asked.borrow_mut().push(k.to_owned());
None
});
let ours: Vec<String> = asked.into_inner().into_iter().filter(|k| k.starts_with("CADCRAFT_")).collect();
let help = help();
let unset = Env::from_vars(|_| None);
for k in &ours {
assert!(VARS.iter().any(|(name, _)| name == k), "{k} is read but not in VARS");
}
for (name, line) in VARS {
assert!(ours.iter().any(|k| k == name), "{name} is in VARS but not read");
assert_ne!(Env::from_vars(env(&[(name, "1")])), unset, "{name} sets no field");
assert!(help.lines().any(|l| l.trim_start().starts_with(name) && l.ends_with(line)), "{help}");
}
}
#[test]
fn values_are_read_and_blank_ones_are_unset() {
let vars = [
(CONFIG_DIR, "/private/cc"),
("HOME", "/home/u"),
("XDG_CONFIG_HOME", "/x"),
("APPDATA", "C:\\a"),
(LOCALE, "cs-CZ"),
(VSYNC, " On "),
(WAYLAND, "0"),
(NO_NATIVE_MENU, "yes"),
(SYSTEM_CURSOR, "maybe"),
(FONTALT, "Noto Sans"),
(FONTFALLBACK, "Vazirmatn, msjh.ttc"),
];
let read = Env::from_vars(env(&vars));
assert_eq!(
read,
Env {
config_dir: Some(PathBuf::from("/private/cc")),
locale: Some("cs-CZ".into()),
vsync: Switch::On,
wayland: Switch::Off,
no_native_menu: Switch::On,
system_cursor: Switch::Default,
font_alt: Some("Noto Sans".into()),
font_fallback: Some("Vazirmatn, msjh.ttc".into()),
}
);
let blank = vars.map(|(k, _)| (k, if k == FONTALT { " " } else { "" }));
let read = Env::from_vars(env(&blank));
assert_eq!(read.config_dir, None, "an empty variable means no directory, not the working directory");
assert_eq!((read.locale, read.font_alt, read.font_fallback), (None, None, None));
assert_eq!([read.vsync, read.wayland, read.no_native_menu, read.system_cursor], [Switch::Default; 4]);
}
#[test]
fn config_dir_unset_is_the_platform_folder_set_is_that_folder_blank_is_none() {
let platform = [("HOME", "/home/u"), ("XDG_CONFIG_HOME", "/x"), ("APPDATA", "C:\\a")];
let expected = cadcraft_engine::settings_dir::platform(env(&platform));
assert!(expected.is_some());
assert_eq!(Env::from_vars(env(&platform)).config_dir, expected);
let set = [platform.as_slice(), &[(CONFIG_DIR, "/private/cc")]].concat();
assert_eq!(Env::from_vars(env(&set)).config_dir, Some(PathBuf::from("/private/cc")));
for blank in ["", " "] {
let vars = [platform.as_slice(), &[(CONFIG_DIR, blank)]].concat();
assert_eq!(Env::from_vars(env(&vars)).config_dir, None, "{blank:?} means no settings folder, not the platform's");
}
}
}
+158
View File
@@ -0,0 +1,158 @@
//! The desktop app's command line, as clap types: `cadcraft --help` comes from these doc comments
//! and its Environment section from [`crate::env::VARS`], so the help and the parsers can't disagree. Anything they don't declare is a usage error naming
//! it (exit status 2), so a typo such as `--sampel` doesn't open the window and then fail to open
//! the flag as a file; so is a flag without its value, a repeated flag and a bad port, from the
//! flag or from `CADCRAFT_CONTROL_PORT`. A value never starts with `-`, and a file whose name
//! starts with `-` goes after `--`.
use std::ffi::OsString;
use std::path::PathBuf;
use cadcraft_mcp::args::{blank_env_is_unset, name_env_vars};
use clap::{Command, CommandFactory, FromArgMatches, Parser};
/// Opens CADCraft with the given files
#[derive(Parser)]
#[command(name = "cadcraft", version = cadcraft_engine::build_version(), after_help = crate::env::help())]
pub(crate) struct LaunchArgs {
/// Files to open. Put `--` before a name that starts with `-`
pub(crate) files: Vec<PathBuf>,
/// Start the localhost JSON-lines control server on this port
// allow_negative_numbers makes `--control -1` a bad value rather than a missing one.
#[arg(long, value_name = "PORT", env = "CADCRAFT_CONTROL_PORT", value_parser = control_port, allow_negative_numbers = true)]
pub(crate) control: Option<u16>,
/// Open with the sample drawing
#[arg(long)]
pub(crate) sample: bool,
}
/// The command line. An empty or blank variable counts as unset.
pub(crate) fn command() -> Command {
LaunchArgs::command().mut_args(|a| blank_env_is_unset(a, |k| std::env::var_os(k)))
}
/// Parse the launch arguments (without the binary name) with `cmd`, from [`command`]. Old macOS
/// passes a process serial number (`-psn_…`) when launched from Finder: it is dropped first, unless
/// it comes after `--`. A bad value from a variable is a usage error naming the variable.
pub(crate) fn parse(mut cmd: Command, args: impl IntoIterator<Item = OsString>) -> Result<LaunchArgs, clap::Error> {
let mut options = true;
let args: Vec<OsString> = args
.into_iter()
.filter(|a| {
options &= a != "--";
!(options && a.to_str().is_some_and(|s| s.starts_with("-psn_")))
})
.collect();
let argv = std::iter::once(OsString::from("cadcraft")).chain(args.iter().cloned());
let matches = cmd.try_get_matches_from_mut(argv).map_err(|e| name_env_vars(e, &cmd, &args))?;
LaunchArgs::from_arg_matches(&matches)
}
/// A control port, for clap's `value_parser`: `--control` and `CADCRAFT_CONTROL_PORT`, which may
/// carry spaces.
fn control_port(s: &str) -> Result<u16, NotAPort> {
s.trim().parse().map_err(|_| NotAPort)
}
/// A [`control_port`] value that isn't a port.
#[derive(Debug, PartialEq, Eq, thiserror::Error)]
#[error("expected a port, 0-65535")]
struct NotAPort;
#[cfg(test)]
mod tests {
use super::*;
use clap::error::ErrorKind;
/// Parse without the variables, so one exported where the tests run can't change them.
fn parse_args(args: &[&str]) -> Result<LaunchArgs, clap::Error> {
parse(command().mut_args(|a| a.env(None::<&str>)), args.iter().map(OsString::from))
}
fn usage_error(args: &[&str]) -> String {
let Err(e) = parse_args(args) else { panic!("{args:?} parsed") };
assert_eq!(e.exit_code(), 2, "{args:?}: {e}");
e.to_string()
}
#[test]
fn values_are_parsed() {
let parsed = parse_args(&["--control", " 50494 ", "--sample", "a.dxf", "b.dwg"]).unwrap();
assert_eq!((parsed.control, parsed.sample), (Some(50494), true));
assert_eq!(parsed.files, [PathBuf::from("a.dxf"), PathBuf::from("b.dwg")]);
let parsed = parse_args(&[]).unwrap();
assert_eq!((parsed.control, parsed.sample, parsed.files.len()), (None, false, 0));
}
#[test]
fn control_port_rejects_unparseable_values_naming_them() {
for bad in ["78787", "-1", "abc", "99999999999999999999"] {
assert!(usage_error(&["--control", bad]).contains(&format!("invalid value '{bad}' for '--control <PORT>'")), "{bad}");
}
assert!(usage_error(&["--control="]).contains("invalid value '' for '--control <PORT>'"));
}
#[test]
fn usage_errors_exit_2() {
assert!(usage_error(&["--sampel"]).contains("unexpected argument '--sampel'"));
assert!(usage_error(&["--control"]).contains("a value is required for '--control <PORT>'"));
// A following flag is never taken as the value.
assert!(usage_error(&["--control", "--sample"]).contains("a value is required for '--control <PORT>'"));
assert!(usage_error(&["--sample", "--sample"]).contains("cannot be used multiple times"));
assert!(usage_error(&["--control", "1", "--control", "2"]).contains("cannot be used multiple times"));
}
#[test]
fn version_stops_parsing() {
let e = parse_args(&["-V", "--control"]).err().unwrap();
assert_eq!((e.kind(), e.exit_code()), (ErrorKind::DisplayVersion, 0));
assert_eq!(e.to_string(), format!("{}\n", crate::version_string()));
let e = parse_args(&["--version", "--bogus"]).err().unwrap();
assert_eq!(e.kind(), ErrorKind::DisplayVersion);
}
#[test]
fn help_stops_parsing_and_double_dash_ends_options() {
let e = parse_args(&["-h", "--sampel", "--control"]).err().unwrap();
assert_eq!((e.kind(), e.exit_code()), (ErrorKind::DisplayHelp, 0));
let help = parse_args(&["--help"]).err().unwrap().to_string();
assert!(help.contains("--control <PORT>") && help.contains("CADCRAFT_CONFIG_DIR"), "{help}");
assert!(usage_error(&["--sampel", "--", "--help"]).contains("unexpected argument '--sampel'"));
let parsed = parse_args(&["a.dxf", "--", "--help", "-x"]).unwrap();
assert_eq!(parsed.files, ["a.dxf", "--help", "-x"].map(PathBuf::from));
}
#[test]
fn a_finder_process_serial_number_is_dropped() {
let parsed = parse_args(&["-psn_0_12345", "a.dxf"]).unwrap();
assert_eq!(parsed.files, [PathBuf::from("a.dxf")]);
let parsed = parse_args(&["--", "-psn_x.dxf"]).unwrap();
assert_eq!(parsed.files, [PathBuf::from("-psn_x.dxf")]);
}
/// A name that isn't valid Unicode is kept as given.
#[cfg(unix)]
#[test]
fn non_unicode_file_names_are_kept() {
use std::os::unix::ffi::OsStringExt as _;
let latin1 = OsString::from_vec(b"caf\xe9.dxf".to_vec());
let parsed = parse(command().mut_args(|a| a.env(None::<&str>)), [latin1.clone(), OsString::from("b.dxf")]).unwrap();
assert_eq!(parsed.files, [PathBuf::from(&latin1), PathBuf::from("b.dxf")]);
}
/// `--control` falls back to `PATH` here instead of `CADCRAFT_CONTROL_PORT`: `PATH` is set
/// wherever the tests run and is never a port, and tests can't set a variable (`set_var` is
/// unsafe).
#[test]
fn the_flag_wins_over_its_variable_and_a_bad_value_names_the_variable() {
let Some(path) = std::env::var_os("PATH").filter(|p| !p.is_empty()) else { return };
let path_fallback = || command().mut_args(|a| a.env(None::<&str>)).mut_arg("control", |a| a.env("PATH"));
let e = parse(path_fallback(), []).err().unwrap();
assert_eq!(e.exit_code(), 2);
let text = e.to_string();
assert!(text.contains(&format!("invalid value '{}' for 'PATH'", path.to_string_lossy())), "{text}");
assert!(!text.contains("--control <PORT>'"), "{text}");
assert_eq!(parse(path_fallback(), ["--control", "7"].map(OsString::from)).unwrap().control, Some(7));
}
}
+37 -17
View File
@@ -3,7 +3,7 @@
//! Without a logger every `log::warn!`/`log::error!`/`log::info!` in the workspace (a broken app //! Without a logger every `log::warn!`/`log::error!`/`log::info!` in the workspace (a broken app
//! icon, the GPU canvas set-up, the control server) vanished. A launch from a desktop menu has no //! icon, the GPU canvas set-up, the control server) vanished. A launch from a desktop menu has no
//! terminal, so the file is what a bug report can attach: `<settings dir>/logs/cadcraft.log` //! terminal, so the file is what a bug report can attach: `<settings dir>/logs/cadcraft.log`
//! (`CADCRAFT_CONFIG_DIR`, or the platform settings directory; see `config_dir` in `main.rs`). //! (`CADCRAFT_CONFIG_DIR`, or the platform settings directory; empty means none; see `env.rs`).
//! Each start moves the previous log to `cadcraft.1.log` (and that one to `.2`), so the log of a //! Each start moves the previous log to `cadcraft.1.log` (and that one to `.2`), so the log of a
//! run that crashed survives the next launch. //! run that crashed survives the next launch.
//! //!
@@ -93,7 +93,10 @@ fn matches(name: &str, target: &str) -> bool {
} }
} }
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch. /// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch (the
/// `duration_since` clamp); past the last date chrono can represent (year 262143) the proleptic
/// Gregorian date is kept via the 400-year cycle rather than falling back to the epoch, so the
/// output never changes for a value the old formatter accepted and logging never panics.
pub fn timestamp(t: SystemTime) -> String { pub fn timestamp(t: SystemTime) -> String {
let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default(); let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
let secs = since.as_secs(); let secs = since.as_secs();
@@ -102,20 +105,25 @@ pub fn timestamp(t: SystemTime) -> String {
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis()) format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis())
} }
/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's /// Days since 1970-01-01 → proleptic Gregorian (year, month, day).
/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch; saturating so an absurd ///
/// clock can't overflow). /// `timestamp` clamps a pre-1970 clock to the epoch, so `days` is an unsigned count that can run
fn civil_from_days(days: u64) -> (u64, u64, u64) { /// past `NaiveDate`'s ±262143-year range. The Gregorian calendar repeats every 400 years
let z = days.saturating_add(719_468); /// (146_097 days), so chrono converts the day within one cycle and whole cycles go back into the
let era = z / 146_097; /// year (an `i128`, then saturated into `i64`), keeping chrono as the only calendar logic and
let doe = z % 146_097; /// reproducing the old saturating `civil_from_days` result exactly without hand-rolled day math.
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; /// 1970-01-01 is `num_days_from_ce` 719_163.
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); fn civil_from_days(days: u64) -> (i64, u32, u32) {
let mp = (5 * doy + 2) / 153; use chrono::Datelike;
let d = doy - (153 * mp + 2) / 5 + 1; let (cycles, rest) = (days / 146_097, days % 146_097);
let m = if mp < 10 { mp + 3 } else { mp - 9 }; // `rest + 719_163` is 719_163..865_259, always a valid CE day.
let y = era.saturating_mul(400).saturating_add(yoe).saturating_add(u64::from(m <= 2)); match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) {
(y, m, d) Some(d) => {
let y = i128::from(d.year()) + i128::from(cycles) * 400;
(i64::try_from(y).unwrap_or(i64::MAX), d.month(), d.day())
}
None => (1970, 1, 1), // unreachable
}
} }
fn numbered(dir: &Path, n: usize) -> PathBuf { fn numbered(dir: &Path, n: usize) -> PathBuf {
@@ -385,8 +393,20 @@ mod tests {
// 2000 is a leap year (divisible by 400), 2100 is not. // 2000 is a leap year (divisible by 400), 2100 is not.
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(951_782_400)), "2000-02-29T00:00:00.000Z"); assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(951_782_400)), "2000-02-29T00:00:00.000Z");
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z"); assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z");
// Before the epoch (a clock set wrong) clamps instead of panicking. // Before the epoch (a clock set wrong) clamps to the epoch instead of panicking.
assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z"); assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z");
// Past chrono's range (a clock set absurdly far ahead) keeps the proleptic Gregorian date
// the old formatter gave, rather than falling back to the epoch.
// Windows' SystemTime holds only up to about year 30828, so this is checked where it fits.
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) {
assert_eq!(timestamp(far), "318857-05-20T17:46:40.000Z");
}
// Windows' SystemTime can't hold u64::MAX / 2 seconds, so it is only checked where it exists.
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) {
assert_eq!(timestamp(far), "292277026596-12-04T15:30:07.000Z");
}
// Years past 9999 keep the plain zero-padded year (no `+`).
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(253_402_300_800)), "10000-01-01T00:00:00.000Z");
} }
#[test] #[test]
+96 -151
View File
@@ -1,6 +1,6 @@
//! CADCraft desktop app. //! CADCraft desktop app.
//! //!
//! Usage: `cadcraft [--control <port>] [--sample] [files…]` //! Usage: `cadcraft [--control <port>] [--sample] [files…]` (`cadcraft --help` lists the rest; see [`launch_args`])
//! //!
//! `--control <port>` (or `CADCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server: //! `--control <port>` (or `CADCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server:
//! `{"id":1,"method":"cmdline.input","params":{"text":"circle 0,0 5"}}` → `{"id":1,"ok":true,…}`. //! `{"id":1,"method":"cmdline.input","params":{"text":"circle 0,0 5"}}` → `{"id":1,"ok":true,…}`.
@@ -10,14 +10,16 @@
//! present mode (`CADCRAFT_VSYNC=0` selects that elsewhere); see `cadcraft_ui_egui::gpu::surface_config`. //! present mode (`CADCRAFT_VSYNC=0` selects that elsewhere); see `cadcraft_ui_egui::gpu::surface_config`.
//! //!
//! `log` records go to standard error and `<settings dir>/logs/cadcraft.log` (`RUST_LOG` sets the //! `log` records go to standard error and `<settings dir>/logs/cadcraft.log` (`RUST_LOG` sets the
//! levels); see [`logging`] and [`config_dir`]. //! levels); see [`logging`]. The `CADCRAFT_*` variables are read once, at startup; see [`env`].
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")] #![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod control_server; mod control_server;
mod desktop_theme; mod desktop_theme;
mod env;
#[cfg(any(target_os = "windows", test))] #[cfg(any(target_os = "windows", test))]
mod graphics; mod graphics;
mod launch_args;
mod logging; mod logging;
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
mod native_menu; mod native_menu;
@@ -26,6 +28,7 @@ mod system_animations;
mod workspace; mod workspace;
use cadcraft_engine::Session; use cadcraft_engine::Session;
use cadcraft_ui_egui::switch::Switch;
use cadcraft_ui_egui::{CadApp, Services, i18n}; use cadcraft_ui_egui::{CadApp, Services, i18n};
struct App { struct App {
@@ -39,11 +42,25 @@ struct App {
workspace_error: Option<String>, workspace_error: Option<String>,
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
native_menu: Option<native_menu::NativeMenu>, native_menu: Option<native_menu::NativeMenu>,
/// `CADCRAFT_NO_NATIVE_MENU`: the menus stay inside the window.
#[cfg(target_os = "macos")]
menus_in_window: bool,
/// The `movable` flag last set on the main window.
#[cfg(target_os = "macos")]
movable: Option<bool>,
} }
impl App { impl App {
fn persist_workspace(&mut self, final_attempt: bool) { /// `ctx` is `None` only for the final attempt at exit.
let result = if final_attempt { self.workspace.save_on_exit(&self.cad.ui) } else { self.workspace.save_if_changed(&self.cad.ui) }; fn persist_workspace(&mut self, ctx: Option<&egui::Context>) {
let result = match ctx {
None => self.workspace.save_on_exit(&self.cad.ui),
Some(ctx) => self.workspace.save_if_changed(&self.cad.ui, std::time::Instant::now()).map(|wait| {
if let Some(wait) = wait {
ctx.request_repaint_after(wait);
}
}),
};
if let Err(error) = result { if let Err(error) = result {
if self.workspace_error.as_ref() != Some(&error) { if self.workspace_error.as_ref() != Some(&error) {
eprintln!("CADCraft workspace: {error}"); eprintln!("CADCraft workspace: {error}");
@@ -65,37 +82,49 @@ fn follow_desktop(raw: &mut egui::RawInput, desktop: Option<egui::Theme>) {
} }
impl eframe::App for App { impl eframe::App for App {
/// eframe logic callback: syncs the OS animation setting and runs `CadApp::logic`; when the pass index is 0 (the first pass or a hidden tick) it also installs/polls the macOS native menu, polls the spacemouse and persists the workspace; a quit request closes the viewport.
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
#[cfg(target_os = "macos")]
{
if self.native_menu.is_none() && std::env::var_os("CADCRAFT_NO_NATIVE_MENU").is_none() {
self.native_menu = Some(native_menu::NativeMenu::install(&mut self.cad));
}
if let Some(m) = &mut self.native_menu {
m.poll(&mut self.cad, ctx);
}
}
self.cad.system_animations = self.system_animations.current(); self.cad.system_animations = self.system_animations.current();
// Consume control/open/screenshot queues and time-based autosave, including hidden ticks.
self.cad.logic(ctx); self.cad.logic(ctx);
self.spacemouse.poll(&mut self.cad, ctx); // Host frame work runs here, once per frame, and so also on a hidden logic-only tick (which
self.persist_workspace(false); // never reaches `App::ui`): native menus stay current, the spacemouse still navigates and a
// workspace changed while hidden is persisted. `current_pass_index()` is zero on the first
// pass and on every hidden tick, and non-zero on later passes of a multi-pass frame.
if ctx.current_pass_index() == 0 {
#[cfg(target_os = "macos")]
{
if self.native_menu.is_none() && !self.menus_in_window {
self.native_menu = Some(native_menu::NativeMenu::install(&mut self.cad, ctx));
}
if let Some(m) = &mut self.native_menu {
m.poll(&mut self.cad, ctx);
}
}
self.spacemouse.poll(&mut self.cad, ctx);
// After `cad.logic`, so a control command's workspace change is saved in the same frame.
self.persist_workspace(Some(ctx));
}
if std::mem::take(&mut self.cad.quit_requested) { if std::mem::take(&mut self.cad.quit_requested) {
ctx.send_viewport_cmd(egui::ViewportCommand::Close); ctx.send_viewport_cmd(egui::ViewportCommand::Close);
} }
} }
/// Apply the desktop-theme fallback, forward raw input to `CadApp`, and capture Tab at the command line.
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
follow_desktop(raw, self.desktop_theme.current()); follow_desktop(raw, self.desktop_theme.current());
self.cad.raw_input_hook(raw); self.cad.raw_input_hook(ctx, raw);
cadcraft_ui_egui::cmdline::capture_tab(ctx, raw); cadcraft_ui_egui::cmdline::capture_tab(ctx, raw);
} }
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
self.cad.ui(ui); self.cad.ui(ui);
self.persist_workspace(false); #[cfg(target_os = "macos")]
craft_window::title_bar::sync_movable(ui.ctx(), &mut self.movable);
} }
/// A normal exit: the workspace is saved, and this run's autosave files are not needed (a /// A normal exit: the workspace is saved, and this run's autosave files are not needed (a
/// crash leaves them for recovery). /// crash leaves them for recovery).
fn on_exit(&mut self) { fn on_exit(&mut self) {
self.persist_workspace(true); self.persist_workspace(None);
self.cad.cancel_screenshots();
self.cad.shutdown_autosave(); self.cad.shutdown_autosave();
} }
/// Preferences (the interface theme) survive restarts; window and egui state are not kept. /// Preferences (the interface theme) survive restarts; window and egui state are not kept.
@@ -143,6 +172,13 @@ fn services() -> Services {
.save_file() .save_file()
.map(|p| p.to_string_lossy().to_string()) .map(|p| p.to_string_lossy().to_string())
})), })),
// APPLOAD: AutoLISP routines only, starting where the last one was loaded from.
pick_lisp: Some(Box::new(|dir: &str| {
let dialog = rfd::FileDialog::new().add_filter(i18n::t("AutoLISP (LSP)"), &["lsp"]).add_filter(i18n::t("All files"), &["*"]);
let dialog = if dir.is_empty() { dialog } else { dialog.set_directory(dir) };
dialog.pick_file().map(|p| p.to_string_lossy().to_string())
})),
no_file_system: false,
} }
} }
@@ -158,30 +194,6 @@ fn filters_for_save(name: &str) -> [(&'static str, &'static str); 6] {
filters filters
} }
/// The per-user settings directory (it holds `logs/` and `autosave/`): `CADCRAFT_CONFIG_DIR` if set, else
/// `$XDG_CONFIG_HOME/cadcraft` or `~/.config/cadcraft` on Linux and the BSDs,
/// `~/Library/Application Support/CADCraft` on macOS, `%APPDATA%\CADCraft` on Windows.
fn config_dir() -> Option<std::path::PathBuf> {
config_dir_from(|k| std::env::var_os(k))
}
/// [`config_dir`] with the environment passed in (`var` looks a variable up). An empty variable
/// counts as unset, so a blank value never means the working directory.
fn config_dir_from(var: impl Fn(&str) -> Option<std::ffi::OsString>) -> Option<std::path::PathBuf> {
let set = |k: &str| var(k).filter(|v| !v.is_empty()).map(std::path::PathBuf::from);
if let Some(dir) = set("CADCRAFT_CONFIG_DIR") {
return Some(dir);
}
if cfg!(target_os = "macos") {
set("HOME").map(|h| h.join("Library/Application Support/CADCraft"))
} else if cfg!(windows) {
set("APPDATA").map(|a| a.join("CADCraft"))
} else {
// The XDG spec ignores a relative XDG_CONFIG_HOME.
set("XDG_CONFIG_HOME").filter(|p| p.is_absolute()).or_else(|| set("HOME").map(|h| h.join(".config"))).map(|c| c.join("cadcraft"))
}
}
const APP_ID: &str = "ai.storyteller.cadcraft"; const APP_ID: &str = "ai.storyteller.cadcraft";
/// Stencil bits of the window's render pass (see `NativeOptions::stencil_buffer`). /// Stencil bits of the window's render pass (see `NativeOptions::stencil_buffer`).
@@ -197,68 +209,49 @@ fn app_icon() -> Option<egui::IconData> {
} }
fn version_string() -> String { fn version_string() -> String {
let sha = option_env!("CADCRAFT_BUILD_SHA").unwrap_or("dev"); format!("cadcraft {}", cadcraft_engine::build_version())
format!("cadcraft {} ({sha})", env!("CARGO_PKG_VERSION"))
}
/// The control port from `--control <port>` or `CADCRAFT_CONTROL_PORT` (`source`); a value that is
/// not a port is logged and ignored instead of silently starting no control channel.
fn control_port_from(source: &str, value: Option<String>) -> Option<u16> {
let value = value?;
let port = value.trim().parse().ok();
if port.is_none() {
log::warn!("{source}: {value:?} is not a port number; the control channel is off");
}
port
}
/// `--control` followed by `value`: a bare `--control` (last argument) keeps the port already
/// read from `CADCRAFT_CONTROL_PORT` (`current`) instead of turning the control channel off.
fn control_flag(current: Option<u16>, value: Option<String>) -> Option<u16> {
if value.is_some() {
return control_port_from("--control", value);
}
match current {
Some(port) => log::warn!("--control: no port given; using CADCRAFT_CONTROL_PORT ({port})"),
None => log::warn!("--control: no port given; the control channel is off"),
}
current
} }
/// Parse CLI options (`--control`, `--sample`, files, `--version`), install logging and I/O hooks, build the services and app, and run eframe.
fn main() -> eframe::Result { fn main() -> eframe::Result {
// First, so every start-up record is captured; see `logging`. // First, so every start-up record is captured; see `logging`.
let logger = logging::install(); let logger = logging::install();
// lossy, so a value that is not Unicode is reported like any other bad port instead of ignored // The update helper relaunches this binary after a self-update; it runs and exits here.
let mut control_port = #[cfg(not(target_arch = "wasm32"))]
control_port_from("CADCRAFT_CONTROL_PORT", std::env::var_os("CADCRAFT_CONTROL_PORT").map(|p| p.to_string_lossy().into_owned())); if let Some(done) = craft_update::self_update::finish(&craft_update::suite::CADCRAFT, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
let mut files = Vec::new(); if let Err(e) = done {
let mut sample = false; eprintln!("CADCraft update helper failed: {e}");
let mut args = std::env::args().skip(1); std::process::exit(1);
while let Some(a) = args.next() {
match a.as_str() {
"--control" => control_port = control_flag(control_port, args.next()),
"--sample" => sample = true,
"--version" | "-V" => {
println!("{}", version_string());
return Ok(());
}
_ => files.push(a),
} }
return Ok(());
} }
// `--help` and `--version` print to standard output and exit 0. A usage error (an unknown or
// repeated flag, a missing or bad value, a bad CADCRAFT_CONTROL_PORT) names the argument on
// standard error and exits 2 before the window opens.
let launch = launch_args::parse(launch_args::command(), std::env::args_os().skip(1)).unwrap_or_else(|e| e.exit());
let vars = env::Env::read();
let (control_port, sample, files) = (launch.control, launch.sample, launch.files);
// The log file lives under the settings directory; opened after the arguments, so `--version` // The log file lives under the settings directory; opened after the arguments, so `--version`
// leaves no file behind. Records logged until now are written to it first. // leaves no file behind. Records logged until now are written to it first.
if let Some(logger) = logger { if let Some(logger) = logger {
match config_dir().map(|d| logger.attach_dir(&d.join(logging::LOG_DIR))) { match vars.config_dir.as_ref().map(|d| logger.attach_dir(&d.join(logging::LOG_DIR))) {
Some(Ok(path)) => log::info!("{} ({} {}), log file {}", version_string(), std::env::consts::OS, std::env::consts::ARCH, path.display()), Some(Ok(path)) => log::info!("{} ({} {}), log file {}", version_string(), std::env::consts::OS, std::env::consts::ARCH, path.display()),
Some(Err(e)) => log::warn!("no log file: {e}"), Some(Err(e)) => log::warn!("no log file: {e}"),
None => { None => {
logger.stderr_only(); logger.stderr_only();
log::warn!("no log file: no settings directory (set CADCRAFT_CONFIG_DIR)"); log::warn!("no log file: no settings directory (an empty CADCRAFT_CONFIG_DIR turns it off)");
} }
} }
} }
install_io(); install_io();
let surface = cadcraft_ui_egui::gpu::surface_config(std::env::var("CADCRAFT_VSYNC").ok().as_deref(), cfg!(all(unix, not(target_os = "macos")))); cadcraft_engine::settings_dir::set(vars.config_dir.clone());
if let Some(name) = &vars.font_alt {
cadcraft_engine::fonts::ttf::set_font_alt(name);
}
if let Some(list) = &vars.font_fallback {
cadcraft_engine::fonts::ttf::set_fallback_fonts(list);
}
let surface = cadcraft_ui_egui::gpu::surface_config(vars.vsync, cfg!(all(unix, not(target_os = "macos"))));
let mut options = eframe::NativeOptions { let mut options = eframe::NativeOptions {
// The crosshair is drawn by the app: present without a queue on Linux/BSD, where X11 // The crosshair is drawn by the app: present without a queue on Linux/BSD, where X11
// swapchains hold two frames back under vsync (`CADCRAFT_VSYNC=1` restores vsync). // swapchains hold two frames back under vsync (`CADCRAFT_VSYNC=1` restores vsync).
@@ -273,6 +266,9 @@ fn main() -> eframe::Result {
.with_title_shown(false) .with_title_shown(false)
.with_app_id(APP_ID), .with_app_id(APP_ID),
persist_window: false, persist_window: false,
// CADCraft installs its own capped font chain (`theme::install_fonts`); never run
// system-font discovery as a hidden fallback.
system_font_fallback: false,
// A stencil buffer clips the contents of clipped layout viewports on the GPU. // A stencil buffer clips the contents of clipped layout viewports on the GPU.
stencil_buffer: STENCIL_BITS, stencil_buffer: STENCIL_BITS,
..Default::default() ..Default::default()
@@ -298,7 +294,7 @@ fn main() -> eframe::Result {
// manager showed a "no" cursor. Run through XWayland when it's there; CADCRAFT_WAYLAND=1 // manager showed a "no" cursor. Run through XWayland when it's there; CADCRAFT_WAYLAND=1
// keeps the native Wayland backend. // keeps the native Wayland backend.
#[cfg(all(unix, not(target_os = "macos")))] #[cfg(all(unix, not(target_os = "macos")))]
if std::env::var_os("DISPLAY").is_some() && std::env::var_os("CADCRAFT_WAYLAND").is_none() { if std::env::var_os("DISPLAY").is_some() && vars.wayland != Switch::On {
options.event_loop_builder = Some(Box::new(|b| { options.event_loop_builder = Some(Box::new(|b| {
use winit::platform::x11::EventLoopBuilderExtX11; use winit::platform::x11::EventLoopBuilderExtX11;
b.with_x11(); b.with_x11();
@@ -309,15 +305,16 @@ fn main() -> eframe::Result {
options, options,
Box::new(move |cc| { Box::new(move |cc| {
let mut app = CadApp::new(Session::empty(), services()); let mut app = CadApp::new(Session::empty(), services());
let (workspace, restored, workspace_error) = workspace::Workspace::load(); let (workspace, restored, workspace_error) = workspace::Workspace::load(vars.config_dir.as_deref());
if let Some(ui) = restored { if let Some(ui) = restored {
app.ui = ui; app.ui = ui;
} }
app.set_system_cursor_default(vars.system_cursor == Switch::On);
if let Some(error) = &workspace_error { if let Some(error) = &workspace_error {
eprintln!("CADCraft workspace: {error}"); eprintln!("CADCraft workspace: {error}");
app.set_status(error); app.set_status(error);
} }
app.system_languages = std::env::var("CADCRAFT_LOCALE").map(|s| vec![s]).unwrap_or_else(|_| sys_locale::get_locales().collect()); app.system_languages = vars.locale.map(|s| vec![s]).unwrap_or_else(|| sys_locale::get_locales().collect());
// The user profile (Options): drafting settings, selection sizes, drawing-area colours. // The user profile (Options): drafting settings, selection sizes, drawing-area colours.
if let Some(profile) = cc.storage.and_then(|s| s.get_string(cadcraft_ui_egui::options::PROFILE_KEY)) { if let Some(profile) = cc.storage.and_then(|s| s.get_string(cadcraft_ui_egui::options::PROFILE_KEY)) {
app.load_profile(&profile); app.load_profile(&profile);
@@ -329,8 +326,9 @@ fn main() -> eframe::Result {
cadcraft_ui_egui::autosave::load_prefs(&prefs); cadcraft_ui_egui::autosave::load_prefs(&prefs);
} }
// Autosave files (SAVEFILEPATH) default to `<settings dir>/autosave`. // Autosave files (SAVEFILEPATH) default to `<settings dir>/autosave`.
if let Some(dir) = config_dir() { if let Some(dir) = &vars.config_dir {
app.enable_autosave(&dir.join("autosave")); app.enable_autosave(&dir.join("autosave"));
app.enable_update_check();
// Startup LISP (acad.lsp, acaddoc.lsp) is read from `<settings>/support`; the // Startup LISP (acad.lsp, acaddoc.lsp) is read from `<settings>/support`; the
// folder is made so it is there to put them in. // folder is made so it is there to put them in.
let support = dir.join("support"); let support = dir.join("support");
@@ -357,7 +355,9 @@ fn main() -> eframe::Result {
app.open_path(f); app.open_path(f);
} }
if app.session.docs.is_empty() { if app.session.docs.is_empty() {
app.session.new_drawing(false); // In the units the profile asks for (MEASUREINIT, restored above).
let metric = app.session.settings.measureinit;
app.session.new_drawing(metric);
} }
let ctx = cc.egui_ctx.clone(); let ctx = cc.egui_ctx.clone();
let desktop_theme = desktop_theme::DesktopTheme::start(move || ctx.request_repaint()); let desktop_theme = desktop_theme::DesktopTheme::start(move || ctx.request_repaint());
@@ -372,6 +372,10 @@ fn main() -> eframe::Result {
workspace_error, workspace_error,
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
native_menu: None, native_menu: None,
#[cfg(target_os = "macos")]
menus_in_window: vars.no_native_menu == Switch::On,
#[cfg(target_os = "macos")]
movable: None,
})) }))
}), }),
) )
@@ -379,13 +383,7 @@ fn main() -> eframe::Result {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{config_dir_from, control_flag, control_port_from, filters_for_save, follow_desktop}; use super::{filters_for_save, follow_desktop};
use std::ffi::OsString;
use std::path::PathBuf;
fn env<'a>(vars: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<OsString> + 'a {
move |k| vars.iter().find(|(n, _)| *n == k).map(|(_, v)| OsString::from(v))
}
#[test] #[test]
fn save_dialog_offers_dwg_with_the_drawing_format_first() { fn save_dialog_offers_dwg_with_the_drawing_format_first() {
@@ -403,59 +401,6 @@ mod tests {
} }
} }
#[test]
fn control_port_values_that_are_not_ports_are_ignored() {
assert_eq!(control_port_from("--control", Some("7979".into())), Some(7979));
assert_eq!(control_port_from("--control", Some(" 7979 ".into())), Some(7979));
assert_eq!(control_port_from("--control", None), None);
for bad in ["", "abc", "-1", "65536", "99999999999999999999", "\u{0}"] {
assert_eq!(control_port_from("--control", Some(bad.into())), None, "{bad:?}");
}
}
#[test]
fn control_without_a_port_keeps_the_environment_port() {
// `CADCRAFT_CONTROL_PORT=47321 cadcraft --control` (#844)
assert_eq!(control_flag(Some(47321), None), Some(47321));
assert_eq!(control_flag(None, None), None);
// A port given on the command line still wins over the variable.
assert_eq!(control_flag(Some(47321), Some("7979".into())), Some(7979));
assert_eq!(control_flag(Some(47321), Some("abc".into())), None);
}
#[test]
fn the_override_variable_wins_and_empty_values_are_unset() {
let vars = [("CADCRAFT_CONFIG_DIR", "/tmp/cc"), ("HOME", "/home/u"), ("XDG_CONFIG_HOME", "/x"), ("APPDATA", "C:\\a")];
assert_eq!(config_dir_from(env(&vars)), Some(PathBuf::from("/tmp/cc")));
let empty = [("CADCRAFT_CONFIG_DIR", ""), ("HOME", ""), ("XDG_CONFIG_HOME", ""), ("APPDATA", "")];
assert_eq!(config_dir_from(env(&empty)), None, "no usable variable means no directory, not the working directory");
assert_eq!(config_dir_from(env(&[])), None);
}
#[cfg(all(unix, not(target_os = "macos")))]
#[test]
fn linux_and_bsd_follow_xdg_config_home() {
assert_eq!(config_dir_from(env(&[("XDG_CONFIG_HOME", "/x"), ("HOME", "/home/u")])), Some(PathBuf::from("/x/cadcraft")));
assert_eq!(config_dir_from(env(&[("HOME", "/home/u")])), Some(PathBuf::from("/home/u/.config/cadcraft")));
// The XDG spec says a relative XDG_CONFIG_HOME is ignored.
assert_eq!(config_dir_from(env(&[("XDG_CONFIG_HOME", "rel"), ("HOME", "/home/u")])), Some(PathBuf::from("/home/u/.config/cadcraft")));
}
#[cfg(target_os = "macos")]
#[test]
fn macos_uses_application_support() {
assert_eq!(config_dir_from(env(&[("HOME", "/Users/u")])), Some(PathBuf::from("/Users/u/Library/Application Support/CADCraft")));
}
#[cfg(windows)]
#[test]
fn windows_uses_appdata() {
assert_eq!(
config_dir_from(env(&[("APPDATA", "C:\\Users\\u\\AppData\\Roaming")])),
Some(PathBuf::from("C:\\Users\\u\\AppData\\Roaming").join("CADCraft"))
);
}
/// View ▸ Theme ▸ System on Linux: winit reports no theme, so the desktop's answer stands in; /// View ▸ Theme ▸ System on Linux: winit reports no theme, so the desktop's answer stands in;
/// without one (or where winit answers and the desktop doesn't) the input is left alone. /// without one (or where winit answers and the desktop doesn't) the input is left alone.
#[test] #[test]
+53 -6
View File
@@ -3,6 +3,7 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::str::FromStr; use std::str::FromStr;
use std::sync::mpsc;
use cadcraft_ui_egui::menus::{self, Entry}; use cadcraft_ui_egui::menus::{self, Entry};
use cadcraft_ui_egui::{CadApp, i18n}; use cadcraft_ui_egui::{CadApp, i18n};
@@ -15,10 +16,14 @@ pub struct NativeMenu {
submenus: Vec<(String, Submenu)>, submenus: Vec<(String, Submenu)>,
predefined: Vec<(&'static str, PredefinedMenuItem)>, predefined: Vec<(&'static str, PredefinedMenuItem)>,
language: &'static str, language: &'static str,
/// Monotonic time of the last enablement refresh. `NEG_INFINITY` makes the first poll refresh
/// immediately: `now_ms` starts near zero, so the old `0.0` sentinel suppressed the first
/// ~300 ms of menu enablement.
last_refresh: f64, last_refresh: f64,
/// The recent files File ▸ Open Recent lists now (it is rebuilt when they change). /// The recent files File ▸ Open Recent lists now (it is rebuilt when they change).
recent: Vec<String>, recent: Vec<String>,
counter: usize, counter: usize,
events: mpsc::Receiver<MenuEvent>,
} }
/// Accelerator for "Cmd+Shift+S" (modifier-less shortcuts stay in the app so typing works). /// Accelerator for "Cmd+Shift+S" (modifier-less shortcuts stay in the app so typing works).
@@ -30,7 +35,16 @@ fn accel(sc: &str) -> Option<Accelerator> {
} }
impl NativeMenu { impl NativeMenu {
pub fn install(app: &mut CadApp) -> Self { /// Build and register the macOS menu (app menu, Settings and the translated command tree); returns the state holding item ids and submenus.
pub fn install(app: &mut CadApp, ctx: &egui::Context) -> Self {
// A menu choice (or its shortcut, such as Cmd+W) is no window event, so winit would not
// wake the app for it: the handler does, and the choice runs in the next frame.
let (sender, events) = mpsc::channel();
let wake = ctx.clone();
MenuEvent::set_event_handler(Some(move |event| {
let _ = sender.send(event);
wake.request_repaint();
}));
let menu = Menu::new(); let menu = Menu::new();
let mut items = HashMap::new(); let mut items = HashMap::new();
let language = app.language(); let language = app.language();
@@ -66,7 +80,17 @@ impl NativeMenu {
submenus.push((title, sub)); submenus.push((title, sub));
} }
menu.init_for_nsapp(); menu.init_for_nsapp();
Self { _menu: menu, items, submenus, predefined, language, last_refresh: 0.0, recent: app.ui.recent_files.clone(), counter } Self {
_menu: menu,
items,
submenus,
predefined,
language,
last_refresh: f64::NEG_INFINITY,
recent: app.ui.recent_files.clone(),
counter,
events,
}
} }
/// Rebuild File ▸ Open Recent after a drawing was opened or the list was cleared: the native /// Rebuild File ▸ Open Recent after a drawing was opened or the list was cleared: the native
@@ -87,13 +111,14 @@ impl NativeMenu {
build(&sub, &menus::recent_entries(app), &mut self.items, &mut self.submenus, &mut self.counter, self.language); build(&sub, &menus::recent_entries(app), &mut self.items, &mut self.submenus, &mut self.counter, self.language);
} }
/// Drain queued menu events into commands (Discord opens externally), rebuild Open Recent, retranslate on a language change and refresh enablement when 300 ms have elapsed.
pub fn poll(&mut self, app: &mut CadApp, ctx: &egui::Context) { pub fn poll(&mut self, app: &mut CadApp, ctx: &egui::Context) {
while let Ok(ev) = MenuEvent::receiver().try_recv() { while let Ok(ev) = self.events.try_recv() {
if let Some((cmd, _, _)) = self.items.get(ev.id.as_ref()) { if let Some((cmd, _, _)) = self.items.get(ev.id.as_ref()) {
if cmd == "ui.discord" { if cmd == "ui.discord" {
ctx.open_url(egui::OpenUrl::new_tab("https://discord.gg/artcraft")); ctx.open_url(egui::OpenUrl::new_tab("https://discord.gg/artcraft"));
} else { } else {
menus::activate(app, &cmd.clone()); menus::activate_native(app, ctx, &cmd.clone());
} }
ctx.request_repaint(); ctx.request_repaint();
} }
@@ -113,17 +138,25 @@ impl NativeMenu {
} }
} }
let now = cadcraft_ui_egui::now_ms(); let now = cadcraft_ui_egui::now_ms();
if now - self.last_refresh < 300.0 { if !refresh_due(now, self.last_refresh) {
return; return;
} }
self.last_refresh = now; self.last_refresh = now;
let typing = menus::text_field_window(ctx).is_some();
for (cmd, _, item) in self.items.values() { for (cmd, _, item) in self.items.values() {
let en = cadcraft_engine::find_command(cmd).is_none_or(|c| (c.enabled)(&app.session).is_ok()); let en = (typing && menus::edits_text(cmd)) || cadcraft_engine::find_command(cmd).is_none_or(|c| (c.enabled)(&app.session).is_ok());
item.set_enabled(en); item.set_enabled(en);
} }
} }
} }
/// The periodic enablement refresh is due every 300 ms. Split out so the first-poll sentinel is
/// testable without a macOS menu: `now_ms` starts near zero, so a `0.0` initial value would
/// suppress the first refresh for 300 ms.
fn refresh_due(now: f64, last_refresh: f64) -> bool {
now - last_refresh >= 300.0
}
fn build( fn build(
parent: &Submenu, parent: &Submenu,
entries: &[Entry], entries: &[Entry],
@@ -150,3 +183,17 @@ fn build(
} }
} }
} }
#[cfg(test)]
mod tests {
use super::refresh_due;
/// `refresh_due` treats `NEG_INFINITY` as due at any finite time and `0.0` as due once 300 ms have elapsed; `install` initializes a fresh menu with `last_refresh = NEG_INFINITY`.
#[test]
fn the_first_poll_refreshes_immediately() {
assert!(refresh_due(0.5, f64::NEG_INFINITY), "a fresh menu refreshes on its first poll");
assert!(!refresh_due(0.5, 0.0), "the old 0.0 sentinel delayed the first refresh");
assert!(!refresh_due(299.9, 0.0));
assert!(refresh_due(300.0, 0.0));
}
}
+92 -13
View File
@@ -107,6 +107,23 @@ impl Filter {
*self = Self::default(); *self = Self::default();
} }
fn update_drift(&mut self, complete: bool, now: Instant, bias: &mut [f64; 6]) {
if !complete {
return;
}
if self.raw.iter().zip(*bias).all(|(raw, b)| (*raw - b).abs() <= FULL_SCALE * DEADZONE) {
let since = *self.idle_since.get_or_insert(now);
if now.saturating_duration_since(since) >= Duration::from_secs(2) {
for (b, raw) in bias.iter_mut().zip(self.raw) {
*b += (raw - *b) * 0.01;
}
self.bias = Some(*bias);
}
} else {
self.idle_since = None;
}
}
pub fn update(&mut self, values: [i16; 6], mask: u8, now: Instant) -> Motion { pub fn update(&mut self, values: [i16; 6], mask: u8, now: Instant) -> Motion {
for (index, seen) in self.seen.iter_mut().enumerate() { for (index, seen) in self.seen.iter_mut().enumerate() {
if mask & (1 << index) != 0 { if mask & (1 << index) != 0 {
@@ -128,19 +145,7 @@ impl Filter {
} }
let Some(mut bias) = self.bias else { return Motion::default() }; let Some(mut bias) = self.bias else { return Motion::default() };
// Follow small resting drift only after two seconds entirely inside the deadzone. // Follow small resting drift only after two seconds entirely inside the deadzone.
if complete { self.update_drift(complete, now, &mut bias);
if self.raw.iter().zip(bias).all(|(raw, bias)| (*raw - bias).abs() <= FULL_SCALE * DEADZONE) {
let since = *self.idle_since.get_or_insert(now);
if now.saturating_duration_since(since) >= Duration::from_secs(2) {
for (bias, raw) in bias.iter_mut().zip(self.raw) {
*bias += (raw - *bias) * 0.01;
}
self.bias = Some(bias);
}
} else {
self.idle_since = None;
}
}
let mut axes = [0.0; 6]; let mut axes = [0.0; 6];
for ((axis, raw), bias) in axes.iter_mut().zip(self.raw).zip(bias) { for ((axis, raw), bias) in axes.iter_mut().zip(self.raw).zip(bias) {
let value = ((raw - bias) / FULL_SCALE).clamp(-1.0, 1.0); let value = ((raw - bias) / FULL_SCALE).clamp(-1.0, 1.0);
@@ -156,3 +161,77 @@ impl Filter {
Motion { pan: [tx, ty], zoom: (tz + rz).clamp(-1.0, 1.0) } Motion { pan: [tx, ty], zoom: (tz + rz).clamp(-1.0, 1.0) }
} }
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn decode_reports() {
assert_eq!(decode(&[]), None);
assert_eq!(decode(&[3]), None);
assert_eq!(decode(&[3, 5]), Some(Report::Buttons(5)));
let values = [1_i16, -2, 3, 4, -5, 6];
let mut bytes = vec![1_u8];
for v in values {
bytes.extend_from_slice(&v.to_le_bytes());
}
assert_eq!(decode(&bytes), Some(Report::Axes { values, mask: 3 }));
bytes[0] = 2;
assert_eq!(decode(&bytes), Some(Report::Axes { values: [0, 0, 0, 1, -2, 3], mask: 2 }));
assert_eq!(decode(&[2, 1, 2, 3]), None, "too short for a rotation report");
}
/// Sixteen complete reports at rest, spread over more than CALIBRATION_TIME, all stamped
/// on a made-up point in time big enough that they sit in the past of later updates.
fn calibrated(t0: Instant) -> Filter {
let mut f = Filter::default();
for i in 0..17 {
let at = t0 + Duration::from_millis(50 * i);
assert_eq!(f.update([0; 6], 1, at), Motion::default());
assert_eq!(f.update([0; 6], 2, at), Motion::default());
}
assert!(f.calibrated());
f
}
#[test]
fn axes_deflect_and_expire() {
let t0 = Instant::now();
let mut f = calibrated(t0);
let at = t0 + Duration::from_secs(2);
let ms = Duration::from_millis;
let m = f.update([500, 500, 0, 0, 0, 0], 1, at);
assert!((m.pan[0] - 1.0).abs() < 1e-9);
assert!((m.pan[1] - 1.0).abs() < 1e-9);
assert_eq!(m.zoom, 0.0, "no rotation input");
// A fresh rotation report drives the zoom.
let m = f.update([0, 0, 0, 0, 0, 500], 2, at + ms(100));
assert!((m.zoom - 1.0).abs() < 1e-9);
// The rotation axes went quiet for over 250 ms while translations still arrive.
let m = f.update([500, 500, 0, 0, 0, 0], 1, at + ms(400));
assert_eq!(m.zoom, 0.0);
// A fresh rotation report puts the zoom back.
let m = f.update([500, 500, 0, 0, 0, 500], 2, at + ms(500));
assert!((m.zoom - 1.0).abs() < 1e-9);
}
#[test]
fn drift_tracks_rest_after_two_idle_seconds() {
// Five axes units of drift sit inside the deadzone (FULL_SCALE * DEADZONE = 20).
let mut f = Filter { raw: [5.0; 6], bias: Some([0.0; 6]), ..Filter::default() };
let t0 = Instant::now();
let mut b = [0.0; 6];
f.update_drift(true, t0, &mut b); // starts the idle window: no shift yet
assert_eq!(b, [0.0; 6]);
f.update_drift(true, t0 + Duration::from_secs(3), &mut b);
assert!((b[0] - 0.05).abs() < 1e-12, "bias follows resting drift");
assert_eq!(f.bias, Some(b));
// Motion outside the deadzone resets the idle window.
f.raw = [100.0; 6];
f.update_drift(true, t0 + Duration::from_secs(4), &mut b);
assert_eq!(f.idle_since, None);
f.update_drift(true, t0 + Duration::from_secs(5), &mut b);
assert!((b[0] - 0.05).abs() < 1e-12, "no shift before two seconds at rest");
}
}
+123 -55
View File
@@ -1,15 +1,18 @@
//! Native workspace preferences. Drawing data and transient dialogs never enter this file. //! Native workspace preferences. Drawing data and transient dialogs never enter this file.
use std::ffi::OsString;
use std::fs::{self, File, OpenOptions}; use std::fs::{self, File, OpenOptions};
use std::io::{Read, Write}; use std::io::{Read, Write};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, Instant};
use cadcraft_ui_egui::UiState; use cadcraft_ui_egui::UiState;
use serde_json::{Value, json}; use serde_json::{Value, json};
const MAX_BYTES: u64 = 64 * 1024; const MAX_BYTES: u64 = 64 * 1024;
/// Dragging a floating window changes its rect every frame; writes (each with an fsync) are
/// spaced at least this far apart, and the newest state is written when the interval ends.
const MIN_WRITE_INTERVAL: Duration = Duration::from_millis(500);
static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0); static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0);
pub struct Workspace { pub struct Workspace {
@@ -17,22 +20,11 @@ pub struct Workspace {
last_attempt: Option<Value>, last_attempt: Option<Value>,
read_failed: bool, read_failed: bool,
pending_write_error: Option<String>, pending_write_error: Option<String>,
} last_write: Option<Instant>,
fn config_dir_from(platform: &str, get: impl Fn(&str) -> Option<OsString>) -> Option<PathBuf> {
if let Some(root) = get("CADCRAFT_CONFIG_DIR") {
return (!root.is_empty()).then(|| PathBuf::from(root));
}
let nonempty = |key| get(key).filter(|value| !value.is_empty()).map(PathBuf::from);
match platform {
"macos" => nonempty("HOME").map(|home| home.join("Library/Application Support/CADCraft")),
"windows" => nonempty("APPDATA").map(|root| root.join("CADCraft")),
_ => nonempty("XDG_CONFIG_HOME").map(|root| root.join("cadcraft")).or_else(|| nonempty("HOME").map(|home| home.join(".config/cadcraft"))),
}
} }
fn snapshot(ui: &UiState) -> Result<Value, String> { fn snapshot(ui: &UiState) -> Result<Value, String> {
ui.docking.validate()?; cadcraft_ui_egui::docking::validate(&ui.docking)?;
if !matches!(ui.toolset_tab.as_str(), "Drafting" | "Modeling") { if !matches!(ui.toolset_tab.as_str(), "Drafting" | "Modeling") {
return Err("toolsetTab must be Drafting or Modeling".into()); return Err("toolsetTab must be Drafting or Modeling".into());
} }
@@ -44,6 +36,7 @@ fn snapshot(ui: &UiState) -> Result<Value, String> {
} }
Ok(json!({ Ok(json!({
"docking": ui.docking, "docking": ui.docking,
"dockingVersion": cadcraft_ui_egui::docking::LAYOUT_VERSION,
"showToolsets": ui.show_toolsets, "showToolsets": ui.show_toolsets,
"showPalettes": ui.show_palettes, "showPalettes": ui.show_palettes,
"showToolbar": ui.show_toolbar, "showToolbar": ui.show_toolbar,
@@ -80,12 +73,18 @@ fn read(path: &Path) -> Result<Option<UiState>, String> {
value.get("workspace").filter(|value| value.is_object()).ok_or_else(|| format!("{}: missing workspace object", path.display()))?; value.get("workspace").filter(|value| value.is_object()).ok_or_else(|| format!("{}: missing workspace object", path.display()))?;
// Dock schemas can evolve independently of the other saved workspace preferences. // Dock schemas can evolve independently of the other saved workspace preferences.
// Recover only this optional field; runtime UiState deserialization remains strict. // Recover only this optional field; runtime UiState deserialization remains strict.
// A layout from before the current shape (split frames, saved without `dockingVersion`)
// gives way to the default, its hidden panels with it.
let mut fields = workspace.clone(); let mut fields = workspace.clone();
let docking = fields.as_object_mut().and_then(|fields| fields.remove("docking")); let (docking, version) = match fields.as_object_mut() {
Some(fields) => (fields.remove("docking"), fields.remove("dockingVersion")),
None => (None, None),
};
let current = version.and_then(|version| version.as_u64()).is_some_and(|version| version >= u64::from(cadcraft_ui_egui::docking::LAYOUT_VERSION));
let mut ui: UiState = serde_json::from_value(fields).map_err(|error| format!("{}: invalid workspace: {error}", path.display()))?; let mut ui: UiState = serde_json::from_value(fields).map_err(|error| format!("{}: invalid workspace: {error}", path.display()))?;
if let Some(docking) = docking if let Some(docking) = docking.filter(|_| current)
&& let Ok(docking) = serde_json::from_value::<cadcraft_ui_egui::docking::Workspace>(docking) && let Ok(docking) = serde_json::from_value::<cadcraft_ui_egui::docking::Workspace>(docking)
&& docking.validate().is_ok() && cadcraft_ui_egui::docking::validate(&docking).is_ok()
{ {
ui.docking = docking; ui.docking = docking;
} }
@@ -127,8 +126,9 @@ fn write(path: &Path, workspace: &Value) -> Result<(), String> {
} }
impl Workspace { impl Workspace {
pub fn load() -> (Self, Option<UiState>, Option<String>) { /// The workspace file in the settings directory `config_dir`; without one nothing is kept.
Self::at(config_dir_from(std::env::consts::OS, |key| std::env::var_os(key)).map(|root| root.join("workspace.json"))) pub fn load(config_dir: Option<&Path>) -> (Self, Option<UiState>, Option<String>) {
Self::at(config_dir.map(|root| root.join("workspace.json")))
} }
fn at(path: Option<PathBuf>) -> (Self, Option<UiState>, Option<String>) { fn at(path: Option<PathBuf>) -> (Self, Option<UiState>, Option<String>) {
@@ -136,44 +136,54 @@ impl Workspace {
match result { match result {
Ok(ui) => { Ok(ui) => {
let initial = ui.as_ref().cloned().unwrap_or_default(); let initial = ui.as_ref().cloned().unwrap_or_default();
(Self { path, last_attempt: snapshot(&initial).ok(), read_failed: false, pending_write_error: None }, ui, None) (Self { path, last_attempt: snapshot(&initial).ok(), read_failed: false, pending_write_error: None, last_write: None }, ui, None)
} }
Err(error) => ( Err(error) => (
Self { path, last_attempt: None, read_failed: true, pending_write_error: None }, Self { path, last_attempt: None, read_failed: true, pending_write_error: None, last_write: None },
None, None,
Some(format!("Workspace could not be restored; its file was left unchanged. {error}")), Some(format!("Workspace could not be restored; its file was left unchanged. {error}")),
), ),
} }
} }
pub fn save_if_changed(&mut self, ui: &UiState) -> Result<(), String> { /// Writes `ui` if it differs from the last state written. `Ok(Some(wait))` means a change
let Some(path) = &self.path else { return Ok(()) }; /// is held back by [`MIN_WRITE_INTERVAL`]; call again after `wait` to write it.
pub fn save_if_changed(&mut self, ui: &UiState, now: Instant) -> Result<Option<Duration>, String> {
let Some(path) = &self.path else { return Ok(None) };
if self.read_failed { if self.read_failed {
// Preserve a corrupt, oversized or newer-version file until the user repairs it. // Preserve a corrupt, oversized or newer-version file until the user repairs it.
return Ok(()); return Ok(None);
} }
let value = snapshot(ui)?; let value = snapshot(ui)?;
if self.last_attempt.as_ref() == Some(&value) { if self.last_attempt.as_ref() == Some(&value) {
return self.pending_write_error.clone().map_or(Ok(()), Err); return self.pending_write_error.clone().map_or(Ok(None), Err);
}
if let Some(wait) = self.last_write.and_then(|last| MIN_WRITE_INTERVAL.checked_sub(now.saturating_duration_since(last)))
&& !wait.is_zero()
{
return Ok(Some(wait));
} }
// Keep a failed state's warning without retrying disk I/O every rendered frame. // Keep a failed state's warning without retrying disk I/O every rendered frame.
self.last_attempt = Some(value.clone()); self.last_attempt = Some(value.clone());
self.last_write = Some(now);
let result = write(path, &value); let result = write(path, &value);
self.pending_write_error = result.as_ref().err().cloned(); self.pending_write_error = result.as_ref().err().cloned();
result result.map(|()| None)
} }
pub fn save_on_exit(&mut self, ui: &UiState) -> Result<(), String> { pub fn save_on_exit(&mut self, ui: &UiState) -> Result<(), String> {
if self.pending_write_error.is_some() { if self.pending_write_error.is_some() {
self.last_attempt = None; self.last_attempt = None;
} }
self.save_if_changed(ui) self.last_write = None;
self.save_if_changed(ui, Instant::now()).map(|_| ())
} }
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use std::ffi::OsString;
fn temp_dir() -> PathBuf { fn temp_dir() -> PathBuf {
let sequence = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed); let sequence = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed);
@@ -184,20 +194,18 @@ mod tests {
#[test] #[test]
fn profile_override_isolated_or_explicitly_disabled() { fn profile_override_isolated_or_explicitly_disabled() {
let get = |key: &str| match key { let vars = |config: Option<&'static str>| {
"CADCRAFT_CONFIG_DIR" => Some(OsString::from("/private/test-state")), move |key: &str| match key {
"HOME" | "APPDATA" | "XDG_CONFIG_HOME" => Some(OsString::from("/unrelated-user-profile")), "CADCRAFT_CONFIG_DIR" => config.map(OsString::from),
_ => None, "HOME" | "APPDATA" | "XDG_CONFIG_HOME" => Some(OsString::from("/unrelated-user-profile")),
_ => None,
}
}; };
for platform in ["macos", "windows", "linux", "freebsd"] { let dir = |config| crate::env::Env::from_vars(vars(config)).config_dir;
assert_eq!(config_dir_from(platform, get), Some(PathBuf::from("/private/test-state"))); assert_eq!(dir(Some("/private/test-state")), Some(PathBuf::from("/private/test-state")));
assert_eq!(config_dir_from(platform, |key| if key == "CADCRAFT_CONFIG_DIR" { Some(OsString::new()) } else { get(key) }), None); assert_eq!(dir(Some("")), None);
} let (_, restored, error) = Workspace::load(dir(Some("")).as_deref());
assert_eq!( assert!(restored.is_none() && error.is_none(), "no settings folder, no workspace file");
config_dir_from("linux", |key| (key == "XDG_CONFIG_HOME").then(|| OsString::from("/config"))),
Some(PathBuf::from("/config/cadcraft"))
);
assert_eq!(config_dir_from("macos", |_| None), None);
} }
#[test] #[test]
@@ -216,7 +224,7 @@ mod tests {
start_tab: true, start_tab: true,
..Default::default() ..Default::default()
}; };
first.save_if_changed(&ui).unwrap(); first.save_if_changed(&ui, Instant::now()).unwrap();
let bytes = fs::read(&path).unwrap(); let bytes = fs::read(&path).unwrap();
let value: Value = serde_json::from_slice(&bytes).unwrap(); let value: Value = serde_json::from_slice(&bytes).unwrap();
assert!(value["workspace"].get("dialog").is_none()); assert!(value["workspace"].get("dialog").is_none());
@@ -229,34 +237,68 @@ mod tests {
let mut transient_only = ui.clone(); let mut transient_only = ui.clone();
transient_only.dialog = None; transient_only.dialog = None;
transient_only.start_tab = false; transient_only.start_tab = false;
first.save_if_changed(&transient_only).unwrap(); first.save_if_changed(&transient_only, Instant::now()).unwrap();
assert_eq!(fs::read(&path).unwrap(), bytes); assert_eq!(fs::read(&path).unwrap(), bytes);
fs::remove_dir_all(directory).unwrap(); fs::remove_dir_all(directory).unwrap();
} }
#[test] #[test]
fn docked_floating_and_hidden_panels_survive_native_workspace_file_reload() { fn dock_columns_floating_and_hidden_panels_survive_native_workspace_file_reload() {
use cadcraft_ui_egui::docking::Panel; use cadcraft_ui_egui::docking::{LAYOUT_VERSION, Panel, permissions, reveal_panel};
let directory = temp_dir(); let directory = temp_dir();
let path = directory.join("workspace.json"); let path = directory.join("workspace.json");
let (mut store, _, _) = Workspace::at(Some(path.clone())); let (mut store, _, _) = Workspace::at(Some(path.clone()));
let mut ui = UiState::default(); let mut ui = UiState::default();
let mut app = cadcraft_ui_egui::CadApp::new(cadcraft_engine::Session::default(), Default::default()); let mut app = cadcraft_ui_egui::CadApp::new(cadcraft_engine::Session::default(), Default::default());
app.run("ui.dock", json!({"operation":"float", "panel":"layers", "rect":[120,90,340,410]})).unwrap(); app.run("ui.dock", json!({"operation":"move", "panel":"layers", "target":"properties", "zone":"left"})).unwrap();
app.run("ui.dock", json!({"operation":"float", "panel":"properties", "rect":[120,90,340,410]})).unwrap();
ui.docking = app.ui.docking; ui.docking = app.ui.docking;
ui.docking.set_visible(Panel::ToolSets, false).unwrap(); ui.docking.set_visible(Panel::ToolSets, false, None, permissions).unwrap();
ui.show_toolsets = false; ui.show_toolsets = false;
store.save_if_changed(&ui).unwrap(); store.save_if_changed(&ui, Instant::now()).unwrap();
let saved: Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
assert_eq!(saved["workspace"]["dockingVersion"], json!(LAYOUT_VERSION));
let (_, reloaded, error) = Workspace::at(Some(path)); let (_, reloaded, error) = Workspace::at(Some(path));
assert!(error.is_none()); assert!(error.is_none());
let mut reloaded = reloaded.unwrap(); let mut reloaded = reloaded.unwrap();
assert_eq!(serde_json::to_value(&reloaded.docking).unwrap(), serde_json::to_value(&ui.docking).unwrap()); assert_eq!(serde_json::to_value(&reloaded.docking).unwrap(), serde_json::to_value(&ui.docking).unwrap());
reloaded.docking.set_visible(Panel::ToolSets, true).unwrap(); assert!(!reloaded.show_toolsets);
assert!(reloaded.docking.layout.contains(&Panel::ToolSets)); reveal_panel(&mut reloaded.docking, Panel::ToolSets).unwrap();
let tools = reloaded.docking.layout.location(&Panel::ToolSets).unwrap();
assert_eq!(serde_json::to_value(&tools.group).unwrap()["column"]["side"], json!("Start"), "{tools:?}");
assert_eq!(reloaded.docking.layout.floating[0].rect, [120.0, 90.0, 340.0, 410.0]); assert_eq!(reloaded.docking.layout.floating[0].rect, [120.0, 90.0, 340.0, 410.0]);
fs::remove_dir_all(directory).unwrap(); fs::remove_dir_all(directory).unwrap();
} }
#[test]
fn split_frame_layout_file_loads_as_the_dock_default_with_its_other_settings() {
use cadcraft_ui_egui::docking::default_workspace;
// Written as CADCraft 0.7 writes it: craft-ui's v1 layout with no docking version, a
// floating window as one `{panels, active, rect}` group, Tool Sets closed.
let file = r#"{"version":1,"workspace":{
"docking":{
"layout":{
"root":{"Split":{"axis":"Horizontal","size":{"FixedSecond":300.0},
"first":{"Tabs":{"panels":["canvas"],"active":0}},
"second":{"Stack":{"entries":[{"panel":"properties","open":false,"height":null}]}}}},
"floating":[{"panels":["layers","externalReferences"],"active":1,"rect":[120.0,90.0,340.0,410.0]}]},
"hidden":[["toolSets",{"floating":null,"anchor":"canvas","placement":{"Split":"Left"},
"size":{"FixedFirst":220.0},"stack":null,"sibling":["canvas","properties"]}]]},
"showToolsets":false,"showPalettes":true,"showToolbar":false,"toolsetTab":"Modeling","historyLines":4}}"#;
let directory = temp_dir();
let path = directory.join("workspace.json");
fs::write(&path, file).unwrap();
let (_, restored, error) = Workspace::at(Some(path));
assert!(error.is_none(), "{error:?}");
let ui = restored.unwrap();
assert_eq!(ui.toolset_tab, "Modeling");
assert_eq!(ui.history_lines, 4);
assert!(!ui.show_toolsets && ui.show_palettes && !ui.show_toolbar);
assert_eq!(ui.docking.layout, default_workspace().layout);
assert!(ui.docking.hidden.is_empty(), "{:?}", ui.docking.hidden);
fs::remove_dir_all(directory).unwrap();
}
#[test] #[test]
fn malformed_oversized_and_newer_state_are_reported_and_preserved() { fn malformed_oversized_and_newer_state_are_reported_and_preserved() {
let directory = temp_dir(); let directory = temp_dir();
@@ -272,7 +314,7 @@ mod tests {
let (mut store, ui, error) = Workspace::at(Some(path.clone())); let (mut store, ui, error) = Workspace::at(Some(path.clone()));
assert!(ui.is_none()); assert!(ui.is_none());
assert!(error.as_deref().is_some_and(|error| error.contains("left unchanged"))); assert!(error.as_deref().is_some_and(|error| error.contains("left unchanged")));
store.save_if_changed(&UiState { show_palettes: false, ..Default::default() }).unwrap(); store.save_if_changed(&UiState { show_palettes: false, ..Default::default() }, Instant::now()).unwrap();
assert_eq!(fs::read(&path).unwrap(), bytes); assert_eq!(fs::read(&path).unwrap(), bytes);
} }
fs::remove_dir_all(directory).unwrap(); fs::remove_dir_all(directory).unwrap();
@@ -287,7 +329,8 @@ mod tests {
json!({"layout":false,"hidden":[]}), json!({"layout":false,"hidden":[]}),
json!({"layout":{"root":null,"floating":[]},"hidden":[["futurePanel",{}]]}), json!({"layout":{"root":null,"floating":[]},"hidden":[["futurePanel",{}]]}),
] { ] {
let workspace = json!({"showToolbar":false,"showPalettes":false,"toolsetTab":"Modeling", "collapsedGroups":["Draw"], "historyLines":7,"docking":docking}); let workspace = json!({"showToolbar":false,"showPalettes":false,"toolsetTab":"Modeling", "collapsedGroups":["Draw"], "historyLines":7,"docking":docking,
"dockingVersion":cadcraft_ui_egui::docking::LAYOUT_VERSION});
assert!(serde_json::from_value::<UiState>(workspace.clone()).is_err(), "runtime state must stay strict"); assert!(serde_json::from_value::<UiState>(workspace.clone()).is_err(), "runtime state must stay strict");
fs::write(&path, serde_json::to_vec(&json!({"version":1,"workspace":workspace})).unwrap()).unwrap(); fs::write(&path, serde_json::to_vec(&json!({"version":1,"workspace":workspace})).unwrap()).unwrap();
let (_, restored, error) = Workspace::at(Some(path.clone())); let (_, restored, error) = Workspace::at(Some(path.clone()));
@@ -303,16 +346,41 @@ mod tests {
fs::remove_dir_all(directory).unwrap(); fs::remove_dir_all(directory).unwrap();
} }
#[test]
fn rapid_changes_are_written_once_per_interval_with_the_newest_state() {
let directory = temp_dir();
let path = directory.join("workspace.json");
let (mut store, _, _) = Workspace::at(Some(path.clone()));
let start = Instant::now();
let mut ui = UiState { history_lines: 1, ..Default::default() };
assert_eq!(store.save_if_changed(&ui, start).unwrap(), None);
// A drag: a new state every frame, none of them written until the interval ends.
for (frame, lines) in (2..=5).enumerate() {
ui.history_lines = lines;
let now = start + Duration::from_millis(16 * (frame as u64 + 1));
let wait = store.save_if_changed(&ui, now).unwrap();
assert_eq!(wait, Some(MIN_WRITE_INTERVAL - (now - start)));
}
assert_eq!(read(&path).unwrap().unwrap().history_lines, 1);
assert_eq!(store.save_if_changed(&ui, start + MIN_WRITE_INTERVAL).unwrap(), None);
assert_eq!(read(&path).unwrap().unwrap().history_lines, 5);
ui.history_lines = 6;
assert!(store.save_if_changed(&ui, start + MIN_WRITE_INTERVAL).unwrap().is_some());
store.save_on_exit(&ui).unwrap();
assert_eq!(read(&path).unwrap().unwrap().history_lines, 6);
fs::remove_dir_all(directory).unwrap();
}
#[test] #[test]
fn invalid_update_cannot_replace_last_good_workspace() { fn invalid_update_cannot_replace_last_good_workspace() {
let directory = temp_dir(); let directory = temp_dir();
let path = directory.join("workspace.json"); let path = directory.join("workspace.json");
let (mut store, _, _) = Workspace::at(Some(path.clone())); let (mut store, _, _) = Workspace::at(Some(path.clone()));
let mut ui = UiState { show_palettes: false, ..Default::default() }; let mut ui = UiState { show_palettes: false, ..Default::default() };
store.save_if_changed(&ui).unwrap(); store.save_if_changed(&ui, Instant::now()).unwrap();
let good = fs::read(&path).unwrap(); let good = fs::read(&path).unwrap();
ui.collapsed_groups = vec!["x".repeat(257)]; ui.collapsed_groups = vec!["x".repeat(257)];
assert!(store.save_if_changed(&ui).is_err()); assert!(store.save_if_changed(&ui, Instant::now()).is_err());
assert_eq!(fs::read(&path).unwrap(), good); assert_eq!(fs::read(&path).unwrap(), good);
fs::remove_dir_all(directory).unwrap(); fs::remove_dir_all(directory).unwrap();
} }
@@ -325,9 +393,9 @@ mod tests {
// Simulate a destination becoming unavailable after startup. // Simulate a destination becoming unavailable after startup.
fs::create_dir(&path).unwrap(); fs::create_dir(&path).unwrap();
let ui = UiState { show_palettes: false, ..Default::default() }; let ui = UiState { show_palettes: false, ..Default::default() };
let first_error = store.save_if_changed(&ui).unwrap_err(); let first_error = store.save_if_changed(&ui, Instant::now()).unwrap_err();
fs::remove_dir(&path).unwrap(); fs::remove_dir(&path).unwrap();
assert_eq!(store.save_if_changed(&ui).unwrap_err(), first_error); assert_eq!(store.save_if_changed(&ui, Instant::now()).unwrap_err(), first_error);
assert!(!path.exists(), "ordinary frames must not retry disk writes"); assert!(!path.exists(), "ordinary frames must not retry disk writes");
store.save_on_exit(&ui).unwrap(); store.save_on_exit(&ui).unwrap();
assert!(!read(&path).unwrap().unwrap().show_palettes); assert!(!read(&path).unwrap().unwrap().show_palettes);
+4
View File
@@ -2,3 +2,7 @@ allow-unwrap-in-tests = true
allow-expect-in-tests = true allow-expect-in-tests = true
allow-panic-in-tests = true allow-panic-in-tests = true
allow-indexing-slicing-in-tests = true allow-indexing-slicing-in-tests = true
disallowed-types = [
{ path = "egui::ComboBox", reason = "use craft_ui::ComboBox: egui's combo box menu can't grow back while open (egui#5225)" },
]
cognitive-complexity-threshold = 30
+18
View File
@@ -73,3 +73,21 @@ sync_github_name = false
[people.gurparvez] [people.gurparvez]
real_name = "Gurparvez" real_name = "Gurparvez"
[people.nicholasgower]
sync_github_name = true
[people.ffishman]
real_name = "Rodrigo Gressler"
display_name = "Rodrigo"
sync_github_name = false
[people.aussieboer61]
real_name = "Richard Sauer"
display_name = "Richard Sauer"
sync_github_name = false
[people.dotCooCoo]
real_name = "Robert Lee"
sync_github_name = true
+43 -8
View File
@@ -21,7 +21,8 @@ impl Rgb {
format!("#{:02x}{:02x}{:02x}", self.0, self.1, self.2) format!("#{:02x}{:02x}{:02x}", self.0, self.1, self.2)
} }
pub fn parse_hex(s: &str) -> Option<Rgb> { pub fn parse_hex(s: &str) -> Option<Rgb> {
let s = s.trim().trim_start_matches('#'); let s = s.trim();
let s = s.strip_prefix('#').unwrap_or(s);
if s.len() != 6 || !s.chars().all(|c| c.is_ascii_hexdigit()) { if s.len() != 6 || !s.chars().all(|c| c.is_ascii_hexdigit()) {
return None; return None;
} }
@@ -110,9 +111,9 @@ impl Color {
_ => {} _ => {}
} }
if let Some(rest) = t.strip_prefix("color ") { if let Some(rest) = t.strip_prefix("color ") {
return rest.trim().parse::<u8>().ok().filter(|i| *i >= 1).map(Color::Index); return parse_digits::<u8>(rest.trim()).filter(|i| *i >= 1).map(Color::Index);
} }
if let Ok(i) = t.parse::<u16>() { if let Some(i) = parse_digits::<u16>(&t) {
return match i { return match i {
0 => Some(Color::ByBlock), 0 => Some(Color::ByBlock),
256 => Some(Color::ByLayer), 256 => Some(Color::ByLayer),
@@ -123,14 +124,22 @@ impl Color {
if t.starts_with('#') { if t.starts_with('#') {
return Rgb::parse_hex(&t).map(Color::True); return Rgb::parse_hex(&t).map(Color::True);
} }
let parts: Vec<_> = t.split(',').map(|p| p.trim().parse::<u8>()).collect(); let parts: Vec<_> = t.split(',').map(|p| parse_digits::<u8>(p.trim())).collect();
if let [Ok(r), Ok(g), Ok(b)] = parts.as_slice() { if let [Some(r), Some(g), Some(b)] = parts.as_slice() {
return Some(Color::True(Rgb(*r, *g, *b))); return Some(Color::True(Rgb(*r, *g, *b)));
} }
None None
} }
} }
/// Parse an unsigned integer made of ASCII digits only (no sign, unlike `str::parse`).
fn parse_digits<T: std::str::FromStr>(s: &str) -> Option<T> {
if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
s.parse().ok()
}
/// The RGB of ACI `i` (0 and out-of-range map to white/7). /// The RGB of ACI `i` (0 and out-of-range map to white/7).
pub fn aci_rgb(i: u8) -> Rgb { pub fn aci_rgb(i: u8) -> Rgb {
PALETTE.get(usize::from(i)).copied().unwrap_or(Rgb(255, 255, 255)) PALETTE.get(usize::from(i)).copied().unwrap_or(Rgb(255, 255, 255))
@@ -156,7 +165,7 @@ fn build_palette() -> [Rgb; 256] {
for (slot, c) in p.iter_mut().zip(base) { for (slot, c) in p.iter_mut().zip(base) {
*slot = c; *slot = c;
} }
let values = [255.0, 165.0, 127.0, 76.0, 38.0]; let values = [255.0, 204.0, 153.0, 127.0, 76.0];
for i in 10..250usize { for i in 10..250usize {
let hue = (i - 10) / 10; // 0..24, 15° each let hue = (i - 10) / 10; // 0..24, 15° each
let sub = i % 10; let sub = i % 10;
@@ -233,13 +242,39 @@ mod tests {
assert_eq!(Rgb::parse_hex("#012345"), Some(Rgb(1, 35, 69))); assert_eq!(Rgb::parse_hex("#012345"), Some(Rgb(1, 35, 69)));
} }
#[test]
fn parse_rejects_plus_sign() {
assert_eq!(Color::parse("+5"), None);
assert_eq!(Color::parse(" +256"), None);
assert_eq!(Color::parse("color +5"), None);
assert_eq!(Color::parse("+1,+2,+3"), None);
assert_eq!(Color::parse("1,+2,3"), None);
assert_eq!(Color::parse("5"), Some(Color::Index(5)));
assert_eq!(Color::parse("256"), Some(Color::ByLayer));
assert_eq!(Color::parse("color 5"), Some(Color::Index(5)));
assert_eq!(Color::parse("1, 2, 3"), Some(Color::True(Rgb(1, 2, 3))));
}
#[test]
fn parse_hex_rejects_repeated_hash() {
assert_eq!(Rgb::parse_hex("##ff0000"), None);
assert_eq!(Rgb::parse_hex("###ff0000"), None);
assert_eq!(Color::parse("##ff0000"), None);
assert_eq!(Rgb::parse_hex("#ff0000"), Some(Rgb(255, 0, 0)));
assert_eq!(Rgb::parse_hex("ff0000"), Some(Rgb(255, 0, 0)));
}
#[test] #[test]
fn palette_known_entries() { fn palette_known_entries() {
assert_eq!(aci_rgb(1), Rgb(255, 0, 0)); assert_eq!(aci_rgb(1), Rgb(255, 0, 0));
assert_eq!(aci_rgb(10), Rgb(255, 0, 0)); assert_eq!(aci_rgb(10), Rgb(255, 0, 0));
assert_eq!(aci_rgb(11), Rgb(255, 127, 127)); assert_eq!(aci_rgb(11), Rgb(255, 127, 127));
assert_eq!(aci_rgb(12), Rgb(165, 0, 0)); assert_eq!(aci_rgb(12), Rgb(204, 0, 0));
assert_eq!(aci_rgb(13), Rgb(165, 82, 82)); assert_eq!(aci_rgb(13), Rgb(204, 102, 102));
assert_eq!(aci_rgb(14), Rgb(153, 0, 0));
assert_eq!(aci_rgb(15), Rgb(153, 76, 76));
assert_eq!(aci_rgb(16), Rgb(127, 0, 0));
assert_eq!(aci_rgb(18), Rgb(76, 0, 0));
assert_eq!(aci_rgb(20), Rgb(255, 63, 0)); assert_eq!(aci_rgb(20), Rgb(255, 63, 0));
assert_eq!(aci_rgb(30), Rgb(255, 127, 0)); assert_eq!(aci_rgb(30), Rgb(255, 127, 0));
assert_eq!(aci_rgb(50), Rgb(255, 255, 0)); assert_eq!(aci_rgb(50), Rgb(255, 255, 0));
+5 -1
View File
@@ -102,7 +102,11 @@ fn ang_between(d1: Vec2, d2: Vec2) -> f64 {
pub fn infer(d: &Drawing, handles: &[Handle], o: &InferOptions) -> Vec<Constraint> { pub fn infer(d: &Drawing, handles: &[Handle], o: &InferOptions) -> Vec<Constraint> {
let tol = if o.distance_tolerance.is_finite() { o.distance_tolerance.clamp(0.0, 1e9) } else { 0.05 }; let tol = if o.distance_tolerance.is_finite() { o.distance_tolerance.clamp(0.0, 1e9) } else { 0.05 };
let atol = if o.angle_tolerance.is_finite() { o.angle_tolerance.clamp(0.0, 45.0).to_radians() } else { 1f64.to_radians() }; let atol = if o.angle_tolerance.is_finite() { o.angle_tolerance.clamp(0.0, 45.0).to_radians() } else { 1f64.to_radians() };
let allow = |k: ConstraintKind| o.types.is_empty() || o.types.iter().any(|t| t.eq_ignore_ascii_case(k.name())); // Resolve the allowed type names into a set once, so membership is O(1) per candidate instead
// of re-scanning the whole (attacker-controllable) `o.types` list for every geometric match
// (CWE-407). Empty `o.types` means "all types".
let allowed: std::collections::HashSet<String> = o.types.iter().map(|t| t.to_ascii_lowercase()).collect();
let allow = |k: ConstraintKind| allowed.is_empty() || allowed.contains(&k.name().to_ascii_lowercase());
let g = collect(d, handles); let g = collect(d, handles);
let mut out: Vec<Constraint> = Vec::new(); let mut out: Vec<Constraint> = Vec::new();
let mut push = |kind: ConstraintKind, refs: Vec<GeomRef>| { let mut push = |kind: ConstraintKind, refs: Vec<GeomRef>| {
+49 -3
View File
@@ -12,12 +12,15 @@ use cadcraft_doc::{Constraint, Drawing, Handle};
use serde::Serialize; use serde::Serialize;
use crate::model::Model; use crate::model::Model;
use crate::{Comp, MAX_CONSTRAINTS, SolveError, SolveOptions, build, lm}; use crate::{Comp, MAX_CONSTRAINTS, SolveError, SolveOptions, build_budgeted, lm};
/// Most free parameters for which per-object freedom is analysed (null-space basis size). /// Most free parameters for which per-object freedom is analysed (null-space basis size).
const MAX_NULL_COLS: usize = 600; const MAX_NULL_COLS: usize = 600;
/// Largest Jacobian (rows × columns × min) whose rank is computed. /// Largest Jacobian (rows × columns × min) whose rank is computed.
const MAX_RANK_WORK: f64 = 4e8; const MAX_RANK_WORK: f64 = 4e8;
/// Largest residual-evaluation work (rows × free × per-residual cost, including spline
/// reconstruction) allowed before building the Jacobian during numerical differentiation.
const MAX_EVAL_WORK: f64 = 2e9;
/// Most objects listed in one report. /// Most objects listed in one report.
const MAX_OBJECTS: usize = 10_000; const MAX_OBJECTS: usize = 10_000;
@@ -86,6 +89,8 @@ struct CompDof {
objects: Vec<Option<usize>>, objects: Vec<Option<usize>>,
} }
/// Compute component and object freedom only when rank and residual-evaluation work fit their budgets.
/// Return unknown freedom before building the Jacobian when either work limit is exceeded.
fn analyse(comp: &Comp) -> CompDof { fn analyse(comp: &Comp) -> CompDof {
let free = comp.free(None); let free = comp.free(None);
let rows = comp.rows(None); let rows = comp.rows(None);
@@ -94,6 +99,14 @@ fn analyse(comp: &Comp) -> CompDof {
if rows > lm::MAX_ROWS || (rows as f64) * (nf as f64) * (rows.min(nf) as f64) > MAX_RANK_WORK { if rows > lm::MAX_ROWS || (rows as f64) * (nf as f64) * (rows.min(nf) as f64) > MAX_RANK_WORK {
return unknown; return unknown;
} }
// Numerical differentiation evaluates every residual about `rows × free` times, and each
// spline-end residual reconstructs its spline through a dense solve. Account for that
// reconstruction cost before building the Jacobian so a component with few equations but large
// splines cannot pass the rank-work check yet still require billions of operations (row 43).
let eval_work = (rows as f64) * (nf as f64) * (1.0 + comp.built.iter().map(|b| b.spline_reconstruct_cost(&comp.model)).sum::<f64>());
if eval_work > MAX_EVAL_WORK {
return unknown;
}
let f = |x: &[f64], out: &mut Vec<f64>| comp.eval(x, out, None); let f = |x: &[f64], out: &mut Vec<f64>| comp.eval(x, out, None);
let jac = lm::jacobian(&f, &comp.model.x0, &free, rows); let jac = lm::jacobian(&f, &comp.model.x0, &free, rows);
if nf > MAX_NULL_COLS { if nf > MAX_NULL_COLS {
@@ -126,7 +139,8 @@ fn plural(n: usize, one: &str, many: &str) -> String {
/// (constrained or not) and the constraint components they belong to. /// (constrained or not) and the constraint components they belong to.
pub fn dof(d: &Drawing, handles: &[Handle]) -> Result<DofReport, SolveError> { pub fn dof(d: &Drawing, handles: &[Handle]) -> Result<DofReport, SolveError> {
let cons: Vec<&Constraint> = d.constraints.iter().take(MAX_CONSTRAINTS).collect(); let cons: Vec<&Constraint> = d.constraints.iter().take(MAX_CONSTRAINTS).collect();
let (comps, _) = build(d, &cons, &SolveOptions::default())?; let mut members_left = crate::model::MAX_RIGID_WORK;
let (comps, _) = build_budgeted(d, &cons, &SolveOptions::default(), &mut members_left)?;
let want = |h: Handle| handles.is_empty() || handles.contains(&h); let want = |h: Handle| handles.is_empty() || handles.contains(&h);
let mut objects = Vec::new(); let mut objects = Vec::new();
let mut seen = HashSet::new(); let mut seen = HashSet::new();
@@ -165,7 +179,7 @@ pub fn dof(d: &Drawing, handles: &[Handle]) -> Result<DofReport, SolveError> {
if seen.contains(&h) { if seen.contains(&h) {
continue; continue;
} }
let m = Model::build(d, &[h]); let m = Model::build_budgeted(d, &[h], &mut members_left);
let Some(s) = m.slots.first() else { continue }; let Some(s) = m.slots.first() else { continue };
seen.insert(h); seen.insert(h);
free_objects = true; free_objects = true;
@@ -193,3 +207,35 @@ pub fn dof(d: &Drawing, handles: &[Handle]) -> Result<DofReport, SolveError> {
}; };
Ok(DofReport { dof: total, status, redundant, constraints, objects, message }) Ok(DofReport { dof: total, status, redundant, constraints, objects, message })
} }
#[cfg(test)]
mod budget_tests {
use super::*;
use crate::residual::{Built, Form};
use cadcraft_doc::{Common, ConstraintKind, EntityKind, GeomRef, Space};
use cadcraft_geom::{Spline, Vec2};
/// An expensive fit-spline endpoint residual produces unknown freedom before numerical differentiation.
#[test]
fn endpoint_analysis_rejects_cubic_fit_work_before_differentiation() {
let mut d = Drawing::default();
let pts: Vec<_> = (0..500).map(|i| Vec2::new(i as f64, (i % 3) as f64)).collect();
let mut spline = Spline::from_control(pts.clone(), 3);
spline.fit = pts;
let h = d.add(&Space::Model, Common::default(), EntityKind::Spline(spline)).unwrap();
let model = Model::build(&d, &[h]);
let endpoint = Built {
id: 1,
kind: ConstraintKind::Tangent,
refs: vec![GeomRef::whole(h)],
form: Form::EndOn { spline: 0, at_end: false },
target: 0.0,
sign: 1.0,
rows: 2,
aux_init: Vec::new(),
aux: 0,
};
let comp = Comp { model, built: vec![endpoint], weights: vec![1.0; 2], held: Vec::new(), tol: 1e-9 };
assert!(analyse(&comp).dof.is_none());
}
}
+58 -9
View File
@@ -5,7 +5,7 @@ use std::collections::BTreeMap;
/// Longest expression accepted (characters). /// Longest expression accepted (characters).
pub const MAX_LEN: usize = 1024; pub const MAX_LEN: usize = 1024;
/// Deepest nesting (parentheses, unary minus) and parameter reference chain. /// Deepest syntax tree and combined expression/parameter evaluation path.
pub const MAX_DEPTH: usize = 64; pub const MAX_DEPTH: usize = 64;
#[derive(Clone, Debug, PartialEq, thiserror::Error)] #[derive(Clone, Debug, PartialEq, thiserror::Error)]
@@ -49,7 +49,7 @@ struct Parser<'a> {
impl Parser<'_> { impl Parser<'_> {
fn ws(&mut self) { fn ws(&mut self) {
while self.s.get(self.i).is_some_and(|c| c.is_ascii_whitespace()) { while self.s.get(self.i).is_some_and(u8::is_ascii_whitespace) {
self.i += 1; self.i += 1;
} }
} }
@@ -165,6 +165,20 @@ pub fn parse(text: &str) -> Result<Expr, ExprError> {
if p.peek().is_some() { if p.peek().is_some() {
return Err(ExprError::Syntax(format!("unexpected `{}`", text.get(p.i..).unwrap_or("").trim()))); return Err(ExprError::Syntax(format!("unexpected `{}`", text.get(p.i..).unwrap_or("").trim())));
} }
let mut pending = vec![(&e, 1)];
while let Some((node, depth)) = pending.pop() {
if depth > MAX_DEPTH {
return Err(ExprError::TooDeep);
}
match node {
Expr::Neg(a) => pending.push((a, depth + 1)),
Expr::Bin(_, a, b) => {
pending.push((a, depth + 1));
pending.push((b, depth + 1));
}
_ => {}
}
}
Ok(e) Ok(e)
} }
@@ -224,12 +238,19 @@ pub fn rename(text: &str, old: &str, new: &str) -> String {
/// Evaluate with a variable lookup. /// Evaluate with a variable lookup.
pub fn eval_with(e: &Expr, lookup: &mut dyn FnMut(&str) -> Result<f64, ExprError>) -> Result<f64, ExprError> { pub fn eval_with(e: &Expr, lookup: &mut dyn FnMut(&str) -> Result<f64, ExprError>) -> Result<f64, ExprError> {
eval_at_depth(e, &mut |n, _| lookup(n), 0)
}
fn eval_at_depth(e: &Expr, lookup: &mut dyn FnMut(&str, usize) -> Result<f64, ExprError>, depth: usize) -> Result<f64, ExprError> {
if depth >= MAX_DEPTH {
return Err(ExprError::TooDeep);
}
let v = match e { let v = match e {
Expr::Num(v) => *v, Expr::Num(v) => *v,
Expr::Var(n) => lookup(n)?, Expr::Var(n) => lookup(n, depth + 1)?,
Expr::Neg(a) => -eval_with(a, lookup)?, Expr::Neg(a) => -eval_at_depth(a, lookup, depth + 1)?,
Expr::Bin(op, a, b) => { Expr::Bin(op, a, b) => {
let (x, y) = (eval_with(a, lookup)?, eval_with(b, lookup)?); let (x, y) = (eval_at_depth(a, lookup, depth + 1)?, eval_at_depth(b, lookup, depth + 1)?);
match op { match op {
'+' => x + y, '+' => x + y,
'-' => x - y, '-' => x - y,
@@ -273,9 +294,12 @@ impl Env {
/// Value of a named parameter. /// Value of a named parameter.
pub fn value(&mut self, name: &str) -> Result<f64, ExprError> { pub fn value(&mut self, name: &str) -> Result<f64, ExprError> {
let mut stack = Vec::new(); let mut stack = Vec::new();
self.value_inner(name, &mut stack) self.value_inner(name, &mut stack, 0)
} }
fn value_inner(&mut self, name: &str, stack: &mut Vec<String>) -> Result<f64, ExprError> { fn value_inner(&mut self, name: &str, stack: &mut Vec<String>, depth: usize) -> Result<f64, ExprError> {
if depth >= MAX_DEPTH {
return Err(ExprError::TooDeep);
}
if let Some(v) = self.cache.get(name) { if let Some(v) = self.cache.get(name) {
return Ok(*v); return Ok(*v);
} }
@@ -288,7 +312,7 @@ impl Env {
let text = self.exprs.get(name).cloned().ok_or_else(|| ExprError::Unknown(name.to_string()))?; let text = self.exprs.get(name).cloned().ok_or_else(|| ExprError::Unknown(name.to_string()))?;
let e = parse(&text)?; let e = parse(&text)?;
stack.push(name.to_string()); stack.push(name.to_string());
let r = eval_with(&e, &mut |n| self.value_inner(n, stack)); let r = eval_at_depth(&e, &mut |n, depth| self.value_inner(n, stack, depth), depth);
stack.pop(); stack.pop();
let v = r?; let v = r?;
self.cache.insert(name.to_string(), v); self.cache.insert(name.to_string(), v);
@@ -298,7 +322,7 @@ impl Env {
pub fn eval(&mut self, text: &str) -> Result<f64, ExprError> { pub fn eval(&mut self, text: &str) -> Result<f64, ExprError> {
let e = parse(text)?; let e = parse(text)?;
let mut stack = Vec::new(); let mut stack = Vec::new();
eval_with(&e, &mut |n| self.value_inner(n, &mut stack)) eval_at_depth(&e, &mut |n, depth| self.value_inner(n, &mut stack, depth), 0)
} }
} }
@@ -351,6 +375,31 @@ mod tests {
assert!(chain.value("p0").is_err()); assert!(chain.value("p0").is_err());
} }
#[test]
fn expression_and_parameter_depth_share_a_limit_on_a_small_stack() {
std::thread::Builder::new()
.stack_size(1024 * 1024)
.spawn(|| {
for op in ["+", "-", "*", "/"] {
let within = vec!["1"; MAX_DEPTH].join(op);
assert!(eval_const(&within).is_ok());
assert_eq!(parse(&format!("{within}{op}1")), Err(ExprError::TooDeep));
}
let mut env = Env::new();
for i in 0..MAX_DEPTH {
env.insert(&format!("p{i}"), &format!("p{}+{}", i + 1, vec!["0"; 30].join("+")));
}
env.insert(&format!("p{MAX_DEPTH}"), "1");
assert_eq!(env.value("p0"), Err(ExprError::TooDeep));
assert_eq!(env.eval("p0+1"), Err(ExprError::TooDeep));
env.insert("safe", "2*3");
assert_eq!(env.eval("safe+1"), Ok(7.0));
})
.unwrap()
.join()
.unwrap();
}
#[test] #[test]
fn rename_replaces_whole_names_only() { fn rename_replaces_whole_names_only() {
assert_eq!(rename("d1+d10*d1 / (d1_x+2e1)", "d1", "w"), "w+d10*w / (d1_x+2e1)"); assert_eq!(rename("d1+d10*d1 / (d1_x+2e1)", "d1", "w"), "w+d10*w / (d1_x+2e1)");
+208 -45
View File
@@ -81,6 +81,12 @@ pub struct SolveOptions {
pub keep: Vec<Handle>, pub keep: Vec<Handle>,
/// Entities that should take up the change. /// Entities that should take up the change.
pub prefer_move: Vec<Handle>, pub prefer_move: Vec<Handle>,
/// Points held where they are for this solve, as a Fix would hold them (the grip an edit
/// just put down): see [`edited_points`].
pub hold: Vec<GeomRef>,
/// Where a Fix on a derived point (by constraint id) holds it, instead of where the point is
/// now: see [`moved_anchors`].
pub anchors: Vec<(u32, Vec2)>,
} }
#[derive(Clone, Debug, Default, PartialEq, Serialize)] #[derive(Clone, Debug, Default, PartialEq, Serialize)]
@@ -205,12 +211,19 @@ pub(crate) struct Comp {
pub(crate) model: Model, pub(crate) model: Model,
pub(crate) built: Vec<Built>, pub(crate) built: Vec<Built>,
weights: Vec<f64>, weights: Vec<f64>,
/// Parameters of the points held by [`SolveOptions::hold`].
held: Vec<usize>,
tol: f64, tol: f64,
} }
impl Comp { impl Comp {
fn locks(&self, skip: Option<usize>) -> Vec<bool> { fn locks(&self, skip: Option<usize>) -> Vec<bool> {
let mut locked = vec![false; self.model.x0.len()]; let mut locked = vec![false; self.model.x0.len()];
for &j in &self.held {
if let Some(l) = locked.get_mut(j) {
*l = true;
}
}
for (i, b) in self.built.iter().enumerate() { for (i, b) in self.built.iter().enumerate() {
if Some(i) == skip || b.form != Form::Lock { if Some(i) == skip || b.form != Form::Lock {
continue; continue;
@@ -245,7 +258,30 @@ impl Comp {
return Err(SolveError::TooLarge); return Err(SolveError::TooLarge);
} }
let f = |x: &[f64], out: &mut Vec<f64>| self.eval(x, out, skip); let f = |x: &[f64], out: &mut Vec<f64>| self.eval(x, out, skip);
Ok(lm::solve(&f, &self.model.x0, &free, &self.weights, self.tol, MAX_ITER)) let mut o = lm::solve(&f, &self.model.x0, &free, &self.weights, self.tol, MAX_ITER);
// A solution the ellipses would be stored differently from (axes swapped, minor axis
// mirrored) holds only if the constraints still hold on the stored form: a constraint on
// an axis end by index does not.
if o.converged && !self.model.storable(&o.x) {
let mut out = Vec::new();
match self.model.stored_form(&o.x) {
Some(mut xc) => {
// Contact parameters are positions along the curves: they follow the stored form.
for b in self.built.iter().filter(|b| b.form == Form::Contact) {
for (k, r) in b.refs.iter().enumerate() {
if let (Some(&t), Some(slot)) = (o.x.get(b.aux + k), xc.get_mut(b.aux + k)) {
*slot = self.model.stored_t(&o.x, r.handle, t);
}
}
}
self.eval(&xc, &mut out, skip);
o.converged = lm::max_abs(&out) <= self.tol;
o.x = xc;
}
None => o.converged = false,
}
}
Ok(o)
} }
/// Constraint ids in conflict (each one whose removal makes the rest solvable). /// Constraint ids in conflict (each one whose removal makes the rest solvable).
fn conflicts(&self, x_failed: &[f64]) -> Vec<u32> { fn conflicts(&self, x_failed: &[f64]) -> Vec<u32> {
@@ -322,67 +358,98 @@ fn components<'a>(cons: &[&'a Constraint]) -> Vec<Vec<&'a Constraint>> {
groups.into_values().collect() groups.into_values().collect()
} }
/// The solver weight of each unknown of `model`: kept handles weigh more, preferred-to-move ones
/// less, angles and ratios by size², and contact parameters move freely.
fn solver_weights(model: &Model, opts: &SolveOptions) -> Vec<f64> {
let mut weights = vec![1.0; model.x0.len()];
for s in &model.slots {
let w = if opts.keep.contains(&s.handle) {
1e4
} else if opts.prefer_move.contains(&s.handle) {
1e-2
} else {
1.0
};
for j in s.off..s.off + s.len {
if let Some(slot) = weights.get_mut(j) {
*slot = w;
}
}
for (j, f) in slot_scales(model, s) {
if let Some(slot) = weights.get_mut(j) {
*slot *= f;
}
}
}
// Contact parameters move freely.
let geo = model.slots.iter().map(|s| s.off + s.len).max().unwrap_or(0);
for w in weights.iter_mut().skip(geo) {
*w = 1e-3;
}
weights
}
/// Per-unknown factors for the angles and ratios of one slot: size², so that the movement of
/// points is what is minimised.
fn slot_scales(model: &Model, s: &model::Slot) -> Vec<(usize, f64)> {
let sq = |j: usize| model.x0.get(j).map_or(1.0, |r| (r * r).max(1e-6));
match s.shape {
model::Shape::Arc => vec![(s.off + 3, sq(s.off + 2)), (s.off + 4, sq(s.off + 2))],
model::Shape::Ellipse { arc } => {
let a2 = (sq(s.off + 2) + sq(s.off + 3)).max(1e-6);
let mut v = vec![(s.off + 4, a2)];
if arc {
v.extend([(s.off + 5, a2), (s.off + 6, a2)]);
}
v
}
model::Shape::Insert { .. } => vec![(s.off + 2, model.rigid_extent2(s.handle).max(1.0))],
_ => Vec::new(),
}
}
/// Build the component systems. Constraints that cannot be interpreted are returned as skipped. /// Build the component systems. Constraints that cannot be interpreted are returned as skipped.
pub(crate) fn build(d: &Drawing, cons: &[&Constraint], opts: &SolveOptions) -> Result<(Vec<Comp>, Vec<(u32, String)>), SolveError> { pub(crate) fn build(d: &Drawing, cons: &[&Constraint], opts: &SolveOptions) -> Result<(Vec<Comp>, Vec<(u32, String)>), SolveError> {
let mut members_left = model::MAX_RIGID_WORK;
build_budgeted(d, cons, opts, &mut members_left)
}
/// Build constraint components with one caller-owned rigid-member sampling budget shared by every model.
/// Return constraints that cannot be interpreted as skipped entries.
pub(crate) fn build_budgeted(
d: &Drawing,
cons: &[&Constraint],
opts: &SolveOptions,
members_left: &mut usize,
) -> Result<(Vec<Comp>, Vec<(u32, String)>), SolveError> {
let mut e = env(d); let mut e = env(d);
let mut skipped = Vec::new(); let mut skipped = Vec::new();
let mut comps = Vec::new(); let mut comps = Vec::new();
for group in components(cons) { for group in components(cons) {
let handles: Vec<Handle> = group.iter().flat_map(|c| c.refs.iter().map(|r| r.handle)).collect(); let handles: Vec<Handle> = group.iter().flat_map(|c| c.refs.iter().map(|r| r.handle)).collect();
let mut model = Model::build(d, &handles); let mut model = Model::build_budgeted(d, &handles, members_left);
let mut built = Vec::new(); let mut built = Vec::new();
for c in group { for c in group {
let target = if c.kind.is_dimensional() { constraint_value(&mut e, c)? } else { 0.0 }; let target = if c.kind.is_dimensional() { constraint_value(&mut e, c)? } else { 0.0 };
match interpret(c, &mut model, target) { match interpret(c, &mut model, target) {
Ok(b) => built.push(b), Ok(mut b) => {
if let Form::Anchor(_) = b.form
&& let Some(&(_, a)) = opts.anchors.iter().find(|(id, _)| *id == c.id)
{
b.form = Form::Anchor(a);
}
built.push(b);
}
Err(msg) => skipped.push((c.id, msg)), Err(msg) => skipped.push((c.id, msg)),
} }
} }
if built.is_empty() { if built.is_empty() {
continue; continue;
} }
let mut weights = vec![1.0; model.x0.len()]; let weights = solver_weights(&model, opts);
for s in &model.slots { let held = opts.hold.iter().filter_map(|r| model.lock_indices(r)).flatten().collect();
let w = if opts.keep.contains(&s.handle) {
1e4
} else if opts.prefer_move.contains(&s.handle) {
1e-2
} else {
1.0
};
for j in s.off..s.off + s.len {
if let Some(slot) = weights.get_mut(j) {
*slot = w;
}
}
// Angles and ratios: weight by size² so the movement of points is what is minimised.
let sq = |j: usize| model.x0.get(j).map_or(1.0, |r| (r * r).max(1e-6));
let scaled: Vec<(usize, f64)> = match s.shape {
model::Shape::Arc => vec![(s.off + 3, sq(s.off + 2)), (s.off + 4, sq(s.off + 2))],
model::Shape::Ellipse { arc } => {
let a2 = (sq(s.off + 2) + sq(s.off + 3)).max(1e-6);
let mut v = vec![(s.off + 4, a2)];
if arc {
v.extend([(s.off + 5, a2), (s.off + 6, a2)]);
}
v
}
model::Shape::Insert { .. } => vec![(s.off + 2, model.rigid_extent2(s.handle).max(1.0))],
_ => Vec::new(),
};
for (j, f) in scaled {
if let Some(slot) = weights.get_mut(j) {
*slot *= f;
}
}
}
// Contact parameters move freely.
let geo = model.slots.iter().map(|s| s.off + s.len).max().unwrap_or(0);
for w in weights.iter_mut().skip(geo) {
*w = 1e-3;
}
let tol = scale_tol(&model.x0); let tol = scale_tol(&model.x0);
comps.push(Comp { model, built, weights, tol }); comps.push(Comp { model, built, weights, held, tol });
} }
Ok((comps, skipped)) Ok((comps, skipped))
} }
@@ -433,6 +500,9 @@ pub fn residuals(d: &Drawing) -> Vec<(u32, Option<f64>)> {
/// A Fix keeps no position of its own: the solver locks the parameters at their current values. /// A Fix keeps no position of its own: the solver locks the parameters at their current values.
/// Without this, an edit that moves a fixed point (a grip drag) would carry the lock along with /// Without this, an edit that moves a fixed point (a grip drag) would carry the lock along with
/// it. Only Fixes that already existed in `before`, on objects of the same shape, are restored. /// it. Only Fixes that already existed in `before`, on objects of the same shape, are restored.
/// An object held by a Fix on a derived point (a line's midpoint, an arc's end) has nothing to
/// lock: when the edit only moved it (MOVE), it is put back whole, so it is not reshaped to bring
/// the point back.
pub fn restore_fixed(d: &mut Drawing, before: &Drawing) -> bool { pub fn restore_fixed(d: &mut Drawing, before: &Drawing) -> bool {
let refs: Vec<GeomRef> = d let refs: Vec<GeomRef> = d
.constraints .constraints
@@ -449,10 +519,21 @@ pub fn restore_fixed(d: &mut Drawing, before: &Drawing) -> bool {
let mut changed = false; let mut changed = false;
for r in &refs { for r in &refs {
let same_shape = matches!((now.slot(r.handle), old.slot(r.handle)), (Some(a), Some(b)) if a.shape == b.shape); let same_shape = matches!((now.slot(r.handle), old.slot(r.handle)), (Some(a), Some(b)) if a.shape == b.shape);
let (Some(i), Some(j)) = (now.lock_indices(r), old.lock_indices(r)) else { continue };
if !same_shape { if !same_shape {
continue; continue;
} }
let (Some(i), Some(j)) = (now.lock_indices(r), old.lock_indices(r)) else {
if let (Some(a), Some(b)) = (now.slot(r.handle), old.slot(r.handle))
&& translated(&now, &old, r.handle)
&& let (Some(dst), Some(src)) = (x.get_mut(a.off..a.off + a.len), old.x0.get(b.off..b.off + b.len))
{
for (v, &w) in dst.iter_mut().zip(src) {
*v = w;
}
changed = true;
}
continue;
};
for (i, j) in i.into_iter().zip(j) { for (i, j) in i.into_iter().zip(j) {
if let (Some(slot), Some(&v)) = (x.get_mut(i), old.x0.get(j)) if let (Some(slot), Some(&v)) = (x.get_mut(i), old.x0.get(j))
&& *slot != v && *slot != v
@@ -468,6 +549,88 @@ pub fn restore_fixed(d: &mut Drawing, before: &Drawing) -> bool {
changed changed
} }
/// The parameters of `h`'s points (see [`Model::point_refs`]) in `m`, as (x, y) pairs.
fn point_params(m: &Model, h: Handle) -> Vec<(usize, usize)> {
m.point_refs(h)
.iter()
.filter_map(|r| m.lock_indices(r))
.flat_map(|ix| ix.as_chunks::<2>().0.iter().map(|&[x, y]| (x, y)).collect::<Vec<_>>())
.collect()
}
/// Is `h` in `now` its shape in `old` moved by one vector, and nothing else?
fn translated(now: &Model, old: &Model, h: Handle) -> bool {
let (Some(a), Some(b)) = (now.slot(h), old.slot(h)) else { return false };
let (Some(xa), Some(xb)) = (now.x0.get(a.off..a.off + a.len), old.x0.get(b.off..b.off + b.len)) else { return false };
let tol = scale_tol(xb) * 10.0;
let pts: Vec<(usize, usize)> =
point_params(old, h).into_iter().filter_map(|(i, j)| Some((i.checked_sub(b.off)?, j.checked_sub(b.off)?))).collect();
let delta = |(i, j): (usize, usize)| Some(Vec2::new(xa.get(i)? - xb.get(i)?, xa.get(j)? - xb.get(j)?));
let Some(v) = pts.first().and_then(|&p| delta(p)) else { return false };
if v.len() <= tol || !pts.iter().all(|&p| delta(p).is_some_and(|d| d.near(v, tol))) {
return false;
}
(0..xb.len())
.filter(|k| !pts.iter().any(|&(i, j)| *k == i || *k == j))
.all(|k| matches!((xa.get(k), xb.get(k)), (Some(p), Some(q)) if (p - q).abs() <= tol))
}
/// The points of `h` (its ends, centre or vertices) the edit from `before` to `d` moved, when it
/// moved some of them but not all: the grip that was dragged, to pass as [`SolveOptions::hold`] so
/// it stays where it was put and the rest of the object takes up the constraints. Empty when the
/// object kept its points or moved them all.
pub fn edited_points(d: &Drawing, before: &Drawing, h: Handle) -> Vec<GeomRef> {
let (now, old) = (Model::build(d, &[h]), Model::build(before, &[h]));
if !matches!((now.slot(h), old.slot(h)), (Some(a), Some(b)) if a.shape == b.shape) {
return Vec::new();
}
let tol = scale_tol(&old.x0) * 10.0;
let refs = now.point_refs(h);
let moved: Vec<GeomRef> = refs
.iter()
.filter(|r| matches!((now.lock_indices(r), old.lock_indices(r)), (Some(i), Some(j)) if i.iter().zip(&j).any(|(&i, &j)| matches!((now.x0.get(i), old.x0.get(j)), (Some(p), Some(q)) if (p - q).abs() > tol))))
.copied()
.collect();
if moved.len() == refs.len() { Vec::new() } else { moved }
}
/// Where `before` had each derived point held by a Fix (a line's midpoint, an arc's end), for the
/// Fixes whose point has moved since, to pass as [`SolveOptions::anchors`].
///
/// A Fix on a derived point anchors it where it is when the system is built, and
/// [`restore_fixed`] has no parameters to put back: without this, an edit that moves the point
/// would leave the Fix satisfied at the new place.
pub fn moved_anchors(d: &Drawing, before: &Drawing) -> Vec<(u32, Vec2)> {
let fixes: Vec<(u32, GeomRef)> = d
.constraints
.iter()
.filter(|c| c.kind == ConstraintKind::Fix && before.constraints.contains(c))
.filter_map(|c| match c.refs.as_slice() {
[r] => Some((c.id, *r)),
_ => None,
})
.collect();
if fixes.is_empty() {
return Vec::new();
}
let handles: Vec<Handle> = fixes.iter().map(|(_, r)| r.handle).collect();
let (now, old) = (Model::build(d, &handles), Model::build(before, &handles));
let tol = scale_tol(&old.x0);
let mut out = Vec::new();
for (id, r) in fixes {
let same_shape = matches!((now.slot(r.handle), old.slot(r.handle)), (Some(a), Some(b)) if a.shape == b.shape);
if !same_shape || now.lock_indices(&r).is_some() {
continue;
}
if let (Some(p), Some(a)) = (now.point(&now.x0, &r), old.point(&old.x0, &r))
&& !p.near(a, tol)
{
out.push((id, a));
}
}
out
}
/// True when every interpretable constraint holds at the current geometry. /// True when every interpretable constraint holds at the current geometry.
pub fn all_satisfied(d: &Drawing) -> bool { pub fn all_satisfied(d: &Drawing) -> bool {
let handles: Vec<Handle> = d.constraints.iter().flat_map(|c| c.refs.iter().map(|r| r.handle)).collect(); let handles: Vec<Handle> = d.constraints.iter().flat_map(|c| c.refs.iter().map(|r| r.handle)).collect();
+237 -65
View File
@@ -27,6 +27,13 @@ pub const MAX_PARAMS: usize = 4000;
pub const MAX_SPLINE_POINTS: usize = 500; pub const MAX_SPLINE_POINTS: usize = 500;
/// Most characteristic points of a block reference used as rigid constraint points. /// Most characteristic points of a block reference used as rigid constraint points.
pub const MAX_RIGID_POINTS: usize = 256; pub const MAX_RIGID_POINTS: usize = 256;
/// Block members examined when sampling rigid points, counting those that contribute none, so a
/// block padded with point-less entities cannot make sampling (repeated per Insert) unbounded.
const MAX_RIGID_MEMBERS: usize = 100_000;
/// Shared across every model built for one analysis request.
pub(crate) const MAX_RIGID_WORK: usize = 1_000_000;
/// How far past 1 a solved ellipse ratio may land by rounding and still be stored as 1.
const RATIO_TOL: f64 = 1e-9;
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Shape { pub(crate) enum Shape {
@@ -74,6 +81,21 @@ pub(crate) struct Model {
rigid: HashMap<Handle, Vec<Vec2>>, rigid: HashMap<Handle, Vec<Vec2>>,
} }
impl Model {
/// Work for a spline that an endpoint residual actually reconstructs. Fit interpolation
/// uses dense Gaussian elimination (cubic); control splines clone and evaluate their template.
pub(crate) fn spline_reconstruct_cost(&self, handle: Handle) -> f64 {
let Some(s) = self.splines.get(&handle) else { return 0.0 };
let n = s.fit.len() as f64;
if fit_mode(s) {
n * n * n
} else {
let n = s.control.len() as f64;
n * n
}
}
}
/// Can this entity take part in constraints? /// Can this entity take part in constraints?
pub fn supported(kind: &EntityKind) -> bool { pub fn supported(kind: &EntityKind) -> bool {
matches!( matches!(
@@ -93,13 +115,26 @@ pub fn supported(kind: &EntityKind) -> bool {
/// centres, vertices, nested insertion points) of the block's own objects, at most /// centres, vertices, nested insertion points) of the block's own objects, at most
/// [`MAX_RIGID_POINTS`]. Nested blocks are not entered. /// [`MAX_RIGID_POINTS`]. Nested blocks are not entered.
pub fn rigid_points(d: &Drawing, ins: &Insert) -> Vec<Vec2> { pub fn rigid_points(d: &Drawing, ins: &Insert) -> Vec<Vec2> {
let mut members_left = MAX_RIGID_MEMBERS;
rigid_points_budgeted(d, ins, &mut members_left)
}
/// Sample finite block points within per-insert and caller-shared member limits.
/// Members that contribute no points still consume traversal work; nested blocks are not entered.
fn rigid_points_budgeted(d: &Drawing, ins: &Insert, members_left: &mut usize) -> Vec<Vec2> {
let Some(block) = d.block(&ins.block) else { return Vec::new() }; let Some(block) = d.block(&ins.block) else { return Vec::new() };
let m = ins.transform(block.base.xy()); let m = ins.transform(block.base.xy());
let mut out = Vec::new(); let mut out = Vec::new();
// Bound the members examined, not only the points collected: entities that contribute no
// points (Text, Ray, …) still cost traversal, so a block full of them — scanned again for
// every Insert — would otherwise be unbounded (CWE-407).
let mut examined = 0usize;
for e in block.entities.iter() { for e in block.entities.iter() {
if out.len() >= MAX_RIGID_POINTS { examined += 1;
if out.len() >= MAX_RIGID_POINTS || examined > MAX_RIGID_MEMBERS || *members_left == 0 {
break; break;
} }
*members_left -= 1;
let pts: Vec<Vec2> = match &e.kind { let pts: Vec<Vec2> = match &e.kind {
EntityKind::Line(l) => vec![l.a.xy(), l.b.xy()], EntityKind::Line(l) => vec![l.a.xy(), l.b.xy()],
EntityKind::Arc(a) => { EntityKind::Arc(a) => {
@@ -152,6 +187,83 @@ fn fit_mode(s: &GSpline) -> bool {
impl Model { impl Model {
/// Build a model for the given entities. Unsupported or missing entities are skipped. /// Build a model for the given entities. Unsupported or missing entities are skipped.
pub fn build(d: &Drawing, handles: &[Handle]) -> Model { pub fn build(d: &Drawing, handles: &[Handle]) -> Model {
let mut members_left = MAX_RIGID_WORK;
Self::build_budgeted(d, handles, &mut members_left)
}
/// Add the parameters of one supported entity to `m`, and return its shape; `None` when it is
/// left out (too large, degenerate or unsupported).
fn add_params(m: &mut Model, d: &Drawing, h: Handle, kind: &EntityKind, off: usize, members_left: &mut usize) -> Option<Shape> {
let shape = match kind {
EntityKind::Line(l) => {
m.x0.extend([l.a.x, l.a.y, l.b.x, l.b.y]);
Shape::Line
}
EntityKind::Circle(c) => {
m.x0.extend([c.center.x, c.center.y, c.radius]);
Shape::Circle
}
EntityKind::Arc(a) => {
m.x0.extend([a.center.x, a.center.y, a.radius, a.start, a.end]);
Shape::Arc
}
EntityKind::LwPolyline(p) => {
if p.vertices.len() < 2 || off + p.vertices.len() * 2 > MAX_PARAMS {
return None;
}
for v in &p.vertices {
m.x0.extend([v.p.x, v.p.y]);
}
Shape::Poly { n: p.vertices.len(), closed: p.closed }
}
EntityKind::Point(p) => {
m.x0.extend([p.p.x, p.p.y]);
Shape::Point
}
EntityKind::Ellipse(el) => {
let ell = cadcraft_geom::Ellipse { center: el.center.xy(), major: el.major.xy(), ratio: el.ratio, start: el.start, end: el.end };
let arc = !ell.is_full();
m.x0.extend([el.center.x, el.center.y, el.major.x, el.major.y, el.ratio]);
if arc {
m.x0.extend([el.start, el.end]);
}
Shape::Ellipse { arc }
}
EntityKind::Spline(sp) => {
let fit = fit_mode(sp);
let pts = if fit { &sp.fit } else { &sp.control };
let n = pts.len();
if !(2..=MAX_SPLINE_POINTS).contains(&n) || off + n * 2 > MAX_PARAMS || (!fit && !sp.is_valid()) {
return None;
}
for p in pts {
m.x0.extend([p.x, p.y]);
}
// Fit points interpolate exactly, so the curve's ends are the first and last
// fit point; a control polygon's ends are its first and last points when the
// knots are clamped.
let ends = !sp.closed && (fit || (clamped(sp, false) && clamped(sp, true)));
// Missing weights default to 1 in eval; only excess weights are unused.
let mut kept = sp.clone();
kept.weights.truncate(kept.control.len());
m.splines.insert(h, kept);
Shape::Spline { n, fit, ends }
}
EntityKind::Insert(ins) => {
let base = Vec2::new(ins.insert.x, ins.insert.y);
let pts: Vec<Vec2> = rigid_points_budgeted(d, ins, members_left).into_iter().map(|p| (p - base).rotate(-ins.rotation)).collect();
m.x0.extend([ins.insert.x, ins.insert.y, ins.rotation]);
let n = pts.len();
m.rigid.insert(h, pts);
Shape::Insert { n }
}
_ => return None,
};
Some(shape)
}
/// Build solver parameters for supported entities while sharing rigid-member sampling work with other models.
pub(crate) fn build_budgeted(d: &Drawing, handles: &[Handle], members_left: &mut usize) -> Model {
let mut m = Model::default(); let mut m = Model::default();
for &h in handles { for &h in handles {
if m.index.contains_key(&h) { if m.index.contains_key(&h) {
@@ -159,68 +271,7 @@ impl Model {
} }
let Some(e) = d.entity(h) else { continue }; let Some(e) = d.entity(h) else { continue };
let off = m.x0.len(); let off = m.x0.len();
let shape = match &e.kind { let Some(shape) = Self::add_params(&mut m, d, h, &e.kind, off, members_left) else { continue };
EntityKind::Line(l) => {
m.x0.extend([l.a.x, l.a.y, l.b.x, l.b.y]);
Shape::Line
}
EntityKind::Circle(c) => {
m.x0.extend([c.center.x, c.center.y, c.radius]);
Shape::Circle
}
EntityKind::Arc(a) => {
m.x0.extend([a.center.x, a.center.y, a.radius, a.start, a.end]);
Shape::Arc
}
EntityKind::LwPolyline(p) => {
if p.vertices.len() < 2 || off + p.vertices.len() * 2 > MAX_PARAMS {
continue;
}
for v in &p.vertices {
m.x0.extend([v.p.x, v.p.y]);
}
Shape::Poly { n: p.vertices.len(), closed: p.closed }
}
EntityKind::Point(p) => {
m.x0.extend([p.p.x, p.p.y]);
Shape::Point
}
EntityKind::Ellipse(el) => {
let ell = cadcraft_geom::Ellipse { center: el.center.xy(), major: el.major.xy(), ratio: el.ratio, start: el.start, end: el.end };
let arc = !ell.is_full();
m.x0.extend([el.center.x, el.center.y, el.major.x, el.major.y, el.ratio]);
if arc {
m.x0.extend([el.start, el.end]);
}
Shape::Ellipse { arc }
}
EntityKind::Spline(sp) => {
let fit = fit_mode(sp);
let pts = if fit { &sp.fit } else { &sp.control };
let n = pts.len();
if !(2..=MAX_SPLINE_POINTS).contains(&n) || off + n * 2 > MAX_PARAMS || (!fit && !sp.is_valid()) {
continue;
}
for p in pts {
m.x0.extend([p.x, p.y]);
}
// Fit points interpolate exactly, so the curve's ends are the first and last
// fit point; a control polygon's ends are its first and last points when the
// knots are clamped.
let ends = !sp.closed && (fit || (clamped(sp, false) && clamped(sp, true)));
m.splines.insert(h, sp.clone());
Shape::Spline { n, fit, ends }
}
EntityKind::Insert(ins) => {
let base = Vec2::new(ins.insert.x, ins.insert.y);
let pts: Vec<Vec2> = rigid_points(d, ins).into_iter().map(|p| (p - base).rotate(-ins.rotation)).collect();
m.x0.extend([ins.insert.x, ins.insert.y, ins.rotation]);
let n = pts.len();
m.rigid.insert(h, pts);
Shape::Insert { n }
}
_ => continue,
};
if m.x0.len() > MAX_PARAMS || m.x0.get(off..).unwrap_or(&[]).iter().any(|v| !v.is_finite()) { if m.x0.len() > MAX_PARAMS || m.x0.get(off..).unwrap_or(&[]).iter().any(|v| !v.is_finite()) {
m.x0.truncate(off); m.x0.truncate(off);
continue; continue;
@@ -435,6 +486,18 @@ impl Model {
} }
} }
/// The points of an object a Fix locks directly (ends, centre, vertices, insertion point).
pub fn point_refs(&self, h: Handle) -> Vec<GeomRef> {
let Some(s) = self.slot(h) else { return Vec::new() };
let at = |sub: Sub| GeomRef { handle: h, sub };
match s.shape {
Shape::Line => vec![at(Sub::Start), at(Sub::End)],
Shape::Point => vec![at(Sub::Whole)],
Shape::Circle | Shape::Arc | Shape::Ellipse { .. } | Shape::Insert { .. } => vec![at(Sub::Center)],
Shape::Poly { n, .. } | Shape::Spline { n, .. } => (0..n).filter_map(|i| u32::try_from(i).ok()).map(|i| at(Sub::Vertex(i))).collect(),
}
}
/// Parameters a Fix on this reference locks directly. `None` when the reference is a derived /// Parameters a Fix on this reference locks directly. `None` when the reference is a derived
/// point (fixed through a residual instead). /// point (fixed through a residual instead).
pub fn lock_indices(&self, r: &GeomRef) -> Option<Vec<usize>> { pub fn lock_indices(&self, r: &GeomRef) -> Option<Vec<usize>> {
@@ -462,6 +525,57 @@ impl Model {
Some(v) Some(v)
} }
/// Can `write_back` store `x` as it is? An ellipse keeps 0 < ratio <= 1, its major axis the
/// longer one: a solution past that is stored with its axes swapped or its minor axis
/// mirrored (see [`Model::stored_form`]), which renumbers the axis ends.
pub fn storable(&self, x: &[f64]) -> bool {
self.slots.iter().all(|s| match s.shape {
Shape::Ellipse { .. } => x.get(s.off + 4).is_some_and(|r| *r > 0.0 && *r <= 1.0 + RATIO_TOL),
_ => true,
})
}
/// `x` with every ellipse re-expressed as [`Model::write_back`] stores it (the same curve with
/// the major axis the longer one, a full ellipse's negative ratio made positive), so the
/// constraints can be checked against what would be stored. `None` when an elliptical arc has
/// a negative ratio or an ellipse a zero one.
pub fn stored_form(&self, x: &[f64]) -> Option<Vec<f64>> {
let mut xc = x.to_vec();
for s in &self.slots {
let Shape::Ellipse { arc } = s.shape else { continue };
let o = s.off;
let mut ratio = *x.get(o + 4)?;
if ratio < 0.0 && !arc {
ratio = -ratio;
}
if ratio <= 0.0 {
return None;
}
if ratio > 1.0 + RATIO_TOL {
let major = p2(x, o + 2)?.perp() * ratio;
*xc.get_mut(o + 2)? = major.x;
*xc.get_mut(o + 3)? = major.y;
ratio = 1.0 / ratio;
if arc {
*xc.get_mut(o + 5)? -= std::f64::consts::FRAC_PI_2;
*xc.get_mut(o + 6)? -= std::f64::consts::FRAC_PI_2;
}
}
*xc.get_mut(o + 4)? = ratio;
}
Some(xc)
}
/// The parameter `stored_form` gives the point at parameter `t` on ellipse `h` (unchanged for
/// any other object): `-t` where a negative ratio was made positive, a quarter turn back where
/// the axes were swapped.
pub fn stored_t(&self, x: &[f64], h: Handle, t: f64) -> f64 {
let Some(s) = self.slot(h) else { return t };
let (Shape::Ellipse { .. }, Some(&ratio)) = (s.shape, x.get(s.off + 4)) else { return t };
let (t, ratio) = if ratio < 0.0 { (-t, -ratio) } else { (t, ratio) };
if ratio > 1.0 + RATIO_TOL { t - std::f64::consts::FRAC_PI_2 } else { t }
}
/// Write solved parameters back. Returns the handles that moved. /// Write solved parameters back. Returns the handles that moved.
pub fn write_back(&self, d: &mut Drawing, x: &[f64]) -> Vec<Handle> { pub fn write_back(&self, d: &mut Drawing, x: &[f64]) -> Vec<Handle> {
let mut moved = Vec::new(); let mut moved = Vec::new();
@@ -530,8 +644,9 @@ impl Model {
[a, b] if arc => (*a, *b), [a, b] if arc => (*a, *b),
_ => (el.start, el.end), _ => (el.start, el.end),
}; };
// Keep the stored form: the major axis is the longer one. // Keep the stored form: the major axis is the longer one. `storable` lets a
if ratio > 1.0 { // solved ratio exceed 1 only by rounding: that is a circle, not a swap.
if ratio > 1.0 + RATIO_TOL {
major = major.perp() * ratio; major = major.perp() * ratio;
ratio = 1.0 / ratio; ratio = 1.0 / ratio;
if arc { if arc {
@@ -573,3 +688,60 @@ impl Model {
moved moved
} }
} }
#[cfg(test)]
mod budget_tests {
use super::*;
use cadcraft_doc::{Block, Common, Entity, Line, Space};
use cadcraft_geom::Vec3;
use std::sync::Arc;
/// Writing an unchanged model back leaves partial rational weights and the original spline geometry intact.
#[test]
fn write_back_keeps_partial_rational_weights() {
let mut drawing = Drawing::default();
let mut spline = GSpline::from_control(vec![Vec2::ZERO, Vec2::new(1.0, 2.0), Vec2::new(3.0, 0.0)], 2);
spline.weights = vec![2.0, 0.5];
let h = drawing.add(&Space::Model, Common::default(), EntityKind::Spline(spline.clone())).unwrap();
let model = Model::build(&drawing, &[h]);
model.write_back(&mut drawing, &model.x0);
let EntityKind::Spline(after) = &drawing.entity(h).unwrap().kind else { panic!("spline") };
assert_eq!(after.weights, spline.weights);
for i in 0..=10 {
assert!(after.eval(i as f64 / 10.0).near(spline.eval(i as f64 / 10.0), 1e-12));
}
}
/// A second model receives no sampled block points after the first model spends the shared member budget.
#[test]
fn rigid_sampling_budget_is_shared_between_models() {
let mut d = Drawing::default();
let mut block = Block::new("BAR");
for _ in 0..3 {
block.entities.push(Entity {
handle: d.new_handle(),
common: Common::default(),
kind: EntityKind::Line(Line { a: Vec3::ZERO, b: Vec3::new(1.0, 0.0, 0.0) }),
});
}
d.blocks.insert("BAR".into(), Arc::new(block));
let ins = Insert {
block: "BAR".into(),
insert: Vec3::ZERO,
scale: Vec3::new(1.0, 1.0, 1.0),
rotation: 0.0,
attribs: Vec::new(),
cols: 1,
rows: 1,
col_spacing: 0.0,
row_spacing: 0.0,
};
let h = d.add(&Space::Model, Common::default(), EntityKind::Insert(ins)).unwrap();
let mut budget = 2;
let first = Model::build_budgeted(&d, &[h], &mut budget);
assert_eq!(first.rigid[&h].len(), 4);
assert_eq!(budget, 0);
let next = Model::build_budgeted(&d, &[h], &mut budget);
assert!(next.rigid[&h].is_empty());
}
}
+351 -185
View File
@@ -3,7 +3,7 @@
use cadcraft_doc::{Constraint, ConstraintKind, DistAxis, GeomRef}; use cadcraft_doc::{Constraint, ConstraintKind, DistAxis, GeomRef};
use cadcraft_geom::Vec2; use cadcraft_geom::Vec2;
use crate::curves::circle_at; use crate::curves::{Ell, circle_at};
use crate::model::Model; use crate::model::Model;
/// Contact parameter samples per curve when guessing where two curves touch. /// Contact parameter samples per curve when guessing where two curves touch.
@@ -116,6 +116,16 @@ fn r_at(c: &Constraint, i: usize) -> Option<&GeomRef> {
} }
impl Built { impl Built {
/// Estimate spline reconstruction work for endpoint and join residuals; other residual forms add no such cost.
pub(crate) fn spline_reconstruct_cost(&self, m: &Model) -> f64 {
let cost = |i: usize| self.refs.get(i).map_or(0.0, |r| m.spline_reconstruct_cost(r.handle));
match self.form {
Form::EndOn { spline, .. } => cost(spline),
Form::Join { .. } => cost(0) + cost(1),
_ => 0.0,
}
}
/// Interpret a constraint against the model at `x`. `target` is the evaluated value for /// Interpret a constraint against the model at `x`. `target` is the evaluated value for
/// dimensional constraints (degrees for angular). Errors explain what references are needed. /// dimensional constraints (degrees for angular). Errors explain what references are needed.
pub fn new(c: &Constraint, m: &Model, x: &[f64], target: f64) -> Result<Built, String> { pub fn new(c: &Constraint, m: &Model, x: &[f64], target: f64) -> Result<Built, String> {
@@ -195,42 +205,7 @@ impl Built {
}; };
b.rows = 1; b.rows = 1;
} }
K::Tangent | K::Smooth => { K::Tangent | K::Smooth => return Self::tangent_form(b, c, m, x),
if n != 2 {
return need("two objects");
}
if c.refs.iter().any(|r| m.is_spline(r)) {
return Self::spline_form(b, m, x);
}
b.rows = 1;
if let (Some((a, bb)), Some((cc, _))) = (ln(0), ci(1)) {
b.form = Form::LC { swap: false };
b.sign = sgn(dir_n(a, bb).cross(cc - a));
} else if let (Some((a, bb)), Some((cc, _))) = (ln(1), ci(0)) {
b.form = Form::LC { swap: true };
b.sign = sgn(dir_n(a, bb).cross(cc - a));
} else if let (Some((c1, r1)), Some((c2, r2))) = (ci(0), ci(1)) {
b.form = Form::CC;
// sign > 0: external tangency; < 0: internal.
let d = c1.dist(c2);
b.sign = if d >= r1.abs().max(r2.abs()) { 1.0 } else { -1.0 };
b.target = sgn(r1 - r2);
} else if let (Some((a, bb)), Some(e)) = (ln(0), el(1)) {
b.form = Form::LE { swap: false };
b.sign = sgn(dir_n(a, bb).cross(e.c - a));
} else if let (Some((a, bb)), Some(e)) = (ln(1), el(0)) {
b.form = Form::LE { swap: true };
b.sign = sgn(dir_n(a, bb).cross(e.c - a));
} else if (ci(0).is_some() || el(0).is_some()) && (ci(1).is_some() || el(1).is_some()) {
b.form = Form::Contact;
b.rows = 3;
b.aux_init = b.contact_guess(m, x).ok_or_else(|| format!("{} could not find where the curves touch", c.kind.name()))?;
} else if ln(0).is_some() && ln(1).is_some() && c.kind == K::Smooth {
b.form = Form::LL;
} else {
return need("a line and a circle/arc/ellipse, two curves, or a spline end and a curve");
}
}
K::Symmetric => { K::Symmetric => {
if n != 3 || ln(2).is_none() { if n != 3 || ln(2).is_none() {
return need("two objects and a symmetry line"); return need("two objects and a symmetry line");
@@ -337,154 +312,8 @@ impl Built {
out.resize(start + self.rows, 0.0); out.resize(start + self.rows, 0.0);
} }
fn eval_inner(&self, m: &Model, x: &[f64], out: &mut Vec<f64>) -> Option<()> { fn eval_inner(&self, model: &Model, values: &[f64], out: &mut Vec<f64>) -> Option<()> {
use ConstraintKind as K; ResidualEval { built: self, model, values, out }.evaluate()
let r = |i: usize| self.refs.get(i);
let pt = |i: usize| m.point(x, r(i)?);
let ln = |i: usize| m.line(x, r(i)?);
let ci = |i: usize| m.circle(x, r(i)?);
let el = |i: usize| m.ellipse(x, r(i)?);
match (self.kind, self.form) {
(_, Form::Lock) => {}
(_, Form::PE { swap }) => {
let (p, e) = if swap { (pt(1)?, el(0)?) } else { (pt(0)?, el(1)?) };
out.push(e.on(p));
}
(_, Form::LE { swap }) => {
let ((a, b), e) = if swap { (ln(1)?, el(0)?) } else { (ln(0)?, el(1)?) };
let d = dir_n(a, b);
out.push(self.sign * d.cross(e.c - a) - e.support(d.perp()));
}
(_, Form::Contact) => {
let (p0, d0) = self.curve_at(m, x, 0, *x.get(self.aux)?)?;
let (p1, d1) = self.curve_at(m, x, 1, *x.get(self.aux + 1)?)?;
out.extend([p0.x - p1.x, p0.y - p1.y, d0.normalized().cross(d1.normalized())]);
}
(_, Form::EndOn { spline, at_end }) => {
let f = m.end_frame(x, r(spline)?, at_end)?;
let o = 1 - spline.min(1);
if let Some((a, b)) = ln(o) {
let d = dir_n(a, b);
out.extend([d.cross(f.p - a), f.t.cross(d)]);
} else if let Some((c, rad)) = ci(o) {
out.extend([f.p.dist(c) - rad, f.t.dot(dir_n(c, f.p))]);
} else {
let e = el(o)?;
out.extend([e.on(f.p), f.t.dot(e.normal(f.p))]);
}
}
(_, Form::Join { a_end, b_end, g2 }) => {
// Orient both so the first curve runs into the joint and the second out of it.
let fa = m.end_frame(x, r(0)?, a_end)?;
let fb = m.end_frame(x, r(1)?, b_end)?;
let fa = if a_end { fa } else { fa.reversed() };
let fb = if b_end { fb.reversed() } else { fb };
out.extend([fa.p.x - fb.p.x, fa.p.y - fb.p.y, fa.t.cross(fb.t)]);
if g2 {
out.push(fa.k - fb.k);
}
}
(_, Form::Anchor(a)) => {
let p = pt(0)?;
out.extend([p.x - a.x, p.y - a.y]);
}
(K::Coincident, Form::PP) => {
let (p, q) = (pt(0)?, pt(1)?);
out.extend([p.x - q.x, p.y - q.y]);
}
(K::Coincident, Form::PL { swap }) => {
let (p, (a, b)) = if swap { (pt(1)?, ln(0)?) } else { (pt(0)?, ln(1)?) };
out.push(dir_n(a, b).cross(p - a));
}
(K::Coincident, Form::PC { swap }) => {
let (p, (c, rad)) = if swap { (pt(1)?, ci(0)?) } else { (pt(0)?, ci(1)?) };
out.push(p.dist(c) - rad);
}
(K::Horizontal | K::Vertical, f) => {
let (a, b) = if f == Form::L1 { ln(0).or_else(|| el(0).map(|e| (e.c - e.mj, e.c + e.mj)))? } else { (pt(0)?, pt(1)?) };
out.push(if self.kind == K::Horizontal { a.y - b.y } else { a.x - b.x });
}
(K::Parallel | K::Smooth, Form::LL) => {
let ((a1, b1), (a2, b2)) = (ln(0)?, ln(1)?);
out.push(dir_n(a1, b1).cross(dir_n(a2, b2)));
}
(K::Perpendicular, _) => {
let ((a1, b1), (a2, b2)) = (ln(0)?, ln(1)?);
out.push(dir_n(a1, b1).dot(dir_n(a2, b2)));
}
(K::Collinear, _) => {
let ((a1, b1), (a2, b2)) = (ln(0)?, ln(1)?);
let d = dir_n(a1, b1);
out.extend([d.cross(a2 - a1), d.cross(b2 - a1)]);
}
(K::Concentric, _) => {
let center = |i: usize| ci(i).map(|(c, _)| c).or_else(|| el(i).map(|e| e.c)).or_else(|| pt(i));
let (p, q) = (center(0)?, center(1)?);
out.extend([p.x - q.x, p.y - q.y]);
}
(K::Equal, Form::LL) => {
let ((a1, b1), (a2, b2)) = (ln(0)?, ln(1)?);
out.push(a1.dist(b1) - a2.dist(b2));
}
(K::Equal, _) => {
let ((_, r1), (_, r2)) = (ci(0)?, ci(1)?);
out.push(r1 - r2);
}
(K::Tangent | K::Smooth, Form::LC { swap }) => {
let ((a, b), (c, rad)) = if swap { (ln(1)?, ci(0)?) } else { (ln(0)?, ci(1)?) };
out.push(self.sign * dir_n(a, b).cross(c - a) - rad);
}
(K::Tangent | K::Smooth, _) => {
let ((c1, r1), (c2, r2)) = (ci(0)?, ci(1)?);
let d = c1.dist(c2);
out.push(if self.sign > 0.0 { d - (r1 + r2) } else { d - self.target * (r1 - r2) });
}
(K::Symmetric, Form::PP) => {
let (p, q, (a, b)) = (pt(0)?, pt(1)?, ln(2)?);
let d = dir_n(a, b);
out.extend([d.cross(p.mid(q) - a), d.dot(q - p)]);
}
(K::Symmetric, Form::SymLines { crossed }) => {
let ((a1, b1), (a2, b2), (a, b)) = (ln(0)?, ln(1)?, ln(2)?);
let (a2, b2) = if crossed { (b2, a2) } else { (a2, b2) };
let d = dir_n(a, b);
out.extend([d.cross(a1.mid(a2) - a), d.dot(a2 - a1), d.cross(b1.mid(b2) - a), d.dot(b2 - b1)]);
}
(K::Symmetric, _) => {
let ((p, r1), (q, r2), (a, b)) = (ci(0)?, ci(1)?, ln(2)?);
let d = dir_n(a, b);
out.extend([d.cross(p.mid(q) - a), d.dot(q - p), r1 - r2]);
}
(K::Distance(axis), f) => {
let v = match (axis, f) {
(_, Form::PL { swap }) => {
let (p, (a, b)) = if swap { (pt(1)?, ln(0)?) } else { (pt(0)?, ln(1)?) };
dir_n(a, b).cross(p - a)
}
_ => {
let (a, b) = if f == Form::L1 { ln(0)? } else { (pt(0)?, pt(1)?) };
match axis {
DistAxis::Aligned => a.dist(b),
DistAxis::Horizontal => b.x - a.x,
DistAxis::Vertical => b.y - a.y,
}
}
};
out.push(self.sign * v - self.target);
}
(K::Angular, Form::ArcSweep) => {
out.push(m.arc_sweep(x, r(0)?)? - self.target);
}
(K::Angular, Form::Rays { rev }) => {
let ((a1, b1), (a2, b2)) = (ln(0)?, ln(1)?);
let (d1, d2) = (ray(a1, b1, rev[0]), ray(a2, b2, rev[1]));
out.push(wrap(d1.cross(d2).atan2(d1.dot(d2)) - self.sign * self.target));
}
(K::Radius, _) => out.push(ci(0)?.1 - self.target),
(K::Diameter, _) => out.push(2.0 * ci(0)?.1 - self.target),
_ => {}
}
Some(())
} }
/// Point and derivative of curve reference `i` (circle, arc or ellipse) at parameter `t`. /// Point and derivative of curve reference `i` (circle, arc or ellipse) at parameter `t`.
@@ -517,6 +346,54 @@ impl Built {
/// Tangent or Smooth with a spline: the spline end and the other curve's nearest end are /// Tangent or Smooth with a spline: the spline end and the other curve's nearest end are
/// joined (Smooth: G2; Tangent between splines: G1), or the spline's end touches a line, /// joined (Smooth: G2; Tangent between splines: G1), or the spline's end touches a line,
/// circle, arc or ellipse tangentially. A `start`/`end` reference picks the end. /// circle, arc or ellipse tangentially. A `start`/`end` reference picks the end.
fn tangent_form(mut built: Built, constraint: &Constraint, model: &Model, values: &[f64]) -> Result<Built, String> {
use ConstraintKind as K;
let need = |what: &str| Err(format!("{} needs {what}", constraint.kind.name()));
if built.refs.len() != 2 {
return need("two objects");
}
if constraint.refs.iter().any(|reference| model.is_spline(reference)) {
return Self::spline_form(built, model, values);
}
let line = |index| r_at(constraint, index).and_then(|reference| model.line(values, reference));
let circle = |index| r_at(constraint, index).and_then(|reference| model.circle(values, reference));
let ellipse = |index| r_at(constraint, index).and_then(|reference| model.ellipse(values, reference));
built.rows = 1;
if let (Some((start, end)), Some((center, _))) = (line(0), circle(1)) {
built.form = Form::LC { swap: false };
built.sign = sgn(dir_n(start, end).cross(center - start));
} else if let (Some((start, end)), Some((center, _))) = (line(1), circle(0)) {
built.form = Form::LC { swap: true };
built.sign = sgn(dir_n(start, end).cross(center - start));
} else if let (Some((first_center, first_radius)), Some((second_center, second_radius))) = (circle(0), circle(1)) {
built.form = Form::CC;
// sign > 0: external tangency; < 0: internal.
let distance = first_center.dist(second_center);
built.sign = if distance >= first_radius.abs().max(second_radius.abs()) { 1.0 } else { -1.0 };
built.target = sgn(first_radius - second_radius);
} else if let (Some((start, end)), Some(ellipse)) = (line(0), ellipse(1)) {
built.form = Form::LE { swap: false };
built.sign = sgn(dir_n(start, end).cross(ellipse.c - start));
} else if let (Some((start, end)), Some(ellipse)) = (line(1), ellipse(0)) {
built.form = Form::LE { swap: true };
built.sign = sgn(dir_n(start, end).cross(ellipse.c - start));
} else if (circle(0).is_some() || ellipse(0).is_some()) && (circle(1).is_some() || ellipse(1).is_some()) {
built.form = Form::Contact;
built.rows = 3;
built.aux_init =
built.contact_guess(model, values).ok_or_else(|| format!("{} could not find where the curves touch", constraint.kind.name()))?;
} else if line(0).is_some() && line(1).is_some() && constraint.kind == K::Smooth {
built.form = Form::LL;
} else {
return need("a line and a circle/arc/ellipse, two curves, or a spline end and a curve");
}
Ok(built)
}
fn spline_form(mut b: Built, m: &Model, x: &[f64]) -> Result<Built, String> { fn spline_form(mut b: Built, m: &Model, x: &[f64]) -> Result<Built, String> {
let (Some(r0), Some(r1)) = (b.refs.first().copied(), b.refs.get(1).copied()) else { let (Some(r0), Some(r1)) = (b.refs.first().copied(), b.refs.get(1).copied()) else {
return Err(format!("{} needs two objects", b.kind.name())); return Err(format!("{} needs two objects", b.kind.name()));
@@ -586,3 +463,292 @@ impl Built {
if self.kind == ConstraintKind::Angular { v.to_degrees() } else { v } if self.kind == ConstraintKind::Angular { v.to_degrees() } else { v }
} }
} }
/// Shared access to a constraint's geometry, values, and output while evaluating one residual.
struct ResidualEval<'a> {
built: &'a Built,
model: &'a Model,
values: &'a [f64],
out: &'a mut Vec<f64>,
}
impl ResidualEval<'_> {
fn evaluate(&mut self) -> Option<()> {
use ConstraintKind as K;
match (self.built.kind, self.built.form) {
(_, Form::Lock) => Some(()),
(_, Form::PE { swap }) => self.eval_point_ellipse(swap),
(_, Form::LE { swap }) => self.eval_line_ellipse(swap),
(_, Form::Contact) => self.eval_contact(),
(_, Form::EndOn { spline, at_end }) => self.eval_end_on(spline, at_end),
(_, Form::Join { a_end, b_end, g2 }) => self.eval_join(a_end, b_end, g2),
(_, Form::Anchor(anchor)) => self.eval_anchor(anchor),
(K::Coincident, Form::PP) => self.eval_coincident_points(),
(K::Coincident, Form::PL { swap }) => self.eval_point_line(swap),
(K::Coincident, Form::PC { swap }) => self.eval_point_circle(swap),
(K::Horizontal | K::Vertical, form) => self.eval_horizontal_vertical(form),
(K::Parallel | K::Smooth, Form::LL) => self.eval_parallel(),
(K::Perpendicular, _) => self.eval_perpendicular(),
(K::Collinear, _) => self.eval_collinear(),
(K::Concentric, _) => self.eval_concentric(),
(K::Equal, Form::LL) => self.eval_equal_lines(),
(K::Equal, _) => self.eval_equal_circles(),
(K::Tangent | K::Smooth, Form::LC { swap }) => self.eval_tangent_line_circle(swap),
(K::Tangent | K::Smooth, _) => self.eval_tangent_circles(),
(K::Symmetric, Form::PP) => self.eval_symmetric_points(),
(K::Symmetric, Form::SymLines { crossed }) => self.eval_symmetric_lines(crossed),
(K::Symmetric, _) => self.eval_symmetric_circles(),
(K::Distance(axis), form) => self.eval_distance(axis, form),
(K::Angular, Form::ArcSweep) => self.eval_angular_arc(),
(K::Angular, Form::Rays { rev }) => self.eval_angular_rays(rev),
(K::Radius, _) => self.eval_radius(),
(K::Diameter, _) => self.eval_diameter(),
_ => Some(()),
}
}
fn reference(&self, index: usize) -> Option<&GeomRef> {
self.built.refs.get(index)
}
fn point(&self, index: usize) -> Option<Vec2> {
self.model.point(self.values, self.reference(index)?)
}
fn line(&self, index: usize) -> Option<(Vec2, Vec2)> {
self.model.line(self.values, self.reference(index)?)
}
fn circle(&self, index: usize) -> Option<(Vec2, f64)> {
self.model.circle(self.values, self.reference(index)?)
}
fn ellipse(&self, index: usize) -> Option<Ell> {
self.model.ellipse(self.values, self.reference(index)?)
}
fn eval_point_ellipse(&mut self, swap: bool) -> Option<()> {
let (point, ellipse) = if swap { (self.point(1)?, self.ellipse(0)?) } else { (self.point(0)?, self.ellipse(1)?) };
self.out.push(ellipse.on(point));
Some(())
}
fn eval_line_ellipse(&mut self, swap: bool) -> Option<()> {
let (line, ellipse) = if swap { (self.line(1)?, self.ellipse(0)?) } else { (self.line(0)?, self.ellipse(1)?) };
let (start, end) = line;
let direction = dir_n(start, end);
self.out.push(self.built.sign * direction.cross(ellipse.c - start) - ellipse.support(direction.perp()));
Some(())
}
fn eval_contact(&mut self) -> Option<()> {
let first_parameter = *self.values.get(self.built.aux)?;
let second_parameter = *self.values.get(self.built.aux + 1)?;
let (first_point, first_tangent) = self.built.curve_at(self.model, self.values, 0, first_parameter)?;
let (second_point, second_tangent) = self.built.curve_at(self.model, self.values, 1, second_parameter)?;
self.out.extend([
first_point.x - second_point.x,
first_point.y - second_point.y,
first_tangent.normalized().cross(second_tangent.normalized()),
]);
Some(())
}
fn eval_end_on(&mut self, spline: usize, at_end: bool) -> Option<()> {
let frame = self.model.end_frame(self.values, self.reference(spline)?, at_end)?;
let other = 1 - spline.min(1);
if let Some((start, end)) = self.line(other) {
let direction = dir_n(start, end);
self.out.extend([direction.cross(frame.p - start), frame.t.cross(direction)]);
} else if let Some((center, radius)) = self.circle(other) {
self.out.extend([frame.p.dist(center) - radius, frame.t.dot(dir_n(center, frame.p))]);
} else {
let ellipse = self.ellipse(other)?;
self.out.extend([ellipse.on(frame.p), frame.t.dot(ellipse.normal(frame.p))]);
}
Some(())
}
fn eval_join(&mut self, a_end: bool, b_end: bool, g2: bool) -> Option<()> {
let first = self.model.end_frame(self.values, self.reference(0)?, a_end)?;
let second = self.model.end_frame(self.values, self.reference(1)?, b_end)?;
let first = if a_end { first } else { first.reversed() };
let second = if b_end { second.reversed() } else { second };
self.out.extend([first.p.x - second.p.x, first.p.y - second.p.y, first.t.cross(second.t)]);
if g2 {
self.out.push(first.k - second.k);
}
Some(())
}
fn eval_anchor(&mut self, anchor: Vec2) -> Option<()> {
let point = self.point(0)?;
self.out.extend([point.x - anchor.x, point.y - anchor.y]);
Some(())
}
fn eval_coincident_points(&mut self) -> Option<()> {
let (first, second) = (self.point(0)?, self.point(1)?);
self.out.extend([first.x - second.x, first.y - second.y]);
Some(())
}
fn eval_point_line(&mut self, swap: bool) -> Option<()> {
let (point, (start, end)) = if swap { (self.point(1)?, self.line(0)?) } else { (self.point(0)?, self.line(1)?) };
self.out.push(dir_n(start, end).cross(point - start));
Some(())
}
fn eval_point_circle(&mut self, swap: bool) -> Option<()> {
let (point, (center, radius)) = if swap { (self.point(1)?, self.circle(0)?) } else { (self.point(0)?, self.circle(1)?) };
self.out.push(point.dist(center) - radius);
Some(())
}
fn eval_horizontal_vertical(&mut self, form: Form) -> Option<()> {
use ConstraintKind as K;
let (first, second) = if form == Form::L1 {
self.line(0).or_else(|| self.ellipse(0).map(|ellipse| (ellipse.c - ellipse.mj, ellipse.c + ellipse.mj)))?
} else {
(self.point(0)?, self.point(1)?)
};
let value = if self.built.kind == K::Horizontal { first.y - second.y } else { first.x - second.x };
self.out.push(value);
Some(())
}
fn eval_parallel(&mut self) -> Option<()> {
let ((first_start, first_end), (second_start, second_end)) = (self.line(0)?, self.line(1)?);
self.out.push(dir_n(first_start, first_end).cross(dir_n(second_start, second_end)));
Some(())
}
fn eval_perpendicular(&mut self) -> Option<()> {
let ((first_start, first_end), (second_start, second_end)) = (self.line(0)?, self.line(1)?);
self.out.push(dir_n(first_start, first_end).dot(dir_n(second_start, second_end)));
Some(())
}
fn eval_collinear(&mut self) -> Option<()> {
let ((first_start, first_end), (second_start, second_end)) = (self.line(0)?, self.line(1)?);
let direction = dir_n(first_start, first_end);
self.out.extend([direction.cross(second_start - first_start), direction.cross(second_end - first_start)]);
Some(())
}
fn eval_concentric(&mut self) -> Option<()> {
let center = |index| {
self.circle(index).map(|(center, _)| center).or_else(|| self.ellipse(index).map(|ellipse| ellipse.c)).or_else(|| self.point(index))
};
let (first, second) = (center(0)?, center(1)?);
self.out.extend([first.x - second.x, first.y - second.y]);
Some(())
}
fn eval_equal_lines(&mut self) -> Option<()> {
let ((first_start, first_end), (second_start, second_end)) = (self.line(0)?, self.line(1)?);
self.out.push(first_start.dist(first_end) - second_start.dist(second_end));
Some(())
}
fn eval_equal_circles(&mut self) -> Option<()> {
let ((_, first_radius), (_, second_radius)) = (self.circle(0)?, self.circle(1)?);
self.out.push(first_radius - second_radius);
Some(())
}
fn eval_tangent_line_circle(&mut self, swap: bool) -> Option<()> {
let ((start, end), (center, radius)) = if swap { (self.line(1)?, self.circle(0)?) } else { (self.line(0)?, self.circle(1)?) };
self.out.push(self.built.sign * dir_n(start, end).cross(center - start) - radius);
Some(())
}
fn eval_tangent_circles(&mut self) -> Option<()> {
let ((first_center, first_radius), (second_center, second_radius)) = (self.circle(0)?, self.circle(1)?);
let distance = first_center.dist(second_center);
let residual = if self.built.sign > 0.0 {
distance - (first_radius + second_radius)
} else {
distance - self.built.target * (first_radius - second_radius)
};
self.out.push(residual);
Some(())
}
fn eval_symmetric_points(&mut self) -> Option<()> {
let (first, second, (line_start, line_end)) = (self.point(0)?, self.point(1)?, self.line(2)?);
let direction = dir_n(line_start, line_end);
self.out.extend([direction.cross(first.mid(second) - line_start), direction.dot(second - first)]);
Some(())
}
fn eval_symmetric_lines(&mut self, crossed: bool) -> Option<()> {
let ((first_start, first_end), (second_start, second_end), (line_start, line_end)) = (self.line(0)?, self.line(1)?, self.line(2)?);
let (second_start, second_end) = if crossed { (second_end, second_start) } else { (second_start, second_end) };
let direction = dir_n(line_start, line_end);
self.out.extend([
direction.cross(first_start.mid(second_start) - line_start),
direction.dot(second_start - first_start),
direction.cross(first_end.mid(second_end) - line_start),
direction.dot(second_end - first_end),
]);
Some(())
}
fn eval_symmetric_circles(&mut self) -> Option<()> {
let ((first_center, first_radius), (second_center, second_radius), (line_start, line_end)) =
(self.circle(0)?, self.circle(1)?, self.line(2)?);
let direction = dir_n(line_start, line_end);
self.out.extend([
direction.cross(first_center.mid(second_center) - line_start),
direction.dot(second_center - first_center),
first_radius - second_radius,
]);
Some(())
}
fn eval_distance(&mut self, axis: DistAxis, form: Form) -> Option<()> {
let value = match (axis, form) {
(_, Form::PL { swap }) => {
let (point, (start, end)) = if swap { (self.point(1)?, self.line(0)?) } else { (self.point(0)?, self.line(1)?) };
dir_n(start, end).cross(point - start)
}
_ => {
let (first, second) = if form == Form::L1 { self.line(0)? } else { (self.point(0)?, self.point(1)?) };
match axis {
DistAxis::Aligned => first.dist(second),
DistAxis::Horizontal => second.x - first.x,
DistAxis::Vertical => second.y - first.y,
}
}
};
self.out.push(self.built.sign * value - self.built.target);
Some(())
}
fn eval_angular_arc(&mut self) -> Option<()> {
let reference = self.reference(0)?;
self.out.push(self.model.arc_sweep(self.values, reference)? - self.built.target);
Some(())
}
fn eval_angular_rays(&mut self, reversed: [bool; 2]) -> Option<()> {
let ((first_start, first_end), (second_start, second_end)) = (self.line(0)?, self.line(1)?);
let (first, second) = (ray(first_start, first_end, reversed[0]), ray(second_start, second_end, reversed[1]));
let residual = wrap(first.cross(second).atan2(first.dot(second)) - self.built.sign * self.built.target);
self.out.push(residual);
Some(())
}
fn eval_radius(&mut self) -> Option<()> {
self.out.push(self.circle(0)?.1 - self.built.target);
Some(())
}
fn eval_diameter(&mut self) -> Option<()> {
self.out.push(2.0 * self.circle(0)?.1 - self.built.target);
Some(())
}
}
+2 -2
View File
@@ -305,7 +305,7 @@ fn keep_option_moves_the_other_entity() {
let mut d = Drawing::default(); let mut d = Drawing::default();
let a = line(&mut d, (0.0, 0.0), (10.0, 0.0)); let a = line(&mut d, (0.0, 0.0), (10.0, 0.0));
let b = line(&mut d, (0.0, 2.0), (10.0, 4.0)); let b = line(&mut d, (0.0, 2.0), (10.0, 4.0));
add(&mut d, con(ConstraintKind::Parallel, &[w(a), w(b)]), &SolveOptions { keep: vec![a], prefer_move: vec![b] }).unwrap(); add(&mut d, con(ConstraintKind::Parallel, &[w(a), w(b)]), &SolveOptions { keep: vec![a], prefer_move: vec![b], ..Default::default() }).unwrap();
let (a0, a1) = ln(&d, a); let (a0, a1) = ln(&d, a);
assert!(a0.near(Vec2::new(0.0, 0.0), 1e-3) && a1.near(Vec2::new(10.0, 0.0), 1e-3), "{a0:?} {a1:?}"); assert!(a0.near(Vec2::new(0.0, 0.0), 1e-3) && a1.near(Vec2::new(10.0, 0.0), 1e-3), "{a0:?} {a1:?}");
} }
@@ -412,7 +412,7 @@ fn glyphs_and_residuals() {
}) })
.unwrap(); .unwrap();
assert!(!all_satisfied(&d)); assert!(!all_satisfied(&d));
let r = solve(&mut d, &SolveOptions { keep: vec![], prefer_move: vec![] }).unwrap(); let r = solve(&mut d, &SolveOptions::default()).unwrap();
assert_eq!(r.moved, vec![a]); assert_eq!(r.moved, vec![a]);
assert!(all_satisfied(&d)); assert!(all_satisfied(&d));
} }
+99
View File
@@ -363,3 +363,102 @@ fn hostile_new_geometry_never_panics() {
let _ = dof(&d, &hs); let _ = dof(&d, &hs);
let _ = dof(&d, &[Handle(0), Handle(u64::MAX)]); let _ = dof(&d, &[Handle(0), Handle(u64::MAX)]);
} }
/// Fit-point vertex constraints retain a known freedom count because they do not reconstruct the spline.
#[test]
fn vertex_constraints_on_large_fit_spline_do_not_charge_reconstruction() {
let mut d = Drawing::default();
let pts: Vec<_> = (0..500).map(|i| Vec2::new(i as f64, (i % 3) as f64)).collect();
let mut sp = GSpline::from_control(pts.clone(), 3);
sp.fit = pts;
let h = put(&mut d, EntityKind::Spline(sp));
for i in 0..5 {
let p = point(&mut d, (i as f64, (i % 3) as f64));
let mut c = con(K::Coincident, &[s(h, Sub::Vertex(i)), w(p)]);
c.id = i + 1;
d.constraints.push(c);
}
let report = dof(&d, &[]).unwrap();
assert_eq!(report.status, DofStatus::Under);
assert_eq!(report.dof, Some(1000));
}
/// Adds a constraint that the stored ellipse can only hold with its axes renumbered: refused,
/// with the drawing unchanged.
fn refused_on_minor_axis_end(p: (f64, f64)) {
let mut d = Drawing::default();
let e = ellipse(&mut d, (0.0, 0.0), (4.0, 0.0), 0.5, 0.0, std::f64::consts::TAU);
ok(&mut d, con(K::Fix, &[s(e, Sub::Center)]));
let p = point(&mut d, p);
ok(&mut d, con(K::Fix, &[w(p)]));
let before = d.clone();
let r = add(&mut d, con(K::Coincident, &[s(e, Sub::Vertex(1)), w(p)]), &SolveOptions::default());
assert!(matches!(r, Err(SolveError::Conflict { .. })), "{r:?}, ellipse {:?}", ell(&d, e));
assert_eq!(d, before);
}
/// Pulling a minor axis end past the major axis length would need a ratio above 1, which the
/// stored ellipse holds only by swapping its axes (and so renumbering their ends).
#[test]
fn minor_axis_end_pulled_past_the_major_axis_is_refused() {
refused_on_minor_axis_end((0.0, 6.0));
}
/// Pulling a minor axis end through the centre would need a negative ratio, which the stored
/// ellipse cannot hold: storing its absolute value would mirror the minor axis end.
#[test]
fn minor_axis_end_pulled_through_the_centre_is_refused() {
refused_on_minor_axis_end((0.0, -1.0));
}
/// A circle-like ellipse whose solution makes the minor axis the longer one: stored with its axes
/// swapped, which constraints that name no axis end by index do not notice.
fn round_ellipse(d: &mut Drawing, start: f64, end: f64) -> Handle {
let e = ellipse(d, (0.0, 0.0), (4.0, 0.0), 1.0, start, end);
ok(d, con(K::Fix, &[s(e, Sub::Center)]));
e
}
#[test]
fn tangent_that_swaps_a_round_ellipse_axes_is_kept() {
let mut d = Drawing::default();
let e = round_ellipse(&mut d, 0.0, std::f64::consts::TAU);
let l = line(&mut d, (-10.0, 4.5), (10.0, 4.5));
ok(&mut d, con(K::Fix, &[w(l)]));
ok(&mut d, con(K::Tangent, &[w(l), w(e)]));
assert!(ell(&d, e).ratio <= 1.0);
assert!(all_satisfied(&d));
}
#[test]
fn point_on_curve_that_swaps_a_round_ellipse_axes_is_kept() {
let mut d = Drawing::default();
let e = round_ellipse(&mut d, 0.0, std::f64::consts::TAU);
let p = point(&mut d, (0.0, 4.5));
ok(&mut d, con(K::Fix, &[w(p)]));
ok(&mut d, con(K::Coincident, &[w(p), w(e)]));
assert!(ell(&d, e).ratio <= 1.0);
assert!(all_satisfied(&d));
}
#[test]
fn elliptical_arc_start_that_swaps_its_axes_is_kept() {
let mut d = Drawing::default();
let e = round_ellipse(&mut d, std::f64::consts::FRAC_PI_2, std::f64::consts::PI);
let p = point(&mut d, (0.0, 4.5));
ok(&mut d, con(K::Fix, &[w(p)]));
ok(&mut d, con(K::Coincident, &[s(e, Sub::Start), w(p)]));
assert!(ell(&d, e).ratio <= 1.0);
assert!(all_satisfied(&d));
}
#[test]
fn tangent_circle_that_swaps_a_round_ellipse_axes_is_kept() {
let mut d = Drawing::default();
let e = round_ellipse(&mut d, 0.0, std::f64::consts::TAU);
let c = circle(&mut d, (0.0, 6.0), 1.5);
ok(&mut d, con(K::Fix, &[w(c)]));
ok(&mut d, con(K::Tangent, &[w(c), w(e)]));
assert!(ell(&d, e).ratio <= 1.0);
assert!(all_satisfied(&d));
}
+8 -1
View File
@@ -248,10 +248,17 @@ impl ArrayParams {
return false; return false;
} }
let Some(items) = self.items() else { return false }; let Some(items) = self.items() else { return false };
// Index the replacement entries by placement so each item is matched in O(1) instead of
// scanning the whole `replaced` vector per item, which is quadratic when both the items
// and the replacements are large (CWE-407). Last entry wins, as `.rev().find` did.
let mut last_replaced: std::collections::HashMap<(u32, u32, u32), usize> = std::collections::HashMap::with_capacity(self.replaced.len());
for (idx, r) in self.replaced.iter().enumerate() {
last_replaced.insert((r.i, r.j, r.level), idx);
}
let mut source: Option<&str> = None; let mut source: Option<&str> = None;
for (it, e) in items.iter().zip(&ents) { for (it, e) in items.iter().zip(&ents) {
let EntityKind::Insert(ins) = &e.kind else { return false }; let EntityKind::Insert(ins) = &e.kind else { return false };
match self.replaced.iter_mut().rev().find(|r| (r.i, r.j, r.level) == (it.i, it.j, it.level)) { match last_replaced.get(&(it.i, it.j, it.level)).and_then(|&idx| self.replaced.get_mut(idx)) {
Some(r) => r.block.clone_from(&ins.block), Some(r) => r.block.clone_from(&ins.block),
None => match source { None => match source {
None => source = Some(&ins.block), None => source = Some(&ins.block),
+1 -1
View File
@@ -208,7 +208,7 @@ impl Default for ParametricSettings {
angle_tolerance: 1.0, angle_tolerance: 1.0,
auto_types: ["Coincident", "Collinear", "Parallel", "Perpendicular", "Horizontal", "Vertical", "Tangent", "Concentric", "Equal"] auto_types: ["Coincident", "Collinear", "Parallel", "Perpendicular", "Horizontal", "Vertical", "Tangent", "Concentric", "Equal"]
.iter() .iter()
.map(|s| s.to_string()) .map(ToString::to_string)
.collect(), .collect(),
bars_visible: true, bars_visible: true,
bar_exceptions: Vec::new(), bar_exceptions: Vec::new(),
+6 -2
View File
@@ -5,7 +5,7 @@
//! whose `ACAD_ENHANCEDBLOCK` entry is an evaluation graph (`ACAD_EVALUATION_GRAPH`), and the //! whose `ACAD_ENHANCEDBLOCK` entry is an evaluation graph (`ACAD_EVALUATION_GRAPH`), and the
//! graph lists the block's *parameters* (a distance, a point, visibility states, a lookup…) and //! graph lists the block's *parameters* (a distance, a point, visibility states, a lookup…) and
//! *actions* (what changing a parameter does to the block's objects: stretch, move, look up…). //! *actions* (what changing a parameter does to the block's objects: stretch, move, look up…).
//! [`dynamic_block`] reads them into the model below without changing the kept data, so a drawing //! [`dynamic_block`](crate::dynblock::dynamic_block) reads them into the model below without changing the kept data, so a drawing
//! saved again still carries them exactly as read. //! saved again still carries them exactly as read.
//! //!
//! Every count and index here comes from the file: they are capped by the data actually present //! Every count and index here comes from the file: they are capped by the data actually present
@@ -334,7 +334,11 @@ fn graph_members<'a>(objects: &HashMap<Handle, &'a ForeignObject>, xdict: Handle
} }
let graph = objects.get(&graph?).filter(|o| o.kind.eq_ignore_ascii_case("ACAD_EVALUATION_GRAPH"))?; let graph = objects.get(&graph?).filter(|o| o.kind.eq_ignore_ascii_case("ACAD_EVALUATION_GRAPH"))?;
let members = section(&sections(&graph.tags), "AcDbEvalGraph").iter().filter(|t| t.code == 360).filter_map(|t| Handle::parse_hex(&t.value)); let members = section(&sections(&graph.tags), "AcDbEvalGraph").iter().filter(|t| t.code == 360).filter_map(|t| Handle::parse_hex(&t.value));
Some(members.take(MAX_NODES).filter_map(|h| objects.get(&h).copied()).collect()) // Deduplicate member handles before resolving them: a graph can list the same member many
// times, and parsing each occurrence into its own owned parameter multiplies a single large
// record into gigabytes of retained allocations (CWE-400).
let mut seen = std::collections::HashSet::new();
Some(members.take(MAX_NODES).filter(|h| seen.insert(*h)).filter_map(|h| objects.get(&h).copied()).collect())
} }
/// The dynamic block data of block `name` (any case; `*Model_Space` for a drawing that is a /// The dynamic block data of block `name` (any case; `*Model_Space` for a drawing that is a
+51 -5
View File
@@ -29,16 +29,21 @@ fn tag(code: i32, value: impl Into<String>) -> RawTag {
RawTag { code, value: value.into() } RawTag { code, value: value.into() }
} }
/// The element tag (`AcDbBlockRepETag` 1071) of object `h`, if it has one. /// The element tag (`AcDbBlockRepETag` 1071) in `x`, if it has one.
fn etag(d: &Drawing, h: Handle) -> Option<i64> { fn etag_in(x: &[RawTag]) -> Option<i64> {
let x = &d.foreign.entities.get(&h)?.xdata;
let at = x.iter().position(|t| t.code == 1001 && t.value.eq_ignore_ascii_case(ETAG))?; let at = x.iter().position(|t| t.code == 1001 && t.value.eq_ignore_ascii_case(ETAG))?;
x.iter().skip(at + 1).take_while(|t| t.code != 1001).find(|t| t.code == 1071).and_then(|t| t.value.trim().parse().ok()) x.iter().skip(at + 1).take_while(|t| t.code != 1001).find(|t| t.code == 1071).and_then(|t| t.value.trim().parse().ok())
} }
/// The element tag of object `e`: its own xdata (where the importer keeps it), else any kept in the
/// foreign data.
fn etag(d: &Drawing, e: &Entity) -> Option<i64> {
etag_in(&e.common.xdata).or_else(|| etag_in(&d.foreign.entities.get(&e.handle)?.xdata))
}
/// Each object of `b` keyed by its element tag, or by its position when it has none. /// Each object of `b` keyed by its element tag, or by its position when it has none.
fn by_tag(d: &Drawing, b: &Block) -> Vec<(i64, Handle)> { fn by_tag(d: &Drawing, b: &Block) -> Vec<(i64, Handle)> {
b.entities.iter().enumerate().map(|(i, e)| (etag(d, e.handle).unwrap_or(i as i64 + 1_000_000), e.handle)).collect() b.entities.iter().enumerate().map(|(i, e)| (etag(d, e).unwrap_or(i as i64 + 1_000_000), e.handle)).collect()
} }
/// The file handle of block `name`'s BLOCK_RECORD, as the kept data refers to it. /// The file handle of block `name`'s BLOCK_RECORD, as the kept data refers to it.
@@ -464,7 +469,15 @@ fn apply(
if !(v.x.is_finite() && v.y.is_finite()) || (v.x == 0.0 && v.y == 0.0) { if !(v.x.is_finite() && v.y.is_finite()) || (v.x == 0.0 && v.y == 0.0) {
continue; continue;
} }
// Index this action's vertex lists by handle once, and skip handles already handled in
// this action's selection, so a selection that repeats a handle does not re-scan the
// vertex list and re-clone its index vector per occurrence (CWE-400).
let vtx_by_handle: HashMap<Handle, &Vec<i32>> = vertices.iter().rev().map(|(vh, i)| (*vh, i)).collect();
let mut handled: HashSet<Handle> = HashSet::new();
for h in &a.selection { for h in &a.selection {
if !handled.insert(*h) {
continue;
}
if let Some(node) = param_of.get(h) { if let Some(node) = param_of.get(h) {
// A parameter in the selection is carried along: its points in the frame. Which // A parameter in the selection is carried along: its points in the frame. Which
// points are in it is decided where the dynamic block has them (AutoCAD evaluates // points are in it is decided where the dynamic block has them (AutoCAD evaluates
@@ -489,7 +502,7 @@ fn apply(
continue; continue;
} }
let Some(c) = copy_of(h) else { continue }; let Some(c) = copy_of(h) else { continue };
let idx = vertices.iter().find(|(vh, _)| vh == h).map(|(_, i)| i.clone()); let idx = vtx_by_handle.get(h).map(|i| (*i).clone());
moves.entry(c).or_default().push((idx, v)); moves.entry(c).or_default().push((idx, v));
} }
} }
@@ -534,6 +547,12 @@ fn apply(
let Some(nh) = copies.get(&e.handle).copied() else { continue }; let Some(nh) = copies.get(&e.handle).copied() else { continue };
let mut c: Entity = (**e).clone(); let mut c: Entity = (**e).clone();
c.handle = nh; c.handle = nh;
// The copy's own xdata names the copy, not its original (as the kept data below does).
for t in &mut c.common.xdata {
if t.code == 1005 && Handle::parse_hex(&t.value) == Some(e.handle) {
t.value = nh.hex();
}
}
if let Some(v) = shown_copy.get(&e.handle) { if let Some(v) = shown_copy.get(&e.handle) {
c.common.visible = *v; c.common.visible = *v;
} }
@@ -956,3 +975,30 @@ fn drop_if_unused(d: &mut Drawing, name: &str) {
} }
} }
} }
#[cfg(test)]
mod tests {
use super::*;
fn tagged(h: u64, tag_value: Option<&str>) -> Entity {
let mut e = Entity::new(Handle(h), EntityKind::Line(crate::Line { a: Vec3::ZERO, b: Vec3::ZERO }));
if let Some(v) = tag_value {
e.common.xdata = vec![tag(1001, ETAG), tag(1071, v)];
}
e
}
#[test]
fn element_tags_are_read_from_the_entitys_own_xdata() {
let d = Drawing::new_imperial();
let mut def = Block::new("DEF");
def.entities.push(tagged(1, Some("10")));
def.entities.push(tagged(2, Some("20")));
let mut anon = Block::new("*U1");
// Reordered, and the first object left out.
anon.entities.push(tagged(5, Some("20")));
anon.entities.push(tagged(6, None));
assert_eq!(by_tag(&d, &def), vec![(10, Handle(1)), (20, Handle(2))]);
assert_eq!(by_tag(&d, &anon), vec![(20, Handle(5)), (1_000_001, Handle(6))]);
}
}
+46 -931
View File
File diff suppressed because it is too large Load Diff
+182
View File
@@ -0,0 +1,182 @@
use super::common::{Common, Lineweight};
use super::text::{MText, Text};
use super::{Color, Deserialize, EntityKind, Mat3, Serialize, Vec2, Vec3};
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Attrib {
pub tag: String,
pub text: Text,
#[serde(default)]
pub invisible: bool,
#[serde(default)]
pub constant: bool,
#[serde(default)]
pub prompt: String,
/// An attribute's own properties; each one left unset follows the attribute definition.
#[serde(default, skip_serializing_if = "AttribProps::is_empty")]
pub props: AttribProps,
/// A multiline attribute (DXF attribute type 2, or 4 for a definition): its text as
/// multiline text. `text.value` holds the plain text, which value edits change.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub multiline: Option<MultilineAttrib>,
/// Its other modes: Verify, Preset and Lock position.
#[serde(default, skip_serializing_if = "AttribModes::is_default")]
pub modes: AttribModes,
}
/// An attribute's modes beyond invisible and constant.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AttribModes {
/// Verify: the value is asked for twice when the block is inserted (DXF flag 4).
#[serde(default)]
pub verify: bool,
/// Preset: the default value is used without asking (DXF flag 8).
#[serde(default)]
pub preset: bool,
/// Lock position: the attribute can't be moved on its own within a reference (DXF group
/// 280 after the flags). A reference's attributes take it from their definitions.
#[serde(default)]
pub lock_position: bool,
}
impl AttribModes {
pub fn is_default(&self) -> bool {
*self == Self::default()
}
/// The modes of a reference's attribute made from a definition with these: AutoCAD keeps
/// only Lock position there.
pub fn for_reference(self) -> Self {
AttribModes { lock_position: self.lock_position, ..Self::default() }
}
}
/// The multiline text of a multiline attribute.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MultilineAttrib {
pub mtext: MText,
/// The plain text `mtext`'s contents read as: an attribute whose value differs was edited
/// since, and its value replaces the contents.
pub read_as: String,
}
impl MultilineAttrib {
/// This text placed by `m` (a block reference's transform, for its attribute made from a
/// definition).
pub fn transformed(&self, m: &Mat3) -> MultilineAttrib {
let mut k = EntityKind::MText(self.mtext.clone());
k.transform(m);
let mtext = if let EntityKind::MText(t) = k { t } else { self.mtext.clone() };
MultilineAttrib { mtext, read_as: self.read_as.clone() }
}
}
impl Attrib {
/// Move a multiline attribute's text by `m` along with the attribute (nothing for others).
pub fn transform_multiline(&mut self, m: &Mat3) {
if let Some(ml) = &mut self.multiline {
*ml = ml.transformed(m);
}
}
/// A multiline attribute's text as drawn and saved: its multiline text, with the contents
/// replaced by the value when the value was edited since it was read.
pub fn multiline_text(&self) -> Option<MText> {
let m = self.multiline.as_ref()?;
let mut t = m.mtext.clone();
if self.text.value != m.read_as {
t.contents = mtext_literal(&self.text.value);
}
Some(t)
}
}
/// `s` as MTEXT contents that read back as `s`: backslashes and braces escaped, line breaks as
/// paragraph breaks.
pub fn mtext_literal(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'\\' => out.push_str("\\\\"),
'{' => out.push_str("\\{"),
'}' => out.push_str("\\}"),
'\n' => out.push_str("\\P"),
'\r' => {}
c => out.push(c),
}
}
out
}
/// Properties set on one attribute of a block reference (an ATTRIB's layer, colour, linetype and
/// lineweight), overriding those of its definition.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct AttribProps {
#[serde(skip_serializing_if = "Option::is_none")]
pub layer: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub color: Option<Color>,
#[serde(skip_serializing_if = "Option::is_none")]
pub linetype: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub lineweight: Option<Lineweight>,
}
impl AttribProps {
/// Nothing set: the attribute is drawn with its definition's properties.
pub fn is_empty(&self) -> bool {
*self == AttribProps::default()
}
/// `base` (the definition's properties) with the ones set here in place.
pub fn over(&self, base: &Common) -> Common {
Common {
layer: self.layer.clone().unwrap_or_else(|| base.layer.clone()),
color: self.color.unwrap_or(base.color),
linetype: self.linetype.clone().unwrap_or_else(|| base.linetype.clone()),
lineweight: self.lineweight.unwrap_or(base.lineweight),
..base.clone()
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Insert {
pub block: String,
pub insert: Vec3,
#[serde(default = "unit_scale")]
pub scale: Vec3,
#[serde(default)]
pub rotation: f64,
#[serde(default)]
pub attribs: Vec<Attrib>,
#[serde(default = "one_u")]
pub cols: u32,
#[serde(default = "one_u")]
pub rows: u32,
#[serde(default)]
pub col_spacing: f64,
#[serde(default)]
pub row_spacing: f64,
}
fn unit_scale() -> Vec3 {
Vec3::new(1.0, 1.0, 1.0)
}
fn one_u() -> u32 {
1
}
impl Insert {
/// Block-to-world transform (2D part).
pub fn transform(&self, base: Vec2) -> Mat3 {
Mat3::translate(self.insert.xy())
.then_before(Mat3::rotate(self.rotation))
.then_before(Mat3::scale(self.scale.x, self.scale.y))
.then_before(Mat3::translate(-base))
}
}
+132
View File
@@ -0,0 +1,132 @@
use super::{Color, Deserialize, Serialize, Vec3};
/// A database handle (DXF group 5), unique per drawing.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
pub struct Handle(pub u64);
impl Handle {
pub fn hex(self) -> String {
format!("{:X}", self.0)
}
pub fn parse_hex(s: &str) -> Option<Handle> {
let s = s.trim();
if !s.bytes().all(|b| b.is_ascii_hexdigit()) {
return None;
}
u64::from_str_radix(s, 16).ok().map(Handle)
}
}
/// Lineweight in hundredths of a millimetre, or a logical value.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Lineweight {
#[default]
ByLayer,
ByBlock,
Default,
/// 0..=211 (hundredths of mm).
Mm100(u16),
}
impl Lineweight {
/// The standard lineweight values (mm × 100).
pub const STANDARD: [u16; 24] = [0, 5, 9, 13, 15, 18, 20, 25, 30, 35, 40, 50, 53, 60, 70, 80, 90, 100, 106, 120, 140, 158, 200, 211];
pub fn to_dxf(self) -> i16 {
match self {
Lineweight::ByLayer => -1,
Lineweight::ByBlock => -2,
Lineweight::Default => -3,
Lineweight::Mm100(v) => v.min(211) as i16,
}
}
pub fn from_dxf(v: i16) -> Lineweight {
match v {
-2 => Lineweight::ByBlock,
-3 => Lineweight::Default,
0..=211 => Lineweight::Mm100(v as u16),
_ => Lineweight::ByLayer,
}
}
pub fn name(self) -> String {
match self {
Lineweight::ByLayer => "ByLayer".into(),
Lineweight::ByBlock => "ByBlock".into(),
Lineweight::Default => "Default".into(),
Lineweight::Mm100(v) => format!("{:.2} mm", f64::from(v) / 100.0),
}
}
}
/// Transparency: ByLayer, ByBlock, or 0..=90 percent.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Transparency {
#[default]
ByLayer,
ByBlock,
Percent(u8),
}
impl Transparency {
/// "ByLayer", "ByBlock" or the percentage ("50").
pub fn name(&self) -> String {
match self {
Transparency::ByLayer => "ByLayer".into(),
Transparency::ByBlock => "ByBlock".into(),
Transparency::Percent(p) => p.to_string(),
}
}
}
/// Properties every entity has.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct Common {
pub layer: String,
pub color: Color,
pub linetype: String,
pub lineweight: Lineweight,
pub ltscale: f64,
pub transparency: Transparency,
pub visible: bool,
pub thickness: f64,
pub extrusion: Vec3,
/// Named plot style: `ByLayer`, `ByBlock` or a style of the layout's named (`.stb`) plot
/// style table.
pub plot_style: String,
/// The annotation scales an annotative object supports (empty: not annotative).
#[serde(skip_serializing_if = "Vec::is_empty")]
pub anno_scales: Vec<crate::AnnoScale>,
/// Other applications' extended data (xdata) as read from the drawing file: whole
/// applications, each from its `1001` group on. It is part of the object, so a copy of the
/// object (COPY, MIRROR, ARRAY, paste) carries it, as in AutoCAD.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub xdata: Vec<RawTag>,
}
impl Default for Common {
fn default() -> Self {
Common {
layer: "0".into(),
color: Color::ByLayer,
linetype: "ByLayer".into(),
lineweight: Lineweight::ByLayer,
ltscale: 1.0,
transparency: Transparency::ByLayer,
visible: true,
thickness: 0.0,
extrusion: Vec3::Z,
plot_style: "ByLayer".into(),
anno_scales: Vec::new(),
xdata: Vec::new(),
}
}
}
/// A raw DXF group: kept for round-tripping entities we do not model.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RawTag {
pub code: i32,
pub value: String,
}
+76
View File
@@ -0,0 +1,76 @@
use super::{Deserialize, PolyVertex, Serialize, Vec3};
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Line {
pub a: Vec3,
pub b: Vec3,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Point {
pub p: Vec3,
#[serde(default)]
pub angle: f64,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Circle {
pub center: Vec3,
pub radius: f64,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Arc {
pub center: Vec3,
pub radius: f64,
/// Radians, CCW.
pub start: f64,
pub end: f64,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Ellipse {
pub center: Vec3,
pub major: Vec3,
pub ratio: f64,
pub start: f64,
pub end: f64,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct LwPolyline {
pub vertices: Vec<PolyVertex>,
pub closed: bool,
#[serde(default)]
pub const_width: f64,
#[serde(default)]
pub elevation: f64,
/// Polyline linetype generation (PLINEGEN).
#[serde(default)]
pub plinegen: bool,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Polyline3d {
pub points: Vec<Vec3>,
pub closed: bool,
}
/// SOLID / TRACE: up to four corners (DXF order 1,2,4,3 when drawn).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Solid {
pub corners: [Vec3; 4],
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Face3d {
pub corners: [Vec3; 4],
#[serde(default)]
pub hidden_edges: u8,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RayLine {
pub base: Vec3,
pub dir: Vec3,
}
+226
View File
@@ -0,0 +1,226 @@
use super::block::Insert;
use super::common::Handle;
use super::text::MText;
use super::{Deserialize, GSpline, Serialize, Vec2, Vec3, one, standard, yes};
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum DimKind {
/// Rotated linear dimension (horizontal = 0, vertical = 90°).
Linear {
rotation: f64,
},
Aligned,
Angular,
Angular3P,
Diameter,
Radius,
Ordinate {
x_type: bool,
},
ArcLength,
}
/// A dimension. Definition points follow DXF: `defpt` (10: dimension line point),
/// `p13`/`p14` (extension origins), `p15`/`p16` (arc/angle points), `text_mid` (11).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Dimension {
pub kind: DimKind,
pub defpt: Vec3,
pub text_mid: Vec3,
#[serde(default)]
pub p13: Vec3,
#[serde(default)]
pub p14: Vec3,
#[serde(default)]
pub p15: Vec3,
#[serde(default)]
pub p16: Vec3,
/// "" = measured value, "<>" inside = measurement placeholder, " " = suppressed.
#[serde(default)]
pub text: String,
#[serde(default = "standard")]
pub style: String,
#[serde(default)]
pub measurement: f64,
#[serde(default)]
pub text_rotation: f64,
/// User moved the text (text_mid is authoritative).
#[serde(default)]
pub user_text_pos: bool,
/// Anonymous block with the rendered geometry (from files); regenerated when edited.
#[serde(default)]
pub block: Option<String>,
/// Per-dimension style overrides (DIMOVERRIDE): DimStyle field names (camelCase) → values,
/// merged over the named style.
#[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
pub overrides: serde_json::Map<String, serde_json::Value>,
/// Associativity: which definition points follow which objects.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub assoc: Vec<DimAssoc>,
/// Oblique extension lines, jogs, breaks and inspection frames.
#[serde(default, skip_serializing_if = "DimMarks::is_empty")]
pub marks: DimMarks,
}
/// What dimension edits add to a dimension's drawing: DIMEDIT Oblique, DIMJOGLINE, DIMJOGGED,
/// DIMBREAK and DIMINSPECT.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct DimMarks {
/// Angle (radians) of the extension lines of a linear or aligned dimension; None =
/// perpendicular to the dimension line.
#[serde(skip_serializing_if = "Option::is_none")]
pub oblique: Option<f64>,
/// A jog on the dimension line of a linear or aligned dimension, at this point (DIMJOGLINE).
#[serde(skip_serializing_if = "Option::is_none")]
pub jog_line: Option<Vec3>,
/// A jogged radius dimension (DIMJOGGED).
#[serde(skip_serializing_if = "Option::is_none")]
pub jogged: Option<JoggedRadius>,
/// Gaps in the dimension and extension lines (DIMBREAK).
#[serde(skip_serializing_if = "Vec::is_empty")]
pub breaks: Vec<DimBreak>,
/// Inspection frame, label and rate (DIMINSPECT).
#[serde(skip_serializing_if = "Option::is_none")]
pub inspect: Option<Inspection>,
}
impl DimMarks {
pub fn is_empty(&self) -> bool {
*self == DimMarks::default()
}
/// Transform the points and angles (part of [`super::EntityKind::transform`]).
pub(super) fn transform(&mut self, t3: &dyn Fn(&mut Vec3), rot: f64, s: f64, mirror: bool) {
if let Some(a) = &mut self.oblique {
*a = cadcraft_geom::norm_angle(if mirror { rot - (*a - rot) } else { *a + rot });
}
if let Some(j) = &mut self.jog_line {
t3(j);
}
if let Some(j) = &mut self.jogged {
t3(&mut j.center);
t3(&mut j.jog);
}
for b in &mut self.breaks {
t3(&mut b.at);
b.size *= s;
}
}
}
/// The definition of a jogged radius dimension: the dimension line starts at an overriding
/// centre and jogs on its way to the arc.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct JoggedRadius {
/// Where the dimension line starts instead of the true centre.
pub center: Vec3,
/// The middle of the jog.
pub jog: Vec3,
}
/// A gap in a dimension's lines: everything within `size / 2` of `at` is left out.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct DimBreak {
pub at: Vec3,
pub size: f64,
/// Made by DIMBREAK Auto (recomputed by the next Auto) rather than picked by hand.
pub auto: bool,
}
/// An inspection dimension (DIMINSPECT).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct Inspection {
/// Frame shape: 0 round ends, 1 angular ends, 2 none.
pub shape: u8,
/// Text left of the value ("" = none).
pub label: String,
/// Inspection rate right of the value ("" = none).
pub rate: String,
}
/// A dimension definition point that follows an object (associative dimensions).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DimAssoc {
/// Definition point: "defpt", "p13", "p14", "p15" or "p16".
pub point: String,
/// The object it is attached to.
pub handle: Handle,
pub snap: AssocSnap,
}
/// Where on the object an associative definition point sits.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum AssocSnap {
/// Start point of a line/arc (or polyline vertex 0).
Start,
/// End point of a line/arc.
End,
Mid,
/// Centre of a circle/arc.
Center,
/// On a circle/arc at a fixed angle (radians) from its centre.
OnCircle {
angle: f64,
},
/// Intersection of this (line) object with another line.
Intersection {
other: Handle,
},
/// Vertex `index` of a polyline.
Vertex {
index: usize,
},
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Leader {
pub vertices: Vec<Vec3>,
#[serde(default = "yes")]
pub arrow: bool,
#[serde(default)]
pub spline: bool,
#[serde(default = "standard")]
pub style: String,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MLeader {
pub leaders: Vec<Vec<Vec3>>,
pub landing: Vec3,
pub dogleg: f64,
pub text: Option<MText>,
#[serde(default = "standard")]
pub style: String,
#[serde(default = "one")]
pub arrow_size: f64,
/// Leader lines are smooth curves through their points (leader type Spline). An empty
/// `leaders` is a multileader without a leader line (leader type None).
#[serde(default)]
pub spline: bool,
/// Block content in place of the text: a block reference placed at the landing.
#[serde(default)]
pub block: Option<Insert>,
}
impl MLeader {
/// Each leader line as drawn: through its points to the landing, smoothed for spline leaders.
pub fn leader_paths(&self) -> Vec<Vec<Vec2>> {
self.leaders
.iter()
.map(|l| {
let pts: Vec<Vec2> = l.iter().map(|v| v.xy()).chain(std::iter::once(self.landing.xy())).collect();
if self.spline && pts.len() > 2 { GSpline::from_fit_points(&pts).tessellate(1e-3) } else { pts }
})
.collect()
}
}
+61
View File
@@ -0,0 +1,61 @@
use super::common::Handle;
use super::{Color, Deserialize, PolyVertex, Serialize, Vec2, one};
/// A hatch boundary loop: closed polyline with bulges.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct HatchLoop {
pub vertices: Vec<PolyVertex>,
#[serde(default)]
pub outer: bool,
/// The objects the loop was made from (DXF group 97 count, 330 handles): an associative
/// hatch re-fills the loop from them when they change.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub sources: Vec<Handle>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Hatch {
pub pattern: String,
pub solid: bool,
pub loops: Vec<HatchLoop>,
#[serde(default = "one")]
pub scale: f64,
#[serde(default)]
pub angle: f64,
#[serde(default)]
pub associative: bool,
/// 0 normal (odd parity), 1 outer, 2 ignore.
#[serde(default)]
pub style: u8,
#[serde(default)]
pub elevation: f64,
/// Gradient fill: name + second colour.
#[serde(default)]
pub gradient: Option<Gradient>,
#[serde(default)]
pub origin: Vec2,
#[serde(default)]
pub background: Option<Color>,
/// The pattern's line families as a file defined them, for a pattern that isn't in the
/// standard library (unit scale and angle, like the library's; empty otherwise).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub pattern_lines: Vec<crate::library::PatternLine>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Gradient {
pub name: String,
pub color1: Color,
pub color2: Color,
#[serde(default)]
pub angle: f64,
#[serde(default)]
pub centered: bool,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Wipeout {
pub boundary: Vec<Vec2>,
}
+168
View File
@@ -0,0 +1,168 @@
use super::common::Handle;
use super::{Color, Deserialize, Serialize, Vec2, Vec3, standard, yes};
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Viewport {
pub center: Vec3,
pub width: f64,
pub height: f64,
pub view_center: Vec2,
pub view_height: f64,
#[serde(default)]
pub id: u32,
#[serde(default)]
pub locked: bool,
#[serde(default)]
pub frozen_layers: Vec<String>,
/// Per-viewport layer colour overrides (VP Color in the Layer Properties Manager).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub layer_colors: Vec<(String, Color)>,
/// The paper-space entity whose outline clips the viewport (a closed polyline, circle,
/// ellipse or spline; DXF group 340). `None`: the rectangle `center`/`width`/`height`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub clip: Option<Handle>,
/// Display on (MVIEW ON/OFF). An off viewport shows its border but no model space.
#[serde(default = "yes")]
pub on: bool,
}
impl Default for Viewport {
fn default() -> Self {
Viewport {
center: Vec3::ZERO,
width: 1.0,
height: 1.0,
view_center: Vec2::ZERO,
view_height: 1.0,
id: 0,
locked: false,
frozen_layers: Vec::new(),
layer_colors: Vec::new(),
clip: None,
on: true,
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Image {
pub insert: Vec3,
/// One pixel's width (U) and height (V) vectors in drawing units.
pub u: Vec3,
pub v: Vec3,
/// Image size in pixels.
pub size: Vec2,
/// The image file (its IMAGEDEF object's path).
pub path: String,
/// The image definition's name (its key in the drawing's image dictionary).
#[serde(default, skip_serializing_if = "String::is_empty")]
pub name: String,
/// Clip boundary in pixel coordinates (origin at the top-left corner of the image, y down):
/// two opposite corners of a rectangle, or a polygon's vertices. Empty: the whole image.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub clip: Vec<Vec2>,
/// The clip boundary is applied.
#[serde(default)]
pub clipping: bool,
/// Display flags: 1 show the image, 2 show it when not aligned with the screen, 4 use the
/// clip boundary, 8 transparency on.
#[serde(default = "image_display")]
pub display: u16,
/// Brightness, contrast and fade, 0..=100.
#[serde(default = "image_fifty")]
pub brightness: u8,
#[serde(default = "image_fifty")]
pub contrast: u8,
#[serde(default)]
pub fade: u8,
/// The definition's default size of one pixel and its resolution unit (0 none, 2 cm, 5 inch).
#[serde(default = "image_pixel")]
pub pixel_size: Vec2,
#[serde(default)]
pub resolution_units: u8,
}
fn image_display() -> u16 {
7
}
fn image_fifty() -> u8 {
50
}
fn image_pixel() -> Vec2 {
Vec2::new(1.0, 1.0)
}
impl Default for Image {
fn default() -> Self {
Image {
insert: Vec3::ZERO,
u: Vec3::new(1.0, 0.0, 0.0),
v: Vec3::new(0.0, 1.0, 0.0),
size: Vec2::new(1.0, 1.0),
path: String::new(),
name: String::new(),
clip: Vec::new(),
clipping: false,
display: image_display(),
brightness: image_fifty(),
contrast: image_fifty(),
fade: 0,
pixel_size: image_pixel(),
resolution_units: 0,
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TableCell {
pub text: String,
#[serde(default)]
pub merged: Option<(u32, u32)>,
/// The cell's own cell style; empty: its row's.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub style: String,
/// A formula (`=Sum(A1:B3)`); `text` holds its value.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub formula: Option<String>,
/// The cell's own text height (DXF 140 in the cell record), before the table's scale;
/// `None`: its cell style's.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub text_height: Option<f64>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Table {
pub insert: Vec3,
pub col_widths: Vec<f64>,
pub row_heights: Vec<f64>,
pub cells: Vec<Vec<TableCell>>,
#[serde(default = "standard")]
pub style: String,
#[serde(default = "default_text_h")]
pub text_height: f64,
/// First row is a title (merged across, larger text).
#[serde(default)]
pub title: bool,
/// The row after the title holds column headers.
#[serde(default)]
pub header: bool,
/// The cell style of each row; missing or empty: by position (Title, Header, Data).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub row_styles: Vec<String>,
/// Table breaking (TABLEBREAK): the table drawn in several parts.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub breaks: Option<crate::TableBreak>,
/// The CSV file the cells come from (DATALINK).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub data_link: Option<crate::DataLink>,
}
fn default_text_h() -> f64 {
0.18
}
+141
View File
@@ -0,0 +1,141 @@
use super::{Deserialize, Serialize, Vec2, Vec3, one, standard};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum HAlign {
#[default]
Left,
Center,
Right,
Aligned,
Middle,
Fit,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum VAlign {
#[default]
Baseline,
Bottom,
Middle,
Top,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Text {
pub insert: Vec3,
#[serde(default)]
pub align_pt: Option<Vec3>,
pub height: f64,
pub value: String,
#[serde(default)]
pub rotation: f64,
#[serde(default = "one")]
pub width_factor: f64,
#[serde(default)]
pub oblique: f64,
#[serde(default = "standard")]
pub style: String,
#[serde(default)]
pub halign: HAlign,
#[serde(default)]
pub valign: VAlign,
}
impl Text {
/// The point the text is placed by, as the Insertion snap and the grip show it: the alignment
/// point (DXF group 11) of justified text, the start point (group 10) of left-baseline,
/// aligned and fit text.
pub fn justify_point(&self) -> Vec2 {
match (self.halign, self.valign) {
(HAlign::Left, VAlign::Baseline) | (HAlign::Aligned | HAlign::Fit, _) => self.insert.xy(),
_ => self.align_pt.unwrap_or(self.insert).xy(),
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MText {
pub insert: Vec3,
pub height: f64,
/// Reference rectangle width (0 = no wrapping).
#[serde(default)]
pub width: f64,
/// 1..=9: TL TC TR ML MC MR BL BC BR.
#[serde(default = "attach_tl")]
pub attach: u8,
#[serde(default)]
pub rotation: f64,
#[serde(default = "standard")]
pub style: String,
/// Raw MTEXT contents with inline codes (\P, {\fArial|b1;...}, \S1/2; …).
pub contents: String,
#[serde(default = "one")]
pub line_spacing: f64,
/// Line spacing style (DXF 73): Exactly (`true`) keeps `line_spacing` whatever the character
/// heights; At least (`false`, the default) lets taller characters push lines apart.
#[serde(default)]
pub line_spacing_exact: bool,
/// Columns the text flows through, each `width` wide (DXF 75/76/78/79/48/49/50); `None`:
/// one column.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub columns: Option<MTextColumns>,
}
fn attach_tl() -> u8 {
1
}
/// Static columns: a fixed number of columns of one height. Dynamic columns: as many columns
/// as the text needs, of one height (auto height) or each its own (manual heights).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum MTextColumnType {
#[default]
Static,
Dynamic,
}
/// MTEXT columns (MTEXT Columns option, DXF Reference MTEXT groups 75–79, 48–50).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct MTextColumns {
#[serde(rename = "type")]
pub kind: MTextColumnType,
/// Number of columns (static columns: the columns there are).
pub count: u16,
/// Space between two columns.
pub gutter: f64,
/// Height of every column (static, and dynamic with auto height).
pub height: f64,
/// Dynamic columns: one height for all columns (`height`) instead of `heights`.
pub auto_height: bool,
/// Dynamic columns with manual heights: the height of each column.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub heights: Vec<f64>,
/// Columns run right to left.
pub flow_reversed: bool,
}
impl MTextColumns {
/// The width of `count` columns of `column_width` and their gutters.
pub fn total_width(&self, column_width: f64) -> f64 {
let n = f64::from(self.count.max(1));
column_width * n + self.gutter.max(0.0) * (n - 1.0)
}
/// The tallest column.
pub fn max_height(&self) -> f64 {
self.heights.iter().copied().fold(self.height, f64::max).max(0.0)
}
/// Scale every length (SCALE, block insertion).
pub fn scale(&mut self, s: f64) {
self.gutter *= s;
self.height *= s;
self.heights.iter_mut().for_each(|h| *h *= s);
}
}
+15 -3
View File
@@ -165,9 +165,21 @@ fn bounds_in<'a>(d: &'a Drawing, e: &Entity, depth: usize, blocks: &mut HashMap<
if let Some(blk) = dm.block.as_ref().and_then(|n| d.block(n)) if let Some(blk) = dm.block.as_ref().and_then(|n| d.block(n))
&& depth < MAX_BLOCK_DEPTH && depth < MAX_BLOCK_DEPTH
{ {
for be in blk.entities.iter().filter(|be| in_block_visible(d, be)) { // Reuse the shared block-bounds cache (as the Insert branch does) so a dimension
b = b.union(&bounds_in(d, be, depth + 1, blocks)); // block with branching or self-referencing children costs one pass per definition
} // per depth, not one per reference (which grows exponentially) (CWE-400).
let inner = match blocks.get(&(blk.name.as_str(), depth)) {
Some(cached) => *cached,
None => {
let mut inner = Bounds2::EMPTY;
for be in blk.entities.iter().filter(|be| in_block_visible(d, be)) {
inner = inner.union(&bounds_in(d, be, depth + 1, blocks));
}
blocks.insert((blk.name.as_str(), depth), inner);
inner
}
};
b = b.union(&inner);
} }
b b
} }
+5
View File
@@ -66,6 +66,10 @@ pub struct Foreign {
pub records: BTreeMap<ForeignKey, ForeignExtra>, pub records: BTreeMap<ForeignKey, ForeignExtra>,
/// The roles of the file handles the kept data refers to, for objects CADCraft rewrites. /// The roles of the file handles the kept data refers to, for objects CADCraft rewrites.
pub targets: BTreeMap<Handle, ForeignKey>, pub targets: BTreeMap<Handle, ForeignKey>,
/// The upper-case names of the file's classes in its CLASSES order, kept when the file has
/// proxy objects or entities: a proxy's class number (group 91) is 500 plus its class's place
/// here, and saving renumbers it to the written CLASSES (#1812).
pub class_order: Vec<String>,
/// The file's data store section (ACDSDATA, AutoCAD 2013 and later), as read: its groups /// The file's data store section (ACDSDATA, AutoCAD 2013 and later), as read: its groups
/// after the section name. It holds the ACIS geometry of 3D solids, regions, bodies and /// after the section name. It holds the ACIS geometry of 3D solids, regions, bodies and
/// surfaces, which from 2013 on those entities don't carry themselves: each `ACDSRECORD` /// surfaces, which from 2013 on those entities don't carry themselves: each `ACDSRECORD`
@@ -81,6 +85,7 @@ impl Foreign {
&& self.records.is_empty() && self.records.is_empty()
&& self.classes.is_empty() && self.classes.is_empty()
&& self.targets.is_empty() && self.targets.is_empty()
&& self.class_order.is_empty()
&& self.data_store.is_empty() && self.data_store.is_empty()
} }
+100
View File
@@ -0,0 +1,100 @@
use std::path::Path;
/// A file format, chosen by the file name's extension (ASCII case-insensitive).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Format {
Dxf,
Dwg,
/// A drawing template: DWG content.
Dwt,
Svg,
/// Gzip-compressed SVG.
Svgz,
Png,
Pdf,
}
impl Format {
/// The formats a drawing can be read from by name. DXF and DWG are also recognised by content.
pub const READ: &'static [Format] = &[Format::Dxf, Format::Dwg, Format::Svg, Format::Svgz];
/// The formats a drawing can be written to.
pub const WRITE: &'static [Format] = &[Format::Dxf, Format::Dwg, Format::Dwt, Format::Svg, Format::Png, Format::Pdf];
/// The format `path`'s extension names. A name without an extension is DXF; an unknown
/// extension is `None`.
pub fn from_path(p: impl AsRef<Path>) -> Option<Format> {
let ext = p.as_ref().extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default();
Some(match ext.as_str() {
"dxf" | "" => Format::Dxf,
"dwg" => Format::Dwg,
"dwt" => Format::Dwt,
"svg" => Format::Svg,
"svgz" => Format::Svgz,
"png" => Format::Png,
"pdf" => Format::Pdf,
_ => return None,
})
}
/// The extension, lower case and without the dot.
pub fn extension(self) -> &'static str {
match self {
Format::Dxf => "dxf",
Format::Dwg => "dwg",
Format::Dwt => "dwt",
Format::Svg => "svg",
Format::Svgz => "svgz",
Format::Png => "png",
Format::Pdf => "pdf",
}
}
/// Written as an image (model space fitted to a frame), so framing applies. SVGZ is read only.
pub fn is_image(self) -> bool {
matches!(self, Format::Svg | Format::Png | Format::Pdf)
}
/// A drawing file (DXF, DWG, template) rather than an image.
pub fn is_drawing(self) -> bool {
matches!(self, Format::Dxf | Format::Dwg | Format::Dwt)
}
/// Written as DWG, which drops the object types the DWG writer doesn't know.
pub fn drops_unknown(self) -> bool {
matches!(self, Format::Dwg | Format::Dwt)
}
/// `formats` as "dxf, dwg, svg": the list help text shows.
pub fn list(formats: &[Format]) -> String {
formats.iter().map(|f| f.extension()).collect::<Vec<_>>().join(", ")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn from_path_by_extension() {
for f in [Format::Dxf, Format::Dwg, Format::Dwt, Format::Svg, Format::Svgz, Format::Png, Format::Pdf] {
assert_eq!(Format::from_path(format!("a/b.{}", f.extension())), Some(f));
assert_eq!(Format::from_path(format!("B.{}", f.extension().to_ascii_uppercase())), Some(f));
}
assert_eq!(Format::from_path("Drawing1"), Some(Format::Dxf), "no extension is DXF");
assert_eq!(Format::from_path("dir.dwg/Drawing1"), Some(Format::Dxf));
assert_eq!(Format::from_path("a."), Some(Format::Dxf));
assert_eq!(Format::from_path("a.txt"), None);
assert_eq!(Format::from_path("a.dxf.bak"), None);
assert_eq!(Format::from_path(std::path::Path::new("x.PnG")), Some(Format::Png));
}
#[test]
fn classes() {
assert!(Format::READ.contains(&Format::Svgz) && !Format::WRITE.contains(&Format::Svgz));
assert!(Format::WRITE.contains(&Format::Dwt) && !Format::READ.contains(&Format::Dwt));
assert!(Format::WRITE.iter().filter(|f| f.is_image()).count() == 3 && !Format::Svgz.is_image());
assert!(Format::Dwt.drops_unknown() && Format::Dwg.drops_unknown() && !Format::Dxf.drops_unknown());
assert!(Format::Dwt.is_drawing() && !Format::Pdf.is_drawing());
assert_eq!(Format::list(Format::READ), "dxf, dwg, svg, svgz");
}
}
+18
View File
@@ -10,6 +10,7 @@ mod array;
mod constraint; mod constraint;
mod entity; mod entity;
mod extents; mod extents;
mod format;
mod gdt; mod gdt;
mod header; mod header;
pub mod library; pub mod library;
@@ -27,6 +28,7 @@ pub use cadcraft_geom as geom;
pub use constraint::*; pub use constraint::*;
pub use entity::*; pub use entity::*;
pub use extents::{MAX_BLOCK_DEPTH, entity_bounds}; pub use extents::{MAX_BLOCK_DEPTH, entity_bounds};
pub use format::Format;
pub use gdt::*; pub use gdt::*;
pub use header::{HVal, Header}; pub use header::{HVal, Header};
pub use plotstyle::*; pub use plotstyle::*;
@@ -98,6 +100,9 @@ pub struct Drawing {
pub blocks: BTreeMap<String, std::sync::Arc<Block>>, pub blocks: BTreeMap<String, std::sync::Arc<Block>>,
pub model: EntityStore, pub model: EntityStore,
pub layouts: Vec<Layout>, pub layouts: Vec<Layout>,
/// The layout a file marks active (its block is `*Paper_Space`, the others `*Paper_SpaceN`),
/// written active again. `None`, or a layout that no longer exists: the first by tab order.
pub active_layout: Option<String>,
pub views: Vec<NamedView>, pub views: Vec<NamedView>,
/// Model space viewport configurations (VPORTS): the tiles shown and named ones. /// Model space viewport configurations (VPORTS): the tiles shown and named ones.
pub vports: Vec<VportConfig>, pub vports: Vec<VportConfig>,
@@ -171,6 +176,7 @@ impl Drawing {
blocks: BTreeMap::new(), blocks: BTreeMap::new(),
model: EntityStore::new(), model: EntityStore::new(),
layouts: vec![Layout::new("Layout1", 1), Layout::new("Layout2", 2)], layouts: vec![Layout::new("Layout1", 1), Layout::new("Layout2", 2)],
active_layout: None,
views: Vec::new(), views: Vec::new(),
vports: Vec::new(), vports: Vec::new(),
ucss: Vec::new(), ucss: Vec::new(),
@@ -298,6 +304,18 @@ impl Drawing {
true true
} }
/// The layouts in the order their paper space blocks are named in a file: the active one
/// (`*Paper_Space`) first, then the others by tab order (`*Paper_Space0`, `*Paper_Space1`, …).
pub fn paper_space_layouts(&self) -> Vec<&Layout> {
let mut ls: Vec<&Layout> = self.layouts.iter().collect();
ls.sort_by_key(|l| l.tab_order);
if let Some(i) = self.active_layout.as_ref().and_then(|a| ls.iter().position(|l| l.name.eq_ignore_ascii_case(a))) {
let active = ls.remove(i);
ls.insert(0, active);
}
ls
}
pub fn space(&self, space: &Space) -> Option<&EntityStore> { pub fn space(&self, space: &Space) -> Option<&EntityStore> {
match space { match space {
Space::Model => Some(&self.model), Space::Model => Some(&self.model),
+69 -36
View File
@@ -177,6 +177,11 @@ pub struct DimStyle {
pub dim_line_color: Color, pub dim_line_color: Color,
pub ext_line_color: Color, pub ext_line_color: Color,
pub text_color: Color, pub text_color: Color,
/// DIMTFILL, the background of the text: 0 none, 1 the drawing's background color,
/// 2 `text_fill_color`.
pub text_fill: u8,
/// DIMTFILLCLR: the text background color when `text_fill` is 2.
pub text_fill_color: Color,
pub text_style: String, pub text_style: String,
/// DIMPOST prefix/suffix "<>" template. /// DIMPOST prefix/suffix "<>" template.
pub post: String, pub post: String,
@@ -296,6 +301,8 @@ pub const DIMVARS: &[(&str, &str)] = &[
("DIMCLRD", "dimLineColor"), ("DIMCLRD", "dimLineColor"),
("DIMCLRE", "extLineColor"), ("DIMCLRE", "extLineColor"),
("DIMCLRT", "textColor"), ("DIMCLRT", "textColor"),
("DIMTFILL", "textFill"),
("DIMTFILLCLR", "textFillColor"),
("DIMTXSTY", "textStyle"), ("DIMTXSTY", "textStyle"),
("DIMPOST", "post"), ("DIMPOST", "post"),
("DIMCEN", "centerMark"), ("DIMCEN", "centerMark"),
@@ -367,6 +374,8 @@ impl Default for DimStyle {
dim_line_color: Color::ByBlock, dim_line_color: Color::ByBlock,
ext_line_color: Color::ByBlock, ext_line_color: Color::ByBlock,
text_color: Color::ByBlock, text_color: Color::ByBlock,
text_fill: 0,
text_fill_color: Color::ByBlock,
text_style: "Standard".into(), text_style: "Standard".into(),
post: String::new(), post: String::new(),
center_mark: 0.09, center_mark: 0.09,
@@ -432,6 +441,49 @@ impl DimStyle {
.or_else(|| ["name", "annotative"].into_iter().find(|f| f.eq_ignore_ascii_case(name))) .or_else(|| ["name", "annotative"].into_iter().find(|f| f.eq_ignore_ascii_case(name)))
} }
/// The value `v` takes for a field whose current value is `old`: coerced to the field's type where
/// the text or number can be read as it.
fn coerce_value(old: &serde_json::Value, v: &serde_json::Value, field: &str) -> serde_json::Value {
use serde_json::Value;
match (old, v) {
(Value::Bool(_), Value::Number(n)) => Value::Bool(n.as_f64().is_some_and(|x| x != 0.0)),
(Value::Bool(_), Value::String(t)) => Value::Bool(matches!(t.to_ascii_lowercase().as_str(), "1" | "on" | "true" | "yes")),
(Value::Number(_), Value::String(t)) => match t.trim().parse::<f64>() {
Ok(x) => serde_json::json!(x),
// Lineweight names.
Err(_) if matches!(field, "dimLineweight" | "extLineweight") => match t.trim().to_ascii_lowercase().as_str() {
"bylayer" => serde_json::json!(-1),
"byblock" => serde_json::json!(-2),
"default" => serde_json::json!(-3),
_ => v.clone(),
},
Err(_) => v.clone(),
},
(Value::Number(_), Value::Bool(b)) => serde_json::json!(u8::from(*b)),
(Value::String(_), Value::Number(n)) => Value::String(n.to_string()),
(Value::Object(_), Value::Number(n)) => {
let c = n.as_i64().and_then(|i| i16::try_from(i).ok()).map(Color::from_aci);
c.and_then(|c| serde_json::to_value(c).ok()).unwrap_or_else(|| v.clone())
}
(Value::Object(_), Value::String(t)) => Color::parse(t).and_then(|c| serde_json::to_value(c).ok()).unwrap_or_else(|| v.clone()),
_ => v.clone(),
}
}
/// Whether `coerced` is outside the range its `field` can take.
fn out_of_range(field: &str, coerced: &serde_json::Value) -> bool {
let x = coerced.as_f64();
match field {
"textHeight" | "tolScale" => x.is_some_and(|x| x <= 0.0),
"scale" | "arrowSize" | "tickSize" | "fixedExtLength" => x.is_some_and(|x| x < 0.0),
"fit" | "angularZeroSuppression" => x.is_some_and(|x| x > 3.0),
"textMove" | "tolJust" | "arcSymbol" | "textFill" => x.is_some_and(|x| x > 2.0),
"dimLineweight" | "extLineweight" => x.is_some_and(|x| !(-3.0..=211.0).contains(&x)),
"jogAngle" => x.is_some_and(|x| !(5.0..=90.0).contains(&x)),
_ => false,
}
}
/// Set fields from a JSON object of field or DIM* variable names (numbers, booleans as /// Set fields from a JSON object of field or DIM* variable names (numbers, booleans as
/// 0/1, colours as ACI numbers or names). Returns the names that were not recognised or /// 0/1, colours as ACI numbers or names). Returns the names that were not recognised or
/// had unusable values. /// had unusable values.
@@ -449,51 +501,29 @@ impl DimStyle {
rejected.push(k.clone()); rejected.push(k.clone());
continue; continue;
}; };
let coerced = match (&old, v) { let coerced = Self::coerce_value(&old, v, field);
(Value::Bool(_), Value::Number(n)) => Value::Bool(n.as_f64().is_some_and(|x| x != 0.0)),
(Value::Bool(_), Value::String(t)) => Value::Bool(matches!(t.to_ascii_lowercase().as_str(), "1" | "on" | "true" | "yes")),
(Value::Number(_), Value::String(t)) => match t.trim().parse::<f64>() {
Ok(x) => serde_json::json!(x),
// Lineweight names.
Err(_) if matches!(field, "dimLineweight" | "extLineweight") => match t.trim().to_ascii_lowercase().as_str() {
"bylayer" => serde_json::json!(-1),
"byblock" => serde_json::json!(-2),
"default" => serde_json::json!(-3),
_ => v.clone(),
},
Err(_) => v.clone(),
},
(Value::Number(_), Value::Bool(b)) => serde_json::json!(u8::from(*b)),
(Value::String(_), Value::Number(n)) => Value::String(n.to_string()),
(Value::Object(_), Value::Number(n)) => {
let c = n.as_i64().and_then(|i| i16::try_from(i).ok()).map(Color::from_aci);
c.and_then(|c| serde_json::to_value(c).ok()).unwrap_or_else(|| v.clone())
}
(Value::Object(_), Value::String(t)) => Color::parse(t).and_then(|c| serde_json::to_value(c).ok()).unwrap_or_else(|| v.clone()),
_ => v.clone(),
};
// Integers stored in u8 fields: round floats. // Integers stored in u8 fields: round floats.
let coerced = match (&old, &coerced) { let coerced = match (&old, &coerced) {
// Lineweights (i16): whole hundredths of a mm or -1/-2/-3. // Lineweights (i16): whole hundredths of a mm or -1/-2/-3.
(Value::Number(_), Value::Number(n)) if matches!(field, "dimLineweight" | "extLineweight") && !n.is_i64() => { (Value::Number(_), Value::Number(n)) if matches!(field, "dimLineweight" | "extLineweight") && !n.is_i64() => {
serde_json::json!(n.as_f64().filter(|x| x.is_finite()).unwrap_or(0.0).round().clamp(-3.0, 211.0) as i64) serde_json::json!(n.as_f64().filter(|x| x.is_finite()).unwrap_or(0.0).round() as i64)
} }
(Value::Number(_), Value::Number(_)) if matches!(field, "dimLineweight" | "extLineweight") => coerced,
// A value below 0 or one that rounds above 255 is refused; DIMADEC -1 reads as 0.
(Value::Number(o), Value::Number(n)) if o.is_u64() && !n.is_u64() => { (Value::Number(o), Value::Number(n)) if o.is_u64() && !n.is_u64() => {
serde_json::json!(n.as_f64().filter(|x| x.is_finite()).unwrap_or(0.0).round().clamp(0.0, 255.0) as u64) let min = if field == "angularDecimals" { -1.0 } else { 0.0 };
match n.as_f64().filter(|x| x.is_finite() && *x >= min).map(f64::round).filter(|x| *x <= 255.0) {
Some(x) => serde_json::json!(x.max(0.0) as u64),
None => {
rejected.push(k.clone());
continue;
}
}
} }
_ => coerced, _ => coerced,
}; };
// Sizes that can't be negative (text height and tolerance scale: not zero either). // Sizes that can't be negative (text height and tolerance scale: not zero either).
let x = coerced.as_f64(); let out_of_range = Self::out_of_range(field, &coerced);
let out_of_range = match field {
"textHeight" | "tolScale" => x.is_some_and(|x| x <= 0.0),
"scale" | "arrowSize" | "tickSize" | "fixedExtLength" => x.is_some_and(|x| x < 0.0),
"fit" | "angularZeroSuppression" => x.is_some_and(|x| x > 3.0),
"textMove" | "tolJust" | "arcSymbol" => x.is_some_and(|x| x > 2.0),
"dimLineweight" | "extLineweight" => x.is_some_and(|x| !(-3.0..=211.0).contains(&x)),
"jogAngle" => x.is_some_and(|x| !(5.0..=90.0).contains(&x)),
_ => false,
};
if out_of_range { if out_of_range {
rejected.push(k.clone()); rejected.push(k.clone());
continue; continue;
@@ -752,6 +782,8 @@ pub struct PageSetup {
pub landscape: bool, pub landscape: bool,
/// "layout" | "extents" | "display" | "window" | "limits". /// "layout" | "extents" | "display" | "window" | "limits".
pub plot_area: String, pub plot_area: String,
/// Plot window corners `[x1, y1, x2, y2]` (DXF plot layout groups 48, 49, 140, 141); used when `plot_area` is "window".
pub plot_window: [f64; 4],
pub scale_to_fit: bool, pub scale_to_fit: bool,
/// Paper units per drawing unit. /// Paper units per drawing unit.
pub scale: f64, pub scale: f64,
@@ -779,6 +811,7 @@ impl Default for PageSetup {
margins_mm: [6.35, 6.35, 6.35, 6.35], margins_mm: [6.35, 6.35, 6.35, 6.35],
landscape: true, landscape: true,
plot_area: "layout".into(), plot_area: "layout".into(),
plot_window: [0.0; 4],
scale_to_fit: false, scale_to_fit: false,
scale: 1.0, scale: 1.0,
plot_style_table: String::new(), plot_style_table: String::new(),
@@ -854,7 +887,7 @@ pub struct VportConfig {
impl VportConfig { impl VportConfig {
/// The name of the configuration shown. /// The name of the configuration shown.
pub const ACTIVE: &'static str = "*Active"; pub const ACTIVE: &str = "*Active";
pub fn is_active(&self) -> bool { pub fn is_active(&self) -> bool {
self.name.eq_ignore_ascii_case(Self::ACTIVE) self.name.eq_ignore_ascii_case(Self::ACTIVE)
} }
+4 -4
View File
@@ -109,9 +109,9 @@ impl Default for CellStyle {
} }
impl CellStyle { impl CellStyle {
pub const TITLE: &'static str = "Title"; pub const TITLE: &str = "Title";
pub const HEADER: &'static str = "Header"; pub const HEADER: &str = "Header";
pub const DATA: &'static str = "Data"; pub const DATA: &str = "Data";
/// Whether `name` is one of the three styles every table style has. /// Whether `name` is one of the three styles every table style has.
pub fn is_builtin(name: &str) -> bool { pub fn is_builtin(name: &str) -> bool {
@@ -190,7 +190,7 @@ impl TableStyle {
/// The names of its cell styles: Title, Header, Data, then the custom ones. /// The names of its cell styles: Title, Header, Data, then the custom ones.
pub fn cell_style_names(&self) -> Vec<String> { pub fn cell_style_names(&self) -> Vec<String> {
let mut out: Vec<String> = [CellStyle::TITLE, CellStyle::HEADER, CellStyle::DATA].iter().map(|s| s.to_string()).collect(); let mut out: Vec<String> = [CellStyle::TITLE, CellStyle::HEADER, CellStyle::DATA].iter().map(ToString::to_string).collect();
out.extend(self.cell_styles.iter().filter(|c| !CellStyle::is_builtin(&c.name)).map(|c| c.name.clone())); out.extend(self.cell_styles.iter().filter(|c| !CellStyle::is_builtin(&c.name)).map(|c| c.name.clone()));
out out
} }
+2
View File
@@ -0,0 +1,2 @@
mod lin_pat_parse;
mod sub_segment_alias;
+1 -1
View File
@@ -10,4 +10,4 @@ workspace = true
[target.'cfg(not(target_arch = "wasm32"))'.dependencies] [target.'cfg(not(target_arch = "wasm32"))'.dependencies]
# MPL-2.0: used as an unmodified dependency only (see plan/adr/0001 and NOTICE). # MPL-2.0: used as an unmodified dependency only (see plan/adr/0001 and NOTICE).
acadrust = "0.6.4" acadrust = "0.6.5"
+141 -18
View File
@@ -4,6 +4,25 @@
//! converted to DXF bytes, which CADCraft's own DXF reader maps to its document model; saving //! converted to DXF bytes, which CADCraft's own DXF reader maps to its document model; saving
//! goes the other way. Keeping the dependency behind this byte-level API isolates it: nothing //! goes the other way. Keeping the dependency behind this byte-level API isolates it: nothing
//! else in CADCraft depends on its types. Native targets only (it memory-maps files). //! else in CADCraft depends on its types. Native targets only (it memory-maps files).
//!
//! # Guardrails around the third-party decoder
//!
//! The decoder allocates tables and section buffers from sizes a (possibly damaged or hostile)
//! file declares, and once it starts it cannot be interrupted (an allocation failure aborts the
//! process, which `catch_unwind` cannot recover). Because the dependency is used unmodified, this
//! crate guards it from the outside:
//!
//! - Both directions reject oversized input *before* the decoder runs ([`Limits::max_dwg_bytes`]
//! for DWG→DXF, [`Limits::max_dxf_bytes`] for DXF→DWG), and the DWG→DXF output is capped while
//! it is written (`CappedWriter`).
//! - The reader and writer run inside `catch_unwind`, so a decode failure is reported instead of
//! crashing.
//!
//! These bound the input- and output-size variants. A small file that declares a huge internal
//! table or section can still make the dependency allocate before any size is known; fully fixing
//! that requires an upstream fix in `acadrust` (validate declared sizes against a decoded-memory
//! budget) or running the decode in a separate worker with an OS memory limit. The limits here
//! keep the blast radius bounded in the meantime.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
@@ -88,7 +107,6 @@ mod native {
current_style_names(&mut doc); current_style_names(&mut doc);
table_merges(&mut doc); table_merges(&mut doc);
table_style_rows(&mut doc, false); table_style_rows(&mut doc, false);
dimension_flags(&mut doc, false);
let orphans = drop_orphaned_dgn_components(&mut doc); let orphans = drop_orphaned_dgn_components(&mut doc);
if orphans > 0 { if orphans > 0 {
let (s, were) = if orphans == 1 { ("", "was") } else { ("s", "were") }; let (s, were) = if orphans == 1 { ("", "was") } else { ("s", "were") };
@@ -272,24 +290,14 @@ mod native {
} }
} }
/// The DWG dimension flags byte. Its bit 0 is the opposite of DXF group 70's bit 128 (set: the /// The DWG dimension flags byte. Its bit 1 is DXF group 70's bit 32 (ODA DWG specification,
/// text is at its default position) and its bit 1 is group 70's bit 32 (ODA DWG /// common dimension data), which acadrust keeps no copy of from DXF: it is set here as
/// specification, common dimension data), while acadrust 0.6.4 reads and writes bit 0 as /// CADCraft's DXF writer always sets it (#1094). Bit 0, the opposite of group 70's bit 128,
/// `text_user_positioned` itself and keeps no bit 32 from DXF. Without this, every dimension /// acadrust 0.6.5 reads and writes itself.
/// of a DWG CADCraft writes is user-positioned for AutoCAD (#1094), and every dimension of an fn dimension_flags(doc: &mut acadrust::CadDocument) {
/// AutoCAD DWG is read as user-positioned. `to_dwg` sets the fields up for the writer:
/// bit 32 as CADCraft's DXF writer always sets it, and bit 0 through the inverted field;
/// otherwise the field is read from bit 0 after a DWG is read.
fn dimension_flags(doc: &mut acadrust::CadDocument, to_dwg: bool) {
for e in doc.entities_mut() { for e in doc.entities_mut() {
let acadrust::entities::EntityType::Dimension(d) = e else { continue }; let acadrust::entities::EntityType::Dimension(d) = e else { continue };
let b = d.base_mut(); d.base_mut().dwg_flags_byte |= 0x02;
if to_dwg {
b.dwg_flags_byte |= 0x02;
b.text_user_positioned = !b.text_user_positioned;
} else {
b.text_user_positioned = b.dwg_flags_byte & 0x01 == 0;
}
} }
} }
@@ -494,7 +502,28 @@ mod native {
} }
} }
/// The current text style (`$TEXTSTYLE`) by its name, the same way as [`current_dimstyle`]:
/// the handle read with the DXF needn't be the style's in the document, in which case the DWG
/// writer fell back to Standard and AutoCAD opened the drawing with Standard current (#1650).
fn current_text_style(doc: &mut acadrust::CadDocument) {
let name = doc.header.current_text_style_name.clone();
if let Some(h) = doc.text_styles.iter().find(|ts| ts.name.eq_ignore_ascii_case(&name)).map(|ts| ts.handle) {
doc.header.current_text_style_handle = h;
}
}
fn convert(dxf: &[u8], version: Option<acadrust::DxfVersion>) -> Result<Vec<u8>, String> { fn convert(dxf: &[u8], version: Option<acadrust::DxfVersion>) -> Result<Vec<u8>, String> {
// Reject a pathologically large DXF before handing it to the third-party decoder, which
// allocates tables and section buffers from the input's declared sizes and cannot be
// interrupted once it starts (CWE-789). This bounds the large-input variant; the decoder
// runs inside `catch_unwind` so a decode failure is reported rather than crashing.
if dxf.len() > Limits::DEFAULT.max_dxf_bytes {
return Err(format!(
"DXF: the input is {}, larger than the {} CADCraft converts to DWG",
mb(dxf.len() as u64),
mb(Limits::DEFAULT.max_dxf_bytes as u64)
));
}
let data = dxf.to_vec(); let data = dxf.to_vec();
let mut doc = std::panic::catch_unwind(move || acadrust::DxfReader::from_reader(Cursor::new(data)).and_then(|r| r.read())) let mut doc = std::panic::catch_unwind(move || acadrust::DxfReader::from_reader(Cursor::new(data)).and_then(|r| r.read()))
.map_err(|_| "the DXF→DWG conversion failed".to_string())? .map_err(|_| "the DXF→DWG conversion failed".to_string())?
@@ -509,14 +538,83 @@ mod native {
} }
other_layout_owners(&mut doc); other_layout_owners(&mut doc);
table_style_rows(&mut doc, true); table_style_rows(&mut doc, true);
dimension_flags(&mut doc, true); dimension_flags(&mut doc);
drop_unwritable(&mut doc); drop_unwritable(&mut doc);
current_dimstyle(&mut doc); current_dimstyle(&mut doc);
add_variable_dictionary(&mut doc);
current_text_style(&mut doc);
std::panic::catch_unwind(AssertUnwindSafe(|| acadrust::DwgWriter::write_to_vec(&doc))) std::panic::catch_unwind(AssertUnwindSafe(|| acadrust::DwgWriter::write_to_vec(&doc)))
.map_err(|_| "the DWG writer failed on this drawing".to_string())? .map_err(|_| "the DWG writer failed on this drawing".to_string())?
.map_err(|e| format!("DWG write: {e}")) .map_err(|e| format!("DWG write: {e}"))
} }
/// AutoCAD's entry name for the drawing variables dictionary (the DXF Reference calls it the
/// ACDBVARIABLEDICTIONARY; dictionary keys are case-insensitive).
const VARIABLE_DICTIONARY: &str = "AcDbVariableDictionary";
/// A style or scale name, or `default` when the drawing has none.
fn name_or_default(name: &str, default: &str) -> String {
let name = name.trim();
if name.is_empty() { default.to_string() } else { name.to_string() }
}
/// A drawing variable's value as text, or `default` when the header holds a value the
/// variable doesn't accept (a hostile file can put any integer in it).
fn variable_value(value: i16, valid: &[i16], default: i16) -> String {
if valid.contains(&value) { value.to_string() } else { default.to_string() }
}
/// Add the drawing variables dictionary AutoCAD always writes into the named object
/// dictionary, with the variables it keeps there, when the drawing has none (#1813).
/// Vectric Aspire 10.5 reads a 2004 (AC1018) DWG without it as empty ("No data was imported
/// from the DXF / DWG file"), though it reads the same drawing as DXF — which has no such
/// dictionary either, as CADCraft's and ezdxf's writers don't make one. AutoCAD's AC1032 DWGs
/// read there without it, so newer output is left as it was, and a dictionary the drawing
/// does have keeps its own entries and values.
fn add_variable_dictionary(doc: &mut acadrust::CadDocument) {
use acadrust::objects::{Dictionary, DictionaryVariable, ObjectType};
if doc.version >= acadrust::DxfVersion::AC1032 {
return;
}
let root_handle = doc.header.named_objects_dict_handle;
match doc.objects.get(&root_handle) {
Some(ObjectType::Dictionary(root)) => {
if root.get(VARIABLE_DICTIONARY).is_some() {
return;
}
}
_ => return,
}
// The variables AutoCAD writes for a drawing with default styles and a 1:1 annotation
// scale. DIMASSOC and HIDETEXT keep the drawing's own header values; LAYEREVAL and
// LAYERNOTIFY have no DXF header variable.
let variables = [
("CMLEADERSTYLE", name_or_default(&doc.header.current_mleader_style_name, "Standard")),
("CTABLESTYLE", name_or_default(&doc.header.current_table_style_name, "Standard")),
("CANNOSCALE", name_or_default(&doc.header.current_annotation_scale, "1:1")),
("DIMASSOC", variable_value(doc.header.dimension_associativity, &[0, 1, 2], 2)),
("HIDETEXT", variable_value(doc.header.hide_text, &[0, 1], 1)),
("LAYEREVAL", "0".to_string()),
("LAYERNOTIFY", "0".to_string()),
];
let dictionary_handle = doc.allocate_handle();
let mut dictionary = Dictionary::new();
dictionary.handle = dictionary_handle;
dictionary.owner = root_handle;
for (name, value) in variables {
let handle = doc.allocate_handle();
let mut variable = DictionaryVariable::new(name, &value);
variable.handle = handle;
variable.owner_handle = dictionary_handle;
dictionary.add_entry(name, handle);
doc.objects.insert(handle, ObjectType::DictionaryVariable(variable));
}
doc.objects.insert(dictionary_handle, ObjectType::Dictionary(dictionary));
if let Some(ObjectType::Dictionary(root)) = doc.objects.get_mut(&root_handle) {
root.add_entry(VARIABLE_DICTIONARY, dictionary_handle);
}
}
/// Entities of a layout other than the one in `*Paper_Space` are written owned by their block /// Entities of a layout other than the one in `*Paper_Space` are written owned by their block
/// (entity mode 0, with its handle). acadrust writes every paper-space entity with mode 1, /// (entity mode 0, with its handle). acadrust writes every paper-space entity with mode 1,
/// "paper space", which AutoCAD reads as the `*Paper_Space` block's: everything drawn on a /// "paper space", which AutoCAD reads as the `*Paper_Space` block's: everything drawn on a
@@ -585,12 +683,37 @@ mod tests {
assert_eq!(version(b"AC1018xxxx").as_deref(), Some("AC1018")); assert_eq!(version(b"AC1018xxxx").as_deref(), Some("AC1018"));
} }
/// A DXF with ROMANS as `$TEXTSTYLE` gives a DWG whose current text style is ROMANS, not
/// Standard: the DXF names the style, and the handle read with it was Standard's (#1650).
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn current_text_style_reaches_the_dwg() {
let header = " 0\nSECTION\n 2\nHEADER\n 9\n$ACADVER\n 1\nAC1032\n 9\n$TEXTSTYLE\n 7\nROMANS\n 0\nENDSEC\n";
let style = " 0\nSTYLE\n 5\n1F0\n100\nAcDbSymbolTableRecord\n100\nAcDbTextStyleTableRecord\n 2\nROMANS\n 70\n0\n";
let style = format!("{style} 40\n0\n 41\n1\n 50\n0\n 71\n0\n 42\n2.5\n 3\nromans.shx\n 4\n\n");
let dxf = format!("{header} 0\nSECTION\n 2\nTABLES\n 0\nTABLE\n 2\nSTYLE\n 70\n1\n{style} 0\nENDTAB\n 0\nENDSEC\n 0\nEOF\n");
let dwg = dxf_to_dwg(dxf.as_bytes()).unwrap();
let doc = acadrust::DwgReader::from_stream(std::io::Cursor::new(&dwg)).read().unwrap();
let romans = doc.text_styles.iter().find(|ts| ts.name == "ROMANS").map(|ts| ts.handle);
assert!(romans.is_some(), "ROMANS is missing from the DWG");
assert_eq!(Some(doc.header.current_text_style_handle), romans);
}
#[test] #[test]
fn garbage_is_an_error_not_a_crash() { fn garbage_is_an_error_not_a_crash() {
assert!(dwg_to_dxf(b"AC1032 this is not a dwg file at all").is_err()); assert!(dwg_to_dxf(b"AC1032 this is not a dwg file at all").is_err());
assert!(dwg_to_dxf(b"").is_err()); assert!(dwg_to_dxf(b"").is_err());
} }
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn dxf_to_dwg_rejects_garbage_without_crashing() {
// The DXF→DWG path runs the third-party reader inside catch_unwind and reports failures
// rather than crashing (and rejects oversized input before decoding).
assert!(dxf_to_dwg(b"not a dxf file").is_err());
assert!(dxf_to_dwg(b"").is_err());
}
/// A DXF with one DIMENSION per (text, group 70, subclass, points) row. /// A DXF with one DIMENSION per (text, group 70, subclass, points) row.
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
fn dims_dxf(dims: &[(&str, i64, &str, &[(i32, [f64; 2])])]) -> String { fn dims_dxf(dims: &[(&str, i64, &str, &[(i32, [f64; 2])])]) -> String {
+23 -11
View File
@@ -413,8 +413,7 @@ pub fn write_ascii_as(tags: &[Tag], utf8: bool) -> String {
for t in tags { for t in tags {
let _ = write!(out, "{:>3}\r\n", t.code); let _ = write!(out, "{:>3}\r\n", t.code);
match &t.value { match &t.value {
Value::Str(s) if utf8 => out.extend(s.chars().map(|c| if matches!(c, '\r' | '\n') { ' ' } else { c })), Value::Str(s) => escape_unicode(s, utf8, &mut out),
Value::Str(s) => escape_unicode(s, &mut out),
Value::Real(r) => out.push_str(&format_real(*r)), Value::Real(r) => out.push_str(&format_real(*r)),
Value::Int(i) => out.push_str(&i.to_string()), Value::Int(i) => out.push_str(&i.to_string()),
Value::Bool(b) => out.push_str(&i64::from(*b).to_string()), Value::Bool(b) => out.push_str(&i64::from(*b).to_string()),
@@ -446,11 +445,7 @@ pub fn write_binary(tags: &[Tag], utf8: bool) -> Vec<u8> {
v => v.as_str(), v => v.as_str(),
}; };
let mut text = String::with_capacity(s.len()); let mut text = String::with_capacity(s.len());
if utf8 { escape_unicode(&s, utf8, &mut text);
text.extend(s.chars().map(|c| if matches!(c, '\r' | '\n') { ' ' } else { c }));
} else {
escape_unicode(&s, &mut text);
}
out.extend(text.bytes().filter(|&b| b != 0)); out.extend(text.bytes().filter(|&b| b != 0));
out.push(0); out.push(0);
} }
@@ -507,12 +502,16 @@ pub fn is_binary(bytes: &[u8]) -> bool {
/// Escape a string for a pre-R2007 DXF, whose text is in the `$DWGCODEPAGE` code page: ASCII /// Escape a string for a pre-R2007 DXF, whose text is in the `$DWGCODEPAGE` code page: ASCII
/// stays as it is and every other character becomes `\U+XXXX` (a surrogate pair of escapes /// stays as it is and every other character becomes `\U+XXXX` (a surrogate pair of escapes
/// above U+FFFF), so the bytes are valid in any code page. Line breaks become spaces. /// above U+FFFF), so the bytes are valid in any code page; with `utf8` they stay as they are.
fn escape_unicode(s: &str, out: &mut String) { /// A backslash that would start a `\U+` or `\M+` sequence is written as `\U+005C` so the reader
for c in s.chars() { /// gives back the literal text. Line breaks become spaces.
fn escape_unicode(s: &str, utf8: bool, out: &mut String) {
let chars: Vec<char> = s.chars().collect();
for (i, &c) in chars.iter().enumerate() {
match c { match c {
'\r' | '\n' => out.push(' '), '\r' | '\n' => out.push(' '),
c if c.is_ascii() => out.push(c), '\\' if matches!(chars.get(i + 1), Some('U' | 'M')) && chars.get(i + 2) == Some(&'+') => out.push_str("\\U+005C"),
c if utf8 || c.is_ascii() => out.push(c),
c => { c => {
let mut units = [0u16; 2]; let mut units = [0u16; 2];
for u in c.encode_utf16(&mut units) { for u in c.encode_utf16(&mut units) {
@@ -569,6 +568,19 @@ pub fn records(tags: &[Tag]) -> Vec<(String, Vec<Tag>)> {
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn literal_escape_text_roundtrips() {
for utf8 in [false, true] {
for text in ["\\U+00E9", "\\M+183A4F", "a\\nb \\P", "\\\\U+00E9", "\\U+005C"] {
let tags = vec![Tag::s(8, text)];
let back = parse_ascii(&write_ascii_as(&tags, utf8)).unwrap();
assert_eq!(back, tags, "utf8={utf8} {text}");
let back = parse_binary(&write_binary(&tags, utf8)).unwrap();
assert_eq!(back[1..], tags[..], "binary utf8={utf8} {text}");
}
}
}
#[test] #[test]
fn ascii_roundtrip() { fn ascii_roundtrip() {
let tags = vec![ let tags = vec![
+4 -1
View File
@@ -22,7 +22,10 @@ serde_json = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
log = { workspace = true } log = { workspace = true }
# R-tree spatial index for picking, window selection and object snaps (MIT OR Apache-2.0). # R-tree spatial index for picking, window selection and object snaps (MIT OR Apache-2.0).
rstar = { version = "0.12", default-features = false } rstar = { version = "0.13", default-features = false }
[target.'cfg(unix)'.dependencies]
libc = "0.2"
[dev-dependencies] [dev-dependencies]
# Real DXF files for the external reference tests (io is a lower layer). # Real DXF files for the external reference tests (io is a lower layer).
+163
View File
@@ -0,0 +1,163 @@
//! Safe, atomic file replacement.
//!
//! Save, export, plot, autosave and the audit log all write a file by first putting the bytes in
//! a sibling temporary file and then renaming it over the destination, so a failed or partial
//! write never truncates the real file. The temporary file must be created *exclusively*: opening
//! a predictable, pre-existing temporary path for a truncating write lets another local user plant
//! a symlink there and redirect the write onto a file the victim can write but the attacker chose
//! (CWE-59). [`write_atomic`] creates a fresh temporary file with `create_new` (`O_CREAT|O_EXCL`),
//! so an existing entry at that path — symlink or not — makes the create fail rather than follow
//! it. The handle is retained for the whole write, and only its own fresh file is renamed into
//! place.
#![cfg(not(target_arch = "wasm32"))]
use std::fs::{File, OpenOptions, Permissions};
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
/// Monotonic counter making temporary names unique within this process without a RNG dependency.
static SEQ: AtomicU64 = AtomicU64::new(0);
/// How many fresh temporary names to try before giving up (each collision is another local race).
const MAX_TRIES: u32 = 32;
/// Write `bytes` to `path`, replacing it atomically and without ever following a pre-existing
/// temporary symlink.
///
/// A fresh temporary file is created with exclusive semantics in `path`'s own directory, the bytes
/// are written through the retained handle and flushed, and the temporary is then renamed over
/// `path`. On any failure the temporary is removed and the original `path` is left untouched.
pub fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
let permissions = match std::fs::symlink_metadata(path) {
Ok(meta) if meta.is_file() => Some(meta.permissions()),
Ok(_) => None,
Err(e) if e.kind() == io::ErrorKind::NotFound => None,
Err(e) => return Err(e),
};
let dir = path.parent().filter(|p| !p.as_os_str().is_empty()).map_or_else(|| PathBuf::from("."), Path::to_path_buf);
let (mut file, tmp) = create_temp(&dir, permissions.as_ref())?;
let write = (|| {
if let Some(permissions) = permissions {
file.set_permissions(permissions)?;
}
file.write_all(bytes)?;
file.flush()?;
file.sync_all()
})();
if let Err(e) = write {
let _ = std::fs::remove_file(&tmp);
return Err(e);
}
drop(file);
if let Err(e) = std::fs::rename(&tmp, path) {
let _ = std::fs::remove_file(&tmp);
return Err(e);
}
Ok(())
}
/// Create a fresh, exclusively-owned temporary file inside `dir`, returning its handle and path.
///
/// `create_new(true)` opens with `O_CREAT | O_EXCL`, which refuses to follow or reuse an existing
/// entry (including a symlink), closing the plant-a-symlink race. Names are unpredictable enough to
/// avoid collisions in practice, and genuine collisions are retried with a new name.
fn create_temp(dir: &Path, permissions: Option<&Permissions>) -> io::Result<(File, PathBuf)> {
let pid = std::process::id();
let mut last_err = None;
for _ in 0..MAX_TRIES {
let seq = SEQ.fetch_add(1, Ordering::Relaxed);
let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.subsec_nanos()).unwrap_or(0);
let tmp = dir.join(format!(".cadcraft-tmp-{pid}-{seq}-{nanos}"));
match create_exclusive(&tmp, permissions) {
Ok(file) => return Ok((file, tmp)),
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {
last_err = Some(e);
continue;
}
Err(e) => return Err(e),
}
}
Err(last_err.unwrap_or_else(|| io::Error::new(io::ErrorKind::AlreadyExists, "could not create a unique temporary file")))
}
fn create_exclusive(path: &Path, permissions: Option<&Permissions>) -> io::Result<File> {
let mut options = OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
if let Some(permissions) = permissions {
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
// Restrict access in the create syscall: chmod afterwards leaves a window in which a
// different user can open the inode and retain read access to bytes written later.
options.mode(permissions.mode() & 0o777);
}
#[cfg(not(unix))]
let _ = permissions;
options.open(path)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn replaces_contents_atomically() {
let dir = std::env::temp_dir().join(format!("cadcraft-aw-{}-{}", std::process::id(), SEQ.fetch_add(1, Ordering::Relaxed)));
std::fs::create_dir_all(&dir).unwrap();
let dest = dir.join("out.bin");
std::fs::write(&dest, b"old").unwrap();
write_atomic(&dest, b"new bytes").unwrap();
assert_eq!(std::fs::read(&dest).unwrap(), b"new bytes");
// No temporary files left behind.
let leftovers: Vec<_> =
std::fs::read_dir(&dir).unwrap().flatten().map(|e| e.file_name()).filter(|n| n.to_string_lossy().starts_with(".cadcraft-tmp")).collect();
assert!(leftovers.is_empty(), "left temp files: {leftovers:?}");
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(unix)]
#[test]
fn does_not_follow_a_planted_temp_symlink() {
use std::os::unix::fs::symlink;
let dir = std::env::temp_dir().join(format!("cadcraft-aw-sym-{}-{}", std::process::id(), SEQ.fetch_add(1, Ordering::Relaxed)));
std::fs::create_dir_all(&dir).unwrap();
let victim = dir.join("victim");
std::fs::write(&victim, b"precious").unwrap();
let planted = dir.join(".cadcraft-tmp-plant");
symlink(&victim, &planted).unwrap();
assert_eq!(create_exclusive(&planted, None).unwrap_err().kind(), io::ErrorKind::AlreadyExists);
let dest = dir.join("drawing.dxf");
write_atomic(&dest, b"drawing bytes").unwrap();
assert_eq!(std::fs::read(&victim).unwrap(), b"precious", "victim file was overwritten");
assert_eq!(std::fs::read(&dest).unwrap(), b"drawing bytes");
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(unix)]
#[test]
fn replacement_preserves_private_permissions() {
use std::os::unix::fs::PermissionsExt;
let dir = std::env::temp_dir().join(format!("cadcraft-aw-mode-{}-{}", std::process::id(), SEQ.fetch_add(1, Ordering::Relaxed)));
std::fs::create_dir_all(&dir).unwrap();
let dest = dir.join("private.adt");
std::fs::write(&dest, b"old").unwrap();
std::fs::set_permissions(&dest, std::fs::Permissions::from_mode(0o600)).unwrap();
write_atomic(&dest, b"private report").unwrap();
assert_eq!(std::fs::metadata(&dest).unwrap().permissions().mode() & 0o777, 0o600);
assert_eq!(std::fs::read(&dest).unwrap(), b"private report");
std::fs::remove_dir_all(dir).unwrap();
}
#[cfg(unix)]
#[test]
fn private_temp_is_restricted_from_creation() {
use std::os::unix::fs::PermissionsExt;
let dir = std::env::temp_dir().join(format!("cadcraft-aw-create-mode-{}-{}", std::process::id(), SEQ.fetch_add(1, Ordering::Relaxed)));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("private-temp");
let permissions = Permissions::from_mode(0o600);
let file = create_exclusive(&path, Some(&permissions)).unwrap();
assert_eq!(file.metadata().unwrap().permissions().mode() & 0o077, 0);
drop(file);
std::fs::remove_dir_all(dir).unwrap();
}
}
+29 -9
View File
@@ -208,6 +208,12 @@ impl Autosaver {
if minutes == 0 || dir.trim().is_empty() || (!urgent && now - since < f64::from(minutes) * 60.0) { if minutes == 0 || dir.trim().is_empty() || (!urgent && now - since < f64::from(minutes) * 60.0) {
continue; continue;
} }
// In the Block Editor: the drawing with the editor's unsaved changes in the block. When
// they can't be put there, nothing is written and nothing is marked as covered, so the
// next pass tries again instead of keeping the host without them.
let Ok(doc) = st.recovery_drawing() else {
continue;
};
let path = t let path = t
.file .file
.clone() .clone()
@@ -216,8 +222,7 @@ impl Autosaver {
t.written = Some(Arc::downgrade(&st.doc)); t.written = Some(Arc::downgrade(&st.doc));
t.host = Some(Arc::downgrade(st.drawing())); t.host = Some(Arc::downgrade(st.drawing()));
t.since = None; t.since = None;
// In the Block Editor: the drawing with the editor's unsaved changes in the block. jobs.push(Job::Write { uid: st.uid, path, doc });
jobs.push(Job::Write { uid: st.uid, path, doc: st.recovery_drawing() });
} }
jobs jobs
} }
@@ -251,7 +256,10 @@ mod native {
use std::path::Path; use std::path::Path;
use std::time::{Duration, SystemTime}; use std::time::{Duration, SystemTime};
use super::*; use super::{
Autosaver, Drawing, HEARTBEAT_SECS, HashMap, Job, Recoverable, STALE_LOCK_SECS, Session, is_instance_id, parse_file_name, parse_lock_name,
savefilepath, savetime,
};
/// A new instance id: the process id and the start time. /// A new instance id: the process id and the start time.
pub fn instance_id() -> String { pub fn instance_id() -> String {
@@ -302,7 +310,7 @@ mod native {
/// files of stopped apps that have no files left. Only an autosave file is deleted: a path /// files of stopped apps that have no files left. Only an autosave file is deleted: a path
/// whose name doesn't parse as one ([`parse_file_name`]) is left alone. /// whose name doesn't parse as one ([`parse_file_name`]) is left alone.
pub fn discard(path: &str) { pub fn discard(path: &str) {
let ours = Path::new(path).file_name().and_then(|n| n.to_str()).is_some_and(|n| parse_file_name(n).is_some()); let ours = Path::new(path).file_name().and_then(std::ffi::OsStr::to_str).is_some_and(|n| parse_file_name(n).is_some());
if !ours { if !ours {
log::warn!("autosave: not deleting {path}: not an autosave file"); log::warn!("autosave: not deleting {path}: not an autosave file");
return; return;
@@ -334,14 +342,26 @@ mod native {
type WriteHook = fn(&Drawing, &str) -> std::result::Result<Vec<u8>, String>; type WriteHook = fn(&Drawing, &str) -> std::result::Result<Vec<u8>, String>;
/// Create the autosave folder `dir` and any missing parents. On Unix the folders made here are
/// owner-only (`0o700`): autosave files are unsaved drawings, and with the default mode other
/// accounts on the machine could read them. A folder that already exists keeps its mode, since
/// SAVEFILEPATH can be any folder the user picked.
fn create_folder(dir: &Path) -> std::io::Result<()> {
let mut builder = std::fs::DirBuilder::new();
builder.recursive(true);
#[cfg(unix)]
std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700);
builder.create(dir)
}
fn write_file(write: WriteHook, doc: &Drawing, path: &str) -> std::result::Result<(), String> { fn write_file(write: WriteHook, doc: &Drawing, path: &str) -> std::result::Result<(), String> {
let bytes = write(doc, "autosave.dxf")?; let bytes = write(doc, "autosave.dxf")?;
if let Some(dir) = Path::new(path).parent() { if let Some(dir) = Path::new(path).parent() {
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?; create_folder(dir).map_err(|e| e.to_string())?;
} }
let tmp = format!("{path}.tmp"); // Create a fresh temporary file exclusively and rename it into place, so a planted
std::fs::write(&tmp, bytes).map_err(|e| e.to_string())?; // `<path>.tmp` symlink in a shared autosave directory cannot redirect the write (CWE-59).
std::fs::rename(&tmp, path).map_err(|e| e.to_string()) crate::atomic_write::write_atomic(Path::new(path), &bytes).map_err(|e| e.to_string())
} }
impl Autosaver { impl Autosaver {
@@ -434,7 +454,7 @@ mod native {
} }
self.last_beat = Some(now); self.last_beat = Some(now);
let dir = Path::new(dir); let dir = Path::new(dir);
let r = std::fs::create_dir_all(dir).and_then(|()| std::fs::write(lock_path(dir, &self.instance), std::process::id().to_string())); let r = create_folder(dir).and_then(|()| std::fs::write(lock_path(dir, &self.instance), std::process::id().to_string()));
if let Err(e) = r { if let Err(e) = r {
log::warn!("autosave: lock file in {}: {e}", dir.display()); log::warn!("autosave: lock file in {}: {e}", dir.display());
} }
+44 -5
View File
@@ -144,6 +144,11 @@ impl BlockEdit {
} }
let host = &self.host.doc; let host = &self.host.doc;
let mut d = editor.clone(); let mut d = editor.clone();
// When the block is saved under another name, the edited block keeps the drawing's
// description and block `name` gets the description changed in the editor.
if !name.eq_ignore_ascii_case(&self.name) {
keep_description(&mut d, host, &self.name);
}
d.model = host.model.clone(); d.model = host.model.clone();
d.layouts = host.layouts.clone(); d.layouts = host.layouts.clone();
d.groups = host.groups.clone(); d.groups = host.groups.clone();
@@ -171,6 +176,21 @@ impl BlockEdit {
} }
} }
/// Give block `name` of `d` the description `host` has for it, or, when `host` has no such block,
/// drop the empty definition a description gives a block the editor started and never saved.
fn keep_description(d: &mut Drawing, host: &Drawing, name: &str) {
match host.block(name) {
Some(h) => {
if let Some((_, b)) = d.blocks.iter_mut().find(|(k, _)| k.eq_ignore_ascii_case(name))
&& b.description != h.description
{
Arc::make_mut(b).description = h.description.clone();
}
}
None => d.blocks.retain(|k, b| !(k.eq_ignore_ascii_case(name) && b.entities.is_empty())),
}
}
impl DocState { impl DocState {
/// The drawing as a file saves it: outside the Block Editor's changes while it is open. /// The drawing as a file saves it: outside the Block Editor's changes while it is open.
pub fn drawing(&self) -> &Arc<Drawing> { pub fn drawing(&self) -> &Arc<Drawing> {
@@ -195,12 +215,13 @@ impl DocState {
self.block_edit.as_ref().is_some_and(|b| !Arc::ptr_eq(&b.saved, &self.doc)) self.block_edit.as_ref().is_some_and(|b| !Arc::ptr_eq(&b.saved, &self.doc))
} }
/// The drawing to keep for recovery (autosave): with the Block Editor's unsaved changes in the /// The drawing to keep for recovery (autosave): with the Block Editor's unsaved changes in the
/// block, so a crash doesn't lose them. /// block, so a crash doesn't lose them. An error when the editor's changes can't be put in the
pub fn recovery_drawing(&self) -> Arc<Drawing> { /// block (it would contain itself): the host drawing alone would lose them.
pub fn recovery_drawing(&self) -> Result<Arc<Drawing>> {
match &self.block_edit { match &self.block_edit {
Some(be) if self.block_unsaved() => be.compose(&self.doc, &be.name).map(Arc::new).unwrap_or_else(|_| be.host.doc.clone()), Some(be) if self.block_unsaved() => be.compose(&self.doc, &be.name).map(Arc::new),
Some(be) => be.host.doc.clone(), Some(be) => Ok(be.host.doc.clone()),
None => self.doc.clone(), None => Ok(self.doc.clone()),
} }
} }
@@ -271,6 +292,24 @@ impl DocState {
let d = be.compose(&self.doc, &name)?; let d = be.compose(&self.doc, &name)?;
let original = d.block(&name).map(|b| b.entities.clone()).unwrap_or_default(); let original = d.block(&name).map(|b| b.entities.clone()).unwrap_or_default();
let name = d.block(&name).map(|b| b.name.clone()).unwrap_or(name); let name = d.block(&name).map(|b| b.name.clone()).unwrap_or(name);
// When BSAVEAS saves the block under another name, the editor's drawing and each undo and
// redo step get the block as saved and the edited block as the drawing has it, so a later
// save and the description shown start from them after an undo too.
if !name.eq_ignore_ascii_case(&be.name)
&& let Some(b) = d.block(&name).cloned()
{
let was = be.name.clone();
let reconcile = |doc: &mut Arc<Drawing>| {
let editor = Arc::make_mut(doc);
keep_description(editor, &d, &was);
editor.blocks.retain(|k, _| !k.eq_ignore_ascii_case(&name));
editor.blocks.insert(name.clone(), b.clone());
};
reconcile(&mut self.doc);
for step in self.undo.iter_mut().chain(self.redo.iter_mut()) {
reconcile(&mut step.doc);
}
}
let editor = self.doc.clone(); let editor = self.doc.clone();
let Some(be) = &mut self.block_edit else { return Ok(()) }; let Some(be) = &mut self.block_edit else { return Ok(()) };
let before = be.host.doc.clone(); let before = be.host.doc.clone();
+84
View File
@@ -0,0 +1,84 @@
//! Bounded reads of local files.
//!
//! Reading a path with [`std::fs::read`] buffers until EOF, so a path naming an endless readable
//! device (for example `/dev/urandom` or a FIFO) exhausts process memory before any size check
//! runs (CWE-400). [`read_capped`] opens the path once, refuses anything that is not a regular
//! file, and reads through a reader limited to `max + 1` bytes so oversized input is rejected
//! before it is fully buffered.
#![cfg(not(target_arch = "wasm32"))]
use std::fs::OpenOptions;
use std::io::{self, Read};
use std::path::Path;
/// Read at most `max` bytes from the regular file at `path`.
///
/// Returns an error if the path is not a regular file, cannot be opened, or holds more than `max`
/// bytes. At most `max + 1` bytes are ever buffered, so an endless device cannot exhaust memory.
pub fn read_capped(path: &Path, max: usize) -> io::Result<Vec<u8>> {
let mut options = OpenOptions::new();
options.read(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.custom_flags(libc::O_NONBLOCK);
}
let file = options.open(path)?;
let meta = file.metadata()?;
if !meta.is_file() {
return Err(io::Error::new(io::ErrorKind::InvalidInput, "not a regular file"));
}
// Read one byte past the limit so we can tell "exactly max" from "too big" without trusting
// the metadata length (which a device may misreport).
let mut buf = Vec::new();
let cap = (max as u64).saturating_add(1);
let read = file.take(cap).read_to_end(&mut buf)?;
if read as u64 > max as u64 {
return Err(io::Error::new(io::ErrorKind::InvalidData, format!("file is larger than the {max}-byte limit")));
}
Ok(buf)
}
#[cfg(test)]
mod tests {
use super::*;
/// Regular-file reads accept the byte boundary and reject any larger payload.
#[test]
fn reads_within_limit_and_rejects_oversize() {
let dir = std::env::temp_dir().join(format!("cadcraft-cr-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let p = dir.join("f.bin");
std::fs::write(&p, vec![7u8; 100]).unwrap();
assert_eq!(read_capped(&p, 100).unwrap().len(), 100);
assert_eq!(read_capped(&p, 200).unwrap().len(), 100);
assert!(read_capped(&p, 99).is_err());
assert_eq!(read_capped(&p, usize::MAX).unwrap().len(), 100);
std::fs::remove_dir_all(&dir).ok();
}
/// An endless device is rejected before its contents can be buffered.
#[cfg(unix)]
#[test]
fn rejects_non_regular_file() {
// /dev/zero is an endless readable device; it must be rejected, not buffered.
let p = Path::new("/dev/zero");
if p.exists() {
assert!(read_capped(p, 8 << 20).is_err());
}
}
/// Opening a FIFO without a writer returns an error without waiting for a peer.
#[cfg(unix)]
#[test]
fn rejects_fifo_without_a_writer() {
let dir = std::env::temp_dir().join(format!("cadcraft-cr-fifo-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let fifo = dir.join("pipe");
assert!(std::process::Command::new("mkfifo").arg(&fifo).status().unwrap().success());
let started = std::time::Instant::now();
assert_eq!(read_capped(&fifo, 1024).unwrap_err().kind(), io::ErrorKind::InvalidInput);
assert!(started.elapsed() < std::time::Duration::from_secs(2));
std::fs::remove_dir_all(dir).unwrap();
}
}
@@ -33,6 +33,7 @@ pub mod plotters;
mod props; mod props;
pub use draw2::breakline::BreaklineOptions; pub use draw2::breakline::BreaklineOptions;
pub use props::MatchProps; pub use props::MatchProps;
pub(crate) use props::{current_color_value, set_current_color_value};
mod qselect; mod qselect;
mod selecttools; mod selecttools;
mod settings; mod settings;
+2 -1
View File
@@ -7,7 +7,8 @@ use cadcraft_doc::{AnnoScale, Drawing, EntityKind, Handle, MAX_SCALES, is_annota
use serde_json::{Value, json}; use serde_json::{Value, json};
use super::machines::{SelOutcome, SelectPhase, SelectRun}; use super::machines::{SelOutcome, SelectPhase, SelectRun};
use super::*; use super::{CommandSpec, bad, f64_req, targets};
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step}; use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
pub fn specs() -> Vec<CommandSpec> { pub fn specs() -> Vec<CommandSpec> {
File diff suppressed because it is too large Load Diff
+333
View File
@@ -0,0 +1,333 @@
use super::common::{DimText, add_dim, assoc_enabled, dim, hex_param, pick_at, segment_at, with_assoc};
use super::{
Accept, AssocSnap, DimAssoc, DimKind, EntityKind, Handle, Input, Interactive, Prompt, Result, Segment, Session, Step, Value, Vec2, bad, json,
point_req, targets, v3,
};
/// Two-line angular dimension (type 2) between two lines, attached to them.
fn angular_from_lines(s: &Session, h1: Handle, h2: Handle, at: Vec2) -> Option<EntityKind> {
let d = s.doc().ok()?;
let line = |h: Handle| match &d.entity(h)?.kind {
EntityKind::Line(l) => Some((l.a.xy(), l.b.xy())),
_ => None,
};
let ((a1, a2), (b1, b2)) = (line(h1)?, line(h2)?);
let (v, _, _) = cadcraft_geom::line_line_infinite(a1, a2, b1, b2)?;
// Use the leg ends on the side of the picked arc location (the quadrant the user meant).
let pick = |p: Vec2, q: Vec2, snap_p: AssocSnap, snap_q: AssocSnap| {
let dir = if (p - v).len() > (q - v).len() { (p, snap_p) } else { (q, snap_q) };
let other = if dir.1 == AssocSnap::Start { (q, AssocSnap::End) } else { (p, AssocSnap::Start) };
let side = |x: Vec2| (x - v).normalized().dot((at - v).normalized());
if side(other.0) > side(dir.0) && other.0.dist(v) > 1e-9 { other } else { dir }
};
let (p1, s1) = pick(a1, a2, AssocSnap::Start, AssocSnap::End);
let (p2, s2) = pick(b1, b2, AssocSnap::Start, AssocSnap::End);
// A two-line angular dimension (type 2): line 1 is p13 -> p14, line 2 is defpt -> p15, p16 is on the arc.
// The picked leg end goes last in each pair, so the renderer measures between p14 and p15.
let (o1, t1) = if s1 == AssocSnap::Start { (a2, AssocSnap::End) } else { (a1, AssocSnap::Start) };
let (o2, t2) = if s2 == AssocSnap::Start { (b2, AssocSnap::End) } else { (b1, AssocSnap::Start) };
let mut k = dim(s, DimKind::Angular, o2, o1, p1, p2, "");
if let EntityKind::Dimension(d) = &mut k {
d.p16 = v3(at);
}
let mut assoc = Vec::new();
if assoc_enabled(s) {
assoc = vec![
DimAssoc { point: "p13".into(), handle: h1, snap: t1 },
DimAssoc { point: "p14".into(), handle: h1, snap: s1 },
DimAssoc { point: "defpt".into(), handle: h2, snap: t2 },
DimAssoc { point: "p15".into(), handle: h2, snap: s2 },
];
}
Some(with_assoc(k, assoc))
}
pub(super) fn run_angular(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(ls) = p.get("lines").and_then(Value::as_array) {
let hs: Vec<Handle> = ls.iter().filter_map(|v| v.as_str().and_then(Handle::parse_hex).or_else(|| v.as_u64().map(Handle))).collect();
let (Some(h1), Some(h2)) = (hs.first(), hs.get(1)) else { return Err(bad("dimangular", "`lines` needs two line handles")) };
let at = point_req("dimangular", p, "at")?;
let k = angular_from_lines(s, *h1, *h2, at).ok_or_else(|| bad("dimangular", "objects must be two non-parallel lines"))?;
return Ok(json!({ "handle": add_dim(s, k)?.hex() }));
}
if let Some(h) = hex_param(p, "arc") {
let at = point_req("dimangular", p, "at")?;
let a = match s.doc()?.entity(h).map(|e| e.kind.clone()) {
Some(EntityKind::Arc(a)) => a,
_ => return Err(bad("dimangular", "object is not an arc")),
};
let g = cadcraft_geom::Arc::new(a.center.xy(), a.radius, a.start, a.end);
let k = dim(s, DimKind::Angular3P, at, g.start_point(), g.end_point(), g.center, "");
return Ok(json!({ "handle": add_dim(s, k)?.hex() }));
}
let v = point_req("dimangular", p, "vertex")?;
let a = point_req("dimangular", p, "p1")?;
let b = point_req("dimangular", p, "p2")?;
let at = point_req("dimangular", p, "at")?;
if v.near(a, 1e-12) || v.near(b, 1e-12) {
return Err(bad("dimangular", "`p1` and `p2` must differ from `vertex`"));
}
let k = dim(s, DimKind::Angular3P, at, a, b, v, "");
Ok(json!({ "handle": add_dim(s, k)?.hex() }))
}
pub(super) fn run_arclen(s: &mut Session, p: &Value) -> Result<Value> {
let h = targets(s, p)?.first().copied().ok_or_else(|| bad("dimarc", "`handle` of an arc is required"))?;
let at = point_req("dimarc", p, "at")?;
let a = match s.doc()?.entity(h).map(|e| e.kind.clone()) {
Some(EntityKind::Arc(a)) => a,
_ => return Err(bad("dimarc", "object is not an arc")),
};
let ga = cadcraft_geom::Arc::new(a.center.xy(), a.radius, a.start, a.end);
let k = dim(s, DimKind::ArcLength, at, ga.start_point(), ga.end_point(), ga.center, "");
Ok(json!({ "handle": add_dim(s, k)?.hex() }))
}
#[derive(Default)]
pub(super) struct AngularM {
handles: Vec<Handle>,
first: Option<(Vec2, Vec2)>,
second: Option<(Vec2, Vec2)>,
vertex_mode: Vec<Vec2>,
vertex: bool,
arc: Option<(Vec2, Vec2, Vec2)>,
text: DimText,
/// Quadrant: a point inside the angle to measure; the arc location then only sets the radius.
quadrant: Option<Vec2>,
asking_quadrant: bool,
}
impl AngularM {
/// (vertex, p1, p2) of the angle being dimensioned.
fn geometry(&self) -> Option<(Vec2, Vec2, Vec2)> {
if let Some(a) = self.arc {
return Some(a);
}
if let [v, a, b] = self.vertex_mode.as_slice() {
return Some((*v, *a, *b));
}
let ((a1, a2), (b1, b2)) = (self.first?, self.second?);
let (v, _, _) = cadcraft_geom::line_line_infinite(a1, a2, b1, b2)?;
let far = |p: Vec2, q: Vec2| if p.dist(v) > q.dist(v) { p } else { q };
Some((v, far(a1, a2), far(b1, b2)))
}
/// The arc location `at`, turned into the locked quadrant (same radius) when one is set.
fn located(&self, v: Vec2, at: Vec2) -> Vec2 {
match self.quadrant {
Some(q) if q.dist(v) > 1e-12 && at.dist(v) > 1e-12 => v + (q - v).normalized() * at.dist(v),
_ => at,
}
}
}
impl Interactive for AngularM {
fn name(&self) -> &'static str {
"DIMANGULAR"
}
fn prompt(&self, _s: &Session) -> Prompt {
if let Some(p) = self.text.prompt() {
return p;
}
if self.asking_quadrant {
return Prompt::new("Specify quadrant", Accept::POINT);
}
if self.geometry().is_some() {
return Prompt::new("Specify dimension arc line location", Accept::POINT).kw(&["Mtext", "Text", "Angle", "Quadrant"]);
}
if self.vertex {
return Prompt::new(
["Specify angle vertex", "Specify first angle endpoint", "Specify second angle endpoint"]
.get(self.vertex_mode.len())
.copied()
.unwrap_or(""),
Accept::POINT,
);
}
match self.first {
None => Prompt::new("Select arc, circle, line", Accept::POINT).default("specify vertex"),
Some(_) => Prompt::new("Select second line", Accept::POINT),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
if self.text.answer(s, &i) {
return Ok(Step::Continue);
}
if self.asking_quadrant {
if let Input::Point(q) = i {
self.quadrant = Some(q);
}
self.asking_quadrant = false;
return Ok(Step::Continue);
}
if let Some((v, a, b)) = self.geometry() {
return match i {
Input::Point(at) => {
let at = self.located(v, at);
let k = match (self.handles.as_slice(), self.arc) {
([h1, h2], None) => angular_from_lines(s, *h1, *h2, at),
_ => None,
};
let k = k.unwrap_or_else(|| dim(s, DimKind::Angular3P, at, a, b, v, ""));
add_dim(s, self.text.apply(k))?;
Ok(Step::Done)
}
Input::Keyword(k) => {
if k == "Quadrant" {
self.asking_quadrant = true;
} else {
self.text.keyword(&k);
}
Ok(Step::Continue)
}
Input::Enter => Ok(Step::Cancel),
_ => Ok(Step::Continue),
};
}
if self.vertex {
match i {
Input::Point(p) => self.vertex_mode.push(p),
Input::Enter => return Ok(Step::Cancel),
_ => {}
}
return Ok(Step::Continue);
}
match i {
Input::Enter if self.first.is_none() => {
self.vertex = true;
Ok(Step::Continue)
}
Input::Point(p) => {
let Some(h) = pick_at(s, p) else {
s.echo("*Invalid selection*");
return Ok(Step::Continue);
};
let circle = s
.doc()?
.entity(h)
.and_then(|e| if let EntityKind::Circle(c) = &e.kind { Some(cadcraft_geom::Circle::new(c.center.xy(), c.radius)) } else { None });
if let Some(g) = circle.filter(|_| self.first.is_none()) {
// The center is the vertex and the pick the first endpoint; ask for the second.
self.vertex = true;
self.vertex_mode = vec![g.center, g.closest(p)];
return Ok(Step::Continue);
}
match segment_at(s, h, p) {
Some(Segment::Arc { arc: g, .. }) if self.first.is_none() => {
self.arc = Some((g.center, g.start_point(), g.end_point()));
}
Some(Segment::Line(l)) => {
self.handles.push(h);
let seg = (l.a, l.b);
if self.first.is_none() {
self.first = Some(seg);
} else {
self.second = Some(seg);
}
}
_ => s.echo("Select an arc, circle or line."),
}
Ok(Step::Continue)
}
Input::Enter => Ok(Step::Cancel),
_ => Ok(Step::Continue),
}
}
fn preview(&self, s: &Session, c: Vec2) -> Vec<EntityKind> {
self.geometry().map(|(v, a, b)| vec![self.text.apply(dim(s, DimKind::Angular3P, self.located(v, c), a, b, v, ""))]).unwrap_or_default()
}
}
#[derive(Default)]
pub(super) struct ArcLenM {
arc: Option<cadcraft_geom::Arc>,
text: DimText,
/// Partial: the points picked so far on the arc (`None` when not choosing a part).
partial: Option<Vec<Vec2>>,
}
/// The part of `arc` between the points nearest `p` and `q` (in the arc's direction).
fn arc_part(arc: &cadcraft_geom::Arc, p: Vec2, q: Vec2) -> Option<cadcraft_geom::Arc> {
let sweep = arc.sweep();
// Offset along the arc from its start, clamped to the nearer end when the point is off the arc.
let along = |x: Vec2| {
let t = cadcraft_geom::ccw_sweep(arc.start, arc.center.angle_to(x));
if t <= sweep {
t
} else if t - sweep < std::f64::consts::TAU - t {
sweep
} else {
0.0
}
};
let (t1, t2) = (along(p), along(q));
let (lo, hi) = (t1.min(t2), t1.max(t2));
(hi - lo > 1e-9).then(|| cadcraft_geom::Arc::new(arc.center, arc.radius, arc.start + lo, arc.start + hi))
}
impl Interactive for ArcLenM {
fn name(&self) -> &'static str {
"DIMARC"
}
fn prompt(&self, _s: &Session) -> Prompt {
if let Some(p) = self.text.prompt() {
return p;
}
if let Some(picked) = &self.partial {
return match picked.first() {
None => Prompt::new("Specify first point for arc length dimension", Accept::POINT),
Some(&f) => Prompt::new("Specify second point for arc length dimension", Accept::POINT).base(f),
};
}
match self.arc {
None => Prompt::new("Select arc or polyline arc segment", Accept::POINT),
Some(_) => Prompt::new("Specify arc length dimension location", Accept::POINT).kw(&["Mtext", "Text", "Angle", "Partial"]),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
if self.text.answer(s, &i) {
return Ok(Step::Continue);
}
if let Some(picked) = self.partial.take() {
match (i, picked.first().copied(), self.arc) {
(Input::Point(p), None, _) => self.partial = Some(vec![p]),
(Input::Point(q), Some(p), Some(g)) => match arc_part(&g, p, q) {
Some(part) => self.arc = Some(part),
None => s.echo("The two points must be different."),
},
(Input::Enter, ..) => {}
_ => self.partial = Some(picked),
}
return Ok(Step::Continue);
}
match (self.arc, i) {
(None, Input::Point(p)) => {
if let Some(EntityKind::Arc(a)) = pick_at(s, p).and_then(|h| s.doc().ok()?.entity(h).map(|e| e.kind.clone())) {
self.arc = Some(cadcraft_geom::Arc::new(a.center.xy(), a.radius, a.start, a.end));
} else {
s.echo("Select an arc.");
}
Ok(Step::Continue)
}
(Some(g), Input::Point(at)) => {
add_dim(s, self.text.apply(dim(s, DimKind::ArcLength, at, g.start_point(), g.end_point(), g.center, "")))?;
Ok(Step::Done)
}
(Some(_), Input::Keyword(k)) => {
if k == "Partial" {
self.partial = Some(Vec::new());
} else {
self.text.keyword(&k);
}
Ok(Step::Continue)
}
(_, Input::Enter) => Ok(Step::Cancel),
_ => Ok(Step::Continue),
}
}
fn preview(&self, s: &Session, c: Vec2) -> Vec<EntityKind> {
if self.partial.is_some() {
return Vec::new();
}
self.arc.map(|g| vec![self.text.apply(dim(s, DimKind::ArcLength, c, g.start_point(), g.end_point(), g.center, ""))]).unwrap_or_default()
}
}

Some files were not shown because too many files have changed in this diff Show More