Compare commits

..
Author SHA1 Message Date
dependabot[bot] 6f2e5bf9b1 ci(deps): bump storytold/craft-common-actions/.github/workflows/windows-arm64.yml
Bumps the actions group with 1 update: [storytold/craft-common-actions/.github/workflows/windows-arm64.yml](https://github.com/storytold/craft-common-actions).


Updates `storytold/craft-common-actions/.github/workflows/windows-arm64.yml` from 8bb4e5ab95443171104ad88113fe1ef9b0e5ce3f to fa5ef18780cf5e7d5d3b8de068de56cffc83cac5
- [Commits](https://github.com/storytold/craft-common-actions/compare/8bb4e5ab95443171104ad88113fe1ef9b0e5ce3f...fa5ef18780cf5e7d5d3b8de068de56cffc83cac5)

---
updated-dependencies:
- dependency-name: storytold/craft-common-actions/.github/workflows/windows-arm64.yml
  dependency-version: fa5ef18780cf5e7d5d3b8de068de56cffc83cac5
  dependency-type: direct:production
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-29 10:07:57 +00:00
Dylan M. Taylor 336243e0af Honor CARGO_BUILD_TARGET in FreeBSD packaging for aarch64 release builds (#2425)
Add packaging/freebsd/package.sh, matching the other craft apps: an optional
CARGO_BUILD_TARGET (x86_64 or aarch64 FreeBSD) names the archive and reads the
cross-built binaries; unset means native. --skip-build and --dry-run supported.
2026-10-28 15:53:50 -04:00
Dylan M. Taylor 9de2b8bbdc Grant network access so update checks can reach GitHub (#2413)
Add --share=network to the finish-args of both Flatpak manifests. Without it
the sandbox cannot resolve or reach GitHub, so update checks fail with
"Temporary failure in name resolution". The two manifests keep identical
finish-args.
2026-10-28 13:23:09 -04:00
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
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
356 changed files with 38743 additions and 30066 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"
+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
+24
View File
@@ -21,3 +21,27 @@ jobs:
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.
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')
+4 -2
View File
@@ -25,8 +25,10 @@ permissions:
jobs:
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.
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main
# Immutable supporting workflow revision until the new install contract lands upstream.
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@fa5ef18780cf5e7d5d3b8de068de56cffc83cac5
with:
app-name: cadcraft
app-dir: CADCraft
install-relative-path: Programs/ArtCraft/CADCraft
install-scope: perUser
+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`).
- **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`).
- 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
- **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.
+4 -4
View File
@@ -24,10 +24,10 @@ Generated-in-code assets are original and have no file to list:
| 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 a091680 vendor/krilla/icc/sGrey-v2-magic.icc](https://github.com/storytold/craft-libs/blob/a0916808f7d78bed8c7b90cdf937176015454a7f/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 a091680 vendor/krilla/icc/sGrey-v4.icc](https://github.com/storytold/craft-libs/blob/a0916808f7d78bed8c7b90cdf937176015454a7f/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 a091680 vendor/krilla/icc/sRGB-v2-magic.icc](https://github.com/storytold/craft-libs/blob/a0916808f7d78bed8c7b90cdf937176015454a7f/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 a091680 vendor/krilla/icc/sRGB-v4.icc](https://github.com/storytold/craft-libs/blob/a0916808f7d78bed8c7b90cdf937176015454a7f/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`. |
| `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/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 |
+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`).
- **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`).
- 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
- **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
+504 -132
View File
File diff suppressed because it is too large Load Diff
+16 -9
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
# default-members
[workspace.package]
version = "0.13.0"
version = "0.14.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -25,13 +25,17 @@ cadcraft-engine = { path = "crates/engine" }
cadcraft-ui-egui = { path = "crates/ui-egui" }
cadcraft-mcp = { path = "crates/mcp" }
# 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 = "a0916808f7d78bed8c7b90cdf937176015454a7f" }
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 = "a0916808f7d78bed8c7b90cdf937176015454a7f", default-features = false }
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
thiserror = "2"
same-file = "1"
serde = { version = "1", features = ["derive", "rc"] }
serde_json = { version = "1", features = ["float_roundtrip"] }
log = "0.4"
@@ -53,14 +57,15 @@ encoding_rs = "0.8.35"
quick-xml = "0.42"
# ui
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f", features = ["serde"] }
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["serde"] }
craft-window = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] }
egui = { version = "0.37", default-features = false, features = ["default_fonts", "monochrome_emoji_fonts"] }
egui_kittest = "0.37"
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 = "495e5971bc024aa96e6ed318cb6e7cd3a0103184", features = ["egui"] }
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] }
rfd = "0.17"
arboard = { version = "3.6", default-features = false }
@@ -68,9 +73,11 @@ arboard = { version = "3.6", default-features = false }
unsafe_code = "forbid"
[workspace.lints.clippy]
cognitive_complexity = "deny"
too_many_arguments = "deny"
wildcard_imports = "deny"
dbg_macro = "warn"
todo = "warn"
too_many_arguments = "allow"
type_complexity = "allow"
[profile.dev.package."*"]
@@ -90,9 +97,9 @@ 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 = "a0916808f7d78bed8c7b90cdf937176015454a7f" }
hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f" }
hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f" }
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" }
+2 -2
View File
@@ -28,9 +28,9 @@ The PDF writer in the pinned craft-pdf 0.1.0 / Krilla 0.8.2 dependency embeds fo
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/a0916808f7d78bed8c7b90cdf937176015454a7f/vendor/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/a0916808f7d78bed8c7b90cdf937176015454a7f/vendor/krilla/icc/LICENSE
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
+5 -1
View File
@@ -196,7 +196,7 @@ crashed survives the next start. The file stops growing at 16 MiB. `--version` w
| 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. |
The logger is `apps/cadcraft/src/logging.rs`; the web build logs to the browser console instead.
@@ -238,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
[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
```text
@@ -311,6 +314,7 @@ See [ROADMAP.md](ROADMAP.md) for the stage, measured parity and the estimate of
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`.
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.
Letting wgpu also create an OpenGL instance can crash some graphics drivers (AMD's
+17
View File
@@ -6,6 +6,14 @@ edition.workspace = true
license.workspace = true
publish = false
[lib]
name = "cadcraft_cli"
path = "src/lib.rs"
[[bin]]
name = "cadcraft-cli"
path = "src/main.rs"
[lints]
workspace = true
@@ -14,4 +22,13 @@ cadcraft-engine = { workspace = true }
cadcraft-io = { workspace = true }
cadcraft-mcp = { workspace = true }
cadcraft-render = { workspace = true }
clap = { workspace = true }
same-file = { 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);
}
}
File diff suppressed because it is too large Load Diff
+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(())
}
+7 -7
View File
@@ -1,23 +1,23 @@
//! `--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;
#[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"]] {
let out = Command::new(env!("CARGO_BIN_EXE_cadcraft-cli")).args(args).output().expect("run cadcraft-cli");
assert!(out.status.success(), "{args:?}: {:?}", out.status);
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");
}
}
#[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");
assert_eq!(out.status.code(), Some(1));
assert!(out.stdout.is_empty(), "bare invocation keeps its usage on stderr");
assert!(String::from_utf8_lossy(&out.stderr).starts_with("usage:"));
assert_eq!(out.status.code(), Some(2));
assert!(out.stdout.is_empty(), "bare invocation keeps its help on stderr");
assert!(String::from_utf8_lossy(&out.stderr).starts_with("cadcraft-cli: headless CADCraft\n"));
}
+1
View File
@@ -4,3 +4,4 @@ mod help;
mod mcp_connect;
mod skipped_records;
mod undrawn_entities;
mod usage;
+6 -3
View File
@@ -33,12 +33,15 @@ fn replies(out: &Output) -> Vec<Value> {
#[test]
fn connect_without_address_fails_before_serving() {
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);
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));
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();
}
+7
View File
@@ -21,6 +21,8 @@ eframe = { workspace = true, features = ["clipboard", "wgpu", "accesskit", "wayl
egui = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
clap = { workspace = true }
thiserror = { workspace = true }
chrono = { workspace = true }
[build-dependencies]
@@ -34,11 +36,16 @@ winit = { version = "0.30", default-features = false, features = ["x11"] }
zbus = { version = "=5.19.0", default-features = false, features = ["async-io", "blocking-api"] }
[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 }
sys-locale = "0.3"
[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 }
# View ▸ Accessibility ▸ Animation Effects ▸ Use System Setting: the system's Reduce motion
# (system_animations.rs). Already in Cargo.lock through muda.
+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));
}
}
+1 -1
View File
@@ -3,7 +3,7 @@
//! 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
//! 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
//! run that crashed survives the next launch.
//!
+53 -141
View File
@@ -1,6 +1,6 @@
//! 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:
//! `{"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`.
//!
//! `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")]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod control_server;
mod desktop_theme;
mod env;
#[cfg(any(target_os = "windows", test))]
mod graphics;
mod launch_args;
mod logging;
#[cfg(target_os = "macos")]
mod native_menu;
@@ -26,6 +28,7 @@ mod system_animations;
mod workspace;
use cadcraft_engine::Session;
use cadcraft_ui_egui::switch::Switch;
use cadcraft_ui_egui::{CadApp, Services, i18n};
struct App {
@@ -39,11 +42,25 @@ struct App {
workspace_error: Option<String>,
#[cfg(target_os = "macos")]
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 {
fn persist_workspace(&mut self, final_attempt: bool) {
let result = if final_attempt { self.workspace.save_on_exit(&self.cad.ui) } else { self.workspace.save_if_changed(&self.cad.ui) };
/// `ctx` is `None` only for the final attempt at exit.
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 self.workspace_error.as_ref() != Some(&error) {
eprintln!("CADCraft workspace: {error}");
@@ -77,7 +94,7 @@ impl eframe::App for App {
if ctx.current_pass_index() == 0 {
#[cfg(target_os = "macos")]
{
if self.native_menu.is_none() && std::env::var_os("CADCRAFT_NO_NATIVE_MENU").is_none() {
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 {
@@ -86,7 +103,7 @@ impl eframe::App for App {
}
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(false);
self.persist_workspace(Some(ctx));
}
if std::mem::take(&mut self.cad.quit_requested) {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
@@ -100,11 +117,13 @@ impl eframe::App for App {
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
self.cad.ui(ui);
#[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
/// crash leaves them for recovery).
fn on_exit(&mut self) {
self.persist_workspace(true);
self.persist_workspace(None);
self.cad.cancel_screenshots();
self.cad.shutdown_autosave();
}
@@ -175,30 +194,6 @@ fn filters_for_save(name: &str) -> [(&'static str, &'static str); 6] {
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";
/// Stencil bits of the window's render pass (see `NativeOptions::stencil_buffer`).
@@ -214,32 +209,7 @@ fn app_icon() -> Option<egui::IconData> {
}
fn version_string() -> String {
let sha = option_env!("CADCRAFT_BUILD_SHA").unwrap_or("dev");
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
format!("cadcraft {}", cadcraft_engine::build_version())
}
/// Parse CLI options (`--control`, `--sample`, files, `--version`), install logging and I/O hooks, build the services and app, and run eframe.
@@ -255,37 +225,33 @@ fn main() -> eframe::Result {
}
return Ok(());
}
// lossy, so a value that is not Unicode is reported like any other bad port instead of ignored
let mut control_port =
control_port_from("CADCRAFT_CONTROL_PORT", std::env::var_os("CADCRAFT_CONTROL_PORT").map(|p| p.to_string_lossy().into_owned()));
let mut files = Vec::new();
let mut sample = false;
let mut args = std::env::args().skip(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),
}
}
// `--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`
// leaves no file behind. Records logged until now are written to it first.
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(Err(e)) => log::warn!("no log file: {e}"),
None => {
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();
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 {
// 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).
@@ -328,7 +294,7 @@ fn main() -> eframe::Result {
// manager showed a "no" cursor. Run through XWayland when it's there; CADCRAFT_WAYLAND=1
// keeps the native Wayland backend.
#[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| {
use winit::platform::x11::EventLoopBuilderExtX11;
b.with_x11();
@@ -339,15 +305,16 @@ fn main() -> eframe::Result {
options,
Box::new(move |cc| {
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 {
app.ui = ui;
}
app.set_system_cursor_default(vars.system_cursor == Switch::On);
if let Some(error) = &workspace_error {
eprintln!("CADCraft workspace: {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.
if let Some(profile) = cc.storage.and_then(|s| s.get_string(cadcraft_ui_egui::options::PROFILE_KEY)) {
app.load_profile(&profile);
@@ -359,7 +326,7 @@ fn main() -> eframe::Result {
cadcraft_ui_egui::autosave::load_prefs(&prefs);
}
// 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_update_check();
// Startup LISP (acad.lsp, acaddoc.lsp) is read from `<settings>/support`; the
@@ -405,6 +372,10 @@ fn main() -> eframe::Result {
workspace_error,
#[cfg(target_os = "macos")]
native_menu: None,
#[cfg(target_os = "macos")]
menus_in_window: vars.no_native_menu == Switch::On,
#[cfg(target_os = "macos")]
movable: None,
}))
}),
)
@@ -412,13 +383,7 @@ fn main() -> eframe::Result {
#[cfg(test)]
mod tests {
use super::{config_dir_from, control_flag, control_port_from, 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))
}
use super::{filters_for_save, follow_desktop};
#[test]
fn save_dialog_offers_dwg_with_the_drawing_format_first() {
@@ -436,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;
/// without one (or where winit answers and the desktop doesn't) the input is left alone.
#[test]
+3 -2
View File
@@ -118,7 +118,7 @@ impl NativeMenu {
if cmd == "ui.discord" {
ctx.open_url(egui::OpenUrl::new_tab("https://discord.gg/artcraft"));
} else {
menus::activate(app, &cmd.clone());
menus::activate_native(app, ctx, &cmd.clone());
}
ctx.request_repaint();
}
@@ -142,8 +142,9 @@ impl NativeMenu {
return;
}
self.last_refresh = now;
let typing = menus::text_field_window(ctx).is_some();
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);
}
}
+99 -70
View File
@@ -1,15 +1,18 @@
//! Native workspace preferences. Drawing data and transient dialogs never enter this file.
use std::ffi::OsString;
use std::fs::{self, File, OpenOptions};
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, Instant};
use cadcraft_ui_egui::UiState;
use serde_json::{Value, json};
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);
pub struct Workspace {
@@ -17,18 +20,7 @@ pub struct Workspace {
last_attempt: Option<Value>,
read_failed: bool,
pending_write_error: Option<String>,
}
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"))),
}
last_write: Option<Instant>,
}
fn snapshot(ui: &UiState) -> Result<Value, String> {
@@ -44,6 +36,7 @@ fn snapshot(ui: &UiState) -> Result<Value, String> {
}
Ok(json!({
"docking": ui.docking,
"dockingVersion": cadcraft_ui_egui::docking::LAYOUT_VERSION,
"showToolsets": ui.show_toolsets,
"showPalettes": ui.show_palettes,
"showToolbar": ui.show_toolbar,
@@ -80,10 +73,16 @@ 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()))?;
// Dock schemas can evolve independently of the other saved workspace preferences.
// 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 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()))?;
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)
&& cadcraft_ui_egui::docking::validate(&docking).is_ok()
{
@@ -127,8 +126,9 @@ fn write(path: &Path, workspace: &Value) -> Result<(), String> {
}
impl Workspace {
pub fn load() -> (Self, Option<UiState>, Option<String>) {
Self::at(config_dir_from(std::env::consts::OS, |key| std::env::var_os(key)).map(|root| root.join("workspace.json")))
/// The workspace file in the settings directory `config_dir`; without one nothing is kept.
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>) {
@@ -136,44 +136,54 @@ impl Workspace {
match result {
Ok(ui) => {
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) => (
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,
Some(format!("Workspace could not be restored; its file was left unchanged. {error}")),
),
}
}
pub fn save_if_changed(&mut self, ui: &UiState) -> Result<(), String> {
let Some(path) = &self.path else { return Ok(()) };
/// Writes `ui` if it differs from the last state written. `Ok(Some(wait))` means a change
/// 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 {
// Preserve a corrupt, oversized or newer-version file until the user repairs it.
return Ok(());
return Ok(None);
}
let value = snapshot(ui)?;
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.
self.last_attempt = Some(value.clone());
self.last_write = Some(now);
let result = write(path, &value);
self.pending_write_error = result.as_ref().err().cloned();
result
result.map(|()| None)
}
pub fn save_on_exit(&mut self, ui: &UiState) -> Result<(), String> {
if self.pending_write_error.is_some() {
self.last_attempt = None;
}
self.save_if_changed(ui)
self.last_write = None;
self.save_if_changed(ui, Instant::now()).map(|_| ())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::ffi::OsString;
fn temp_dir() -> PathBuf {
let sequence = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed);
@@ -184,20 +194,18 @@ mod tests {
#[test]
fn profile_override_isolated_or_explicitly_disabled() {
let get = |key: &str| match key {
"CADCRAFT_CONFIG_DIR" => Some(OsString::from("/private/test-state")),
"HOME" | "APPDATA" | "XDG_CONFIG_HOME" => Some(OsString::from("/unrelated-user-profile")),
_ => None,
let vars = |config: Option<&'static str>| {
move |key: &str| match key {
"CADCRAFT_CONFIG_DIR" => config.map(OsString::from),
"HOME" | "APPDATA" | "XDG_CONFIG_HOME" => Some(OsString::from("/unrelated-user-profile")),
_ => None,
}
};
for platform in ["macos", "windows", "linux", "freebsd"] {
assert_eq!(config_dir_from(platform, get), 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!(
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);
let dir = |config| crate::env::Env::from_vars(vars(config)).config_dir;
assert_eq!(dir(Some("/private/test-state")), Some(PathBuf::from("/private/test-state")));
assert_eq!(dir(Some("")), None);
let (_, restored, error) = Workspace::load(dir(Some("")).as_deref());
assert!(restored.is_none() && error.is_none(), "no settings folder, no workspace file");
}
#[test]
@@ -216,7 +224,7 @@ mod tests {
start_tab: true,
..Default::default()
};
first.save_if_changed(&ui).unwrap();
first.save_if_changed(&ui, Instant::now()).unwrap();
let bytes = fs::read(&path).unwrap();
let value: Value = serde_json::from_slice(&bytes).unwrap();
assert!(value["workspace"].get("dialog").is_none());
@@ -229,37 +237,42 @@ mod tests {
let mut transient_only = ui.clone();
transient_only.dialog = None;
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);
fs::remove_dir_all(directory).unwrap();
}
#[test]
fn docked_floating_and_hidden_panels_survive_native_workspace_file_reload() {
use cadcraft_ui_egui::docking::{Panel, permissions};
fn dock_columns_floating_and_hidden_panels_survive_native_workspace_file_reload() {
use cadcraft_ui_egui::docking::{LAYOUT_VERSION, Panel, permissions, reveal_panel};
let directory = temp_dir();
let path = directory.join("workspace.json");
let (mut store, _, _) = Workspace::at(Some(path.clone()));
let mut ui = UiState::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.set_visible(Panel::ToolSets, false, None, permissions).unwrap();
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));
assert!(error.is_none());
let mut reloaded = reloaded.unwrap();
assert_eq!(serde_json::to_value(&reloaded.docking).unwrap(), serde_json::to_value(&ui.docking).unwrap());
reloaded.docking.set_visible(Panel::ToolSets, true, None, permissions).unwrap();
assert!(reloaded.docking.layout.contains(&Panel::ToolSets));
assert!(!reloaded.show_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]);
fs::remove_dir_all(directory).unwrap();
}
#[test]
fn version_one_docking_file_still_loads() {
use cadcraft_ui_egui::docking::{Panel, reveal_panel};
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":{
@@ -271,28 +284,18 @@ mod tests {
"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,"toolsetTab":"Modeling","historyLines":4}}"#;
"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 mut ui = restored.unwrap();
let ui = restored.unwrap();
assert_eq!(ui.toolset_tab, "Modeling");
let docking = &ui.docking;
assert_eq!(docking.layout.floating.len(), 1);
let window = &docking.layout.floating[0];
assert_eq!(window.rect, [120.0, 90.0, 340.0, 410.0]);
assert_eq!(serde_json::to_value(&window.root).unwrap(), json!({"Tabs":{"panels":["layers","externalReferences"],"active":1}}));
assert_eq!(
serde_json::to_value(&docking.layout.root).unwrap()["Split"]["second"],
json!({"Stack":{"entries":[{"panel":"properties","open":false,"height":null}]}})
);
assert!(!docking.layout.contains(&Panel::ToolSets));
reveal_panel(&mut ui.docking, Panel::ToolSets).unwrap();
let tools = ui.docking.layout.location(&Panel::ToolSets).unwrap();
assert_eq!(tools.anchor, Some(Panel::Canvas));
assert_eq!(serde_json::to_value((&tools.placement, &tools.size)).unwrap(), json!([{"Split":"Left"}, {"FixedFirst":220.0}]));
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();
}
@@ -311,7 +314,7 @@ mod tests {
let (mut store, ui, error) = Workspace::at(Some(path.clone()));
assert!(ui.is_none());
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);
}
fs::remove_dir_all(directory).unwrap();
@@ -326,7 +329,8 @@ mod tests {
json!({"layout":false,"hidden":[]}),
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");
fs::write(&path, serde_json::to_vec(&json!({"version":1,"workspace":workspace})).unwrap()).unwrap();
let (_, restored, error) = Workspace::at(Some(path.clone()));
@@ -342,16 +346,41 @@ mod tests {
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]
fn invalid_update_cannot_replace_last_good_workspace() {
let directory = temp_dir();
let path = directory.join("workspace.json");
let (mut store, _, _) = Workspace::at(Some(path.clone()));
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();
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);
fs::remove_dir_all(directory).unwrap();
}
@@ -364,9 +393,9 @@ mod tests {
// Simulate a destination becoming unavailable after startup.
fs::create_dir(&path).unwrap();
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();
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");
store.save_on_exit(&ui).unwrap();
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-panic-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
+51 -40
View File
@@ -358,6 +358,56 @@ fn components<'a>(cons: &[&'a Constraint]) -> Vec<Vec<&'a Constraint>> {
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.
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;
@@ -396,46 +446,7 @@ pub(crate) fn build_budgeted(
if built.is_empty() {
continue;
}
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;
}
}
// 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 weights = solver_weights(&model, opts);
let held = opts.hold.iter().filter_map(|r| model.lock_indices(r)).flatten().collect();
let tol = scale_tol(&model.x0);
comps.push(Comp { model, built, weights, held, tol });
+72 -65
View File
@@ -191,6 +191,77 @@ impl Model {
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();
@@ -200,71 +271,7 @@ impl Model {
}
let Some(e) = d.entity(h) else { continue };
let off = m.x0.len();
let shape = match &e.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 {
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)));
// 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 }
}
_ => continue,
};
let Some(shape) = Self::add_params(&mut m, d, h, &e.kind, off, members_left) else { continue };
if m.x0.len() > MAX_PARAMS || m.x0.get(off..).unwrap_or(&[]).iter().any(|v| !v.is_finite()) {
m.x0.truncate(off);
continue;
+341 -185
View File
@@ -3,7 +3,7 @@
use cadcraft_doc::{Constraint, ConstraintKind, DistAxis, GeomRef};
use cadcraft_geom::Vec2;
use crate::curves::circle_at;
use crate::curves::{Ell, circle_at};
use crate::model::Model;
/// Contact parameter samples per curve when guessing where two curves touch.
@@ -205,42 +205,7 @@ impl Built {
};
b.rows = 1;
}
K::Tangent | K::Smooth => {
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::Tangent | K::Smooth => return Self::tangent_form(b, c, m, x),
K::Symmetric => {
if n != 3 || ln(2).is_none() {
return need("two objects and a symmetry line");
@@ -347,154 +312,8 @@ impl Built {
out.resize(start + self.rows, 0.0);
}
fn eval_inner(&self, m: &Model, x: &[f64], out: &mut Vec<f64>) -> Option<()> {
use ConstraintKind as K;
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(())
fn eval_inner(&self, model: &Model, values: &[f64], out: &mut Vec<f64>) -> Option<()> {
ResidualEval { built: self, model, values, out }.evaluate()
}
/// Point and derivative of curve reference `i` (circle, arc or ellipse) at parameter `t`.
@@ -527,6 +346,54 @@ impl Built {
/// 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,
/// 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> {
let (Some(r0), Some(r1)) = (b.refs.first().copied(), b.refs.get(1).copied()) else {
return Err(format!("{} needs two objects", b.kind.name()));
@@ -596,3 +463,292 @@ impl Built {
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(())
}
}
+1 -1
View File
@@ -5,7 +5,7 @@
//! 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
//! *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.
//!
//! Every count and index here comes from the file: they are capped by the data actually present
+15 -934
View File
@@ -9,130 +9,6 @@ use serde::{Deserialize, Serialize};
mod mline;
pub use mline::*;
/// 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(),
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Entity {
pub handle: Handle,
@@ -146,108 +22,6 @@ impl Entity {
Entity { handle, common: Common::default(), kind }
}
}
#[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,
}
#[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(),
}
}
}
fn one() -> f64 {
1.0
}
@@ -257,720 +31,27 @@ fn standard() -> String {
mod helix;
pub use helix::*;
#[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);
}
}
#[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))
}
}
#[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 [`EntityKind::transform`]).
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,
}
fn yes() -> bool {
true
}
#[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()
}
}
/// 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,
}
/// 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,
}
#[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, PartialEq, Serialize, Deserialize)]
pub struct Wipeout {
pub boundary: Vec<Vec2>,
}
#[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
}
mod region;
pub use region::*;
/// 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,
}
mod block;
mod common;
mod curves;
mod dim;
mod fill;
mod layout;
mod text;
pub use block::{Attrib, AttribModes, AttribProps, Insert, MultilineAttrib, mtext_literal};
pub use common::{Common, Handle, Lineweight, RawTag, Transparency};
pub use curves::{Arc, Circle, Ellipse, Face3d, Line, LwPolyline, Point, Polyline3d, RayLine, Solid};
pub use dim::{AssocSnap, DimAssoc, DimBreak, DimKind, DimMarks, Dimension, Inspection, JoggedRadius, Leader, MLeader};
pub use fill::{Gradient, Hatch, HatchLoop, Wipeout};
pub use layout::{Image, Table, TableCell, Viewport};
pub use text::{HAlign, MText, MTextColumnType, MTextColumns, Text, VAlign};
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Unknown {
pub dxf_type: String,
+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);
}
}
+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");
}
}
+2
View File
@@ -10,6 +10,7 @@ mod array;
mod constraint;
mod entity;
mod extents;
mod format;
mod gdt;
mod header;
pub mod library;
@@ -27,6 +28,7 @@ pub use cadcraft_geom as geom;
pub use constraint::*;
pub use entity::*;
pub use extents::{MAX_BLOCK_DEPTH, entity_bounds};
pub use format::Format;
pub use gdt::*;
pub use header::{HVal, Header};
pub use plotstyle::*;
+45 -33
View File
@@ -441,6 +441,49 @@ impl DimStyle {
.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
/// 0/1, colours as ACI numbers or names). Returns the names that were not recognised or
/// had unusable values.
@@ -458,29 +501,7 @@ impl DimStyle {
rejected.push(k.clone());
continue;
};
let coerced = 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(),
};
let coerced = Self::coerce_value(&old, v, field);
// Integers stored in u8 fields: round floats.
let coerced = match (&old, &coerced) {
// Lineweights (i16): whole hundredths of a mm or -1/-2/-3.
@@ -502,16 +523,7 @@ impl DimStyle {
_ => coerced,
};
// Sizes that can't be negative (text height and tolerance scale: not zero either).
let x = coerced.as_f64();
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" | "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,
};
let out_of_range = Self::out_of_range(field, &coerced);
if out_of_range {
rejected.push(k.clone());
continue;
+4 -1
View File
@@ -256,7 +256,10 @@ mod native {
use std::path::Path;
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.
pub fn instance_id() -> String {
+38
View File
@@ -144,6 +144,11 @@ impl BlockEdit {
}
let host = &self.host.doc;
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.layouts = host.layouts.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 {
/// The drawing as a file saves it: outside the Block Editor's changes while it is open.
pub fn drawing(&self) -> &Arc<Drawing> {
@@ -272,6 +292,24 @@ impl DocState {
let d = be.compose(&self.doc, &name)?;
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);
// 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 Some(be) = &mut self.block_edit else { return Ok(()) };
let before = be.host.doc.clone();
+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 super::machines::{SelOutcome, SelectPhase, SelectRun};
use super::*;
use super::{CommandSpec, bad, f64_req, targets};
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
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()
}
}
+170
View File
@@ -0,0 +1,170 @@
use super::{Accept, DimAssoc, DimKind, Dimension, EntityKind, Handle, Input, Prompt, Result, Segment, Session, Value, Vec2, Vec3, v3};
pub(super) fn style(s: &Session) -> String {
s.doc().map(|d| d.header.str("DIMSTYLE", "Standard")).unwrap_or_else(|_| "Standard".into())
}
pub(super) fn dim(s: &Session, kind: DimKind, defpt: Vec2, p13: Vec2, p14: Vec2, p15: Vec2, text: &str) -> EntityKind {
EntityKind::Dimension(Dimension {
kind,
defpt: v3(defpt),
text_mid: Vec3::ZERO,
p13: v3(p13),
p14: v3(p14),
p15: v3(p15),
p16: v3(Vec2::ZERO),
text: text.into(),
style: style(s),
measurement: 0.0,
text_rotation: 0.0,
user_text_pos: false,
block: None,
overrides: s.doc().map(|d| d.dim_overrides()).unwrap_or_default(),
assoc: Vec::new(),
marks: Default::default(),
})
}
/// Horizontal (0) or vertical (90°) from where the dimension line is placed (AutoCAD's rule).
fn auto_rotation(p1: Vec2, p2: Vec2, at: Vec2) -> f64 {
let (x0, x1) = (p1.x.min(p2.x), p1.x.max(p2.x));
let (y0, y1) = (p1.y.min(p2.y), p1.y.max(p2.y));
let outside_y = at.y < y0 || at.y > y1;
let outside_x = at.x < x0 || at.x > x1;
if outside_x && !outside_y { std::f64::consts::FRAC_PI_2 } else { 0.0 }
}
pub(super) fn linear_kind(p1: Vec2, p2: Vec2, at: Vec2, rotation: Option<f64>, aligned: bool) -> DimKind {
if aligned { DimKind::Aligned } else { DimKind::Linear { rotation: rotation.unwrap_or_else(|| auto_rotation(p1, p2, at)) } }
}
/// Add a dimension. With DIMASSOC on, definition points that sit on object endpoints,
/// vertices or centres are attached to them (associative dimensions).
pub(super) fn add_dim(s: &mut Session, k: EntityKind) -> Result<Handle> {
let mut k = k;
if let EntityKind::Dimension(dm) = &mut k
&& dm.assoc.is_empty()
&& assoc_enabled(s)
{
dm.assoc = crate::assoc::reassociate(s.doc()?, &s.space(), dm);
}
let h = s.add_entity(k)?;
s.last_dim = Some(h);
Ok(h)
}
pub(super) fn assoc_enabled(s: &Session) -> bool {
s.doc().map(|d| d.header.i64("DIMASSOC", 2) != 0).unwrap_or(false)
}
pub(super) fn with_assoc(k: EntityKind, assoc: Vec<DimAssoc>) -> EntityKind {
match k {
EntityKind::Dimension(mut d) => {
d.assoc = assoc;
EntityKind::Dimension(d)
}
other => other,
}
}
/// Centre and radius of a circle/arc entity.
pub(super) fn circle_of(s: &Session, h: Handle) -> Option<(Vec2, f64)> {
match &s.doc().ok()?.entity(h)?.kind {
EntityKind::Circle(c) => Some((c.center.xy(), c.radius)),
EntityKind::Arc(a) => Some((a.center.xy(), a.radius)),
_ => None,
}
}
/// The dimension line location of a radius/diameter dimension puts the text there, inside or
/// outside the circle, as a user-positioned text (DXF flag 70 bit 128), like AutoCAD.
pub(super) fn text_at(mut k: EntityKind, at: Vec2) -> EntityKind {
if let EntityKind::Dimension(d) = &mut k {
d.text_mid = v3(at);
d.user_text_pos = true;
}
k
}
pub(super) fn hex_param(p: &Value, key: &str) -> Option<Handle> {
p.get(key).and_then(|v| v.as_str().and_then(Handle::parse_hex).or_else(|| v.as_u64().map(Handle)))
}
// ---------------- interactive ----------------
pub(super) fn pick_at(s: &Session, p: Vec2) -> Option<Handle> {
let ap = s.pixel_size() * s.settings.pickbox.max(1.0) * 1.5;
crate::select::pick(s.doc().ok()?, &s.space(), p, ap)
}
/// The straight or curved piece of `h` picked at `p`: a line, an arc, or the polyline segment nearest `p`.
pub(in crate::cmd) fn segment_at(s: &Session, h: Handle, p: Vec2) -> Option<Segment> {
match &s.doc().ok()?.entity(h)?.kind {
EntityKind::Line(l) => Some(Segment::Line(cadcraft_geom::Line::new(l.a.xy(), l.b.xy()))),
EntityKind::Arc(a) => Some(Segment::Arc { arc: cadcraft_geom::Arc::new(a.center.xy(), a.radius, a.start, a.end), ccw: true }),
EntityKind::LwPolyline(pl) => cadcraft_geom::Polyline { vertices: pl.vertices.clone(), closed: pl.closed }
.segments()
.into_iter()
.min_by(|x, y| x.closest(p).dist(p).total_cmp(&y.closest(p).dist(p))),
_ => None,
}
}
/// What a dimension placement prompt asks after Mtext, Text or Angle.
#[derive(Clone, Copy, PartialEq)]
enum DimAsk {
Text,
Angle,
}
/// The Mtext / Text / Angle options of the dimension placement prompts: replace the measured text
/// (`<>` stands for the measurement) and rotate the text.
#[derive(Default)]
pub(super) struct DimText {
text: String,
angle: f64,
asking: Option<DimAsk>,
}
impl DimText {
/// Opens the sub-prompt for Mtext, Text or Angle; `false` for any other keyword.
pub(super) fn keyword(&mut self, k: &str) -> bool {
self.asking = match k {
"Mtext" | "Text" => Some(DimAsk::Text),
"Angle" => Some(DimAsk::Angle),
_ => return false,
};
true
}
pub(super) fn prompt(&self) -> Option<Prompt> {
Some(match self.asking? {
DimAsk::Text => Prompt::new("Enter dimension text (<> = measurement)", Accept::TEXT),
DimAsk::Angle => Prompt::new("Specify angle of dimension text", Accept::NUMBER),
})
}
/// Takes the answer to the open sub-prompt (Enter keeps the current value); `false` when none is open.
pub(super) fn answer(&mut self, s: &mut Session, i: &Input) -> bool {
let Some(a) = self.asking else { return false };
match (a, i) {
(DimAsk::Text, Input::Text(t)) => self.text = t.clone(),
(DimAsk::Angle, Input::Text(t)) => match s.angle_settings().direction(t) {
Some(r) => self.angle = r,
None => {
s.echo("Requires a valid angle.");
return true;
}
},
_ => {}
}
self.asking = None;
true
}
/// `k` with this text and text angle.
pub(super) fn apply(&self, mut k: EntityKind) -> EntityKind {
if let EntityKind::Dimension(d) = &mut k {
d.text = self.text.clone();
d.text_rotation = self.angle;
}
k
}
}
+374
View File
@@ -0,0 +1,374 @@
use super::common::{hex_param, style};
use super::{
Accept, DimKind, EntityKind, Handle, Input, Interactive, Prompt, Result, SelOutcome, SelectPhase, Session, Step, Value, Vec2, bad, json,
point_param, str_param, targets, v3,
};
pub(super) fn run_update(s: &mut Session, p: &Value) -> Result<Value> {
let hs = targets(s, p)?;
let st = style(s);
let d = s.doc_mut()?;
let mut n = 0;
for h in hs {
let _ = d.modify_entity(h, |e| {
if let EntityKind::Dimension(dm) = &mut e.kind {
dm.style = st.clone();
dm.block = None;
n += 1;
}
});
}
Ok(json!({ "updated": n }))
}
/// DIMOVERRIDE: per-dimension style overrides (`clear` removes them); `text` sets the text.
pub(super) fn run_override(s: &mut Session, p: &Value) -> Result<Value> {
if p.get("text").is_none() {
return crate::cmd::props::dim_override(s, p);
}
let hs = targets(s, p)?;
let t = str_param(p, "text").unwrap_or("").to_string();
// Dimension variables given next to `text` are applied first, so an unknown name still errors before anything changes.
let vars: serde_json::Map<String, Value> = p
.as_object()
.map(|o| o.iter().filter(|(k, _)| !matches!(k.as_str(), "text" | "handles" | "handle")).map(|(k, v)| (k.clone(), v.clone())).collect())
.unwrap_or_default();
let mut ignored = Value::Null;
if !vars.is_empty() {
let mut rest = vars;
rest.insert("handles".into(), json!(hs.iter().map(|h| h.hex()).collect::<Vec<_>>()));
ignored = crate::cmd::props::dim_override(s, &Value::Object(rest))?.get("ignored").cloned().unwrap_or(Value::Null);
}
let d = s.doc_mut()?;
for h in &hs {
let _ = d.modify_entity(*h, |e| {
if let EntityKind::Dimension(dm) = &mut e.kind {
dm.text = t.clone();
dm.block = None;
}
});
}
match ignored {
Value::Null => Ok(json!({ "changed": hs.len() })),
ig => Ok(json!({ "changed": hs.len(), "ignored": ig })),
}
}
pub(super) fn dims_of(s: &Session, hs: &[Handle]) -> Vec<Handle> {
let Ok(d) = s.doc() else { return Vec::new() };
hs.iter().copied().filter(|h| d.entity(*h).is_some_and(|e| matches!(e.kind, EntityKind::Dimension(_)))).collect()
}
pub(super) fn run_reassociate(s: &mut Session, p: &Value) -> Result<Value> {
let hs = dims_of(s, &targets(s, p)?);
let space = s.space();
let mut plan = Vec::new();
{
let d = s.doc()?;
for h in &hs {
if let Some(EntityKind::Dimension(dm)) = d.entity(*h).map(|e| &e.kind) {
plan.push((*h, crate::assoc::reassociate(d, &space, dm)));
}
}
}
let mut points = 0;
let mut n = 0;
let d = s.doc_mut()?;
for (h, a) in plan {
points += a.len();
n += usize::from(!a.is_empty());
let _ = d.modify_entity(h, |e| {
if let EntityKind::Dimension(dm) = &mut e.kind {
dm.assoc = a;
}
});
}
Ok(json!({ "dimensions": hs.len(), "associated": n, "points": points, "message": format!("{n} of {} dimension(s) associated.", hs.len()) }))
}
pub(super) fn run_disassociate(s: &mut Session, p: &Value) -> Result<Value> {
let hs = dims_of(s, &targets(s, p)?);
let d = s.doc_mut()?;
let mut n = 0;
for h in &hs {
let _ = d.modify_entity(*h, |e| {
if let EntityKind::Dimension(dm) = &mut e.kind
&& !dm.assoc.is_empty()
{
dm.assoc.clear();
n += 1;
}
});
}
Ok(json!({ "disassociated": n, "message": format!("{n} disassociated.") }))
}
/// DIMTEDIT: move/rotate/justify dimension text.
pub(super) fn run_tedit(s: &mut Session, p: &Value, mode: &str) -> Result<Value> {
let mut q = p.as_object().cloned().unwrap_or_default();
q.insert("mode".into(), json!(mode));
run_dimtedit(s, &Value::Object(q))
}
pub(super) fn run_dimtedit(s: &mut Session, p: &Value) -> Result<Value> {
let hs = dims_of(s, &targets(s, p)?);
if hs.is_empty() {
return Err(bad("dimtedit", "select dimensions"));
}
let mode = str_param(p, "mode").unwrap_or("").to_ascii_lowercase();
let at = point_param(p, "at");
let angle = p.get("angle").and_then(Value::as_f64).filter(|a| a.is_finite()).map(f64::to_radians);
if mode == "angle" && angle.is_none() {
return Err(bad("dimtedit", "`angle` (degrees) is required"));
}
let d = s.doc_mut()?;
let dimscale = d.header.f64("DIMSCALE", 1.0);
for h in &hs {
let st = match d.entity(*h).map(|e| &e.kind) {
Some(EntityKind::Dimension(dm)) => d.dim_style(&dm.style).cloned().unwrap_or_default().with_overrides(&dm.overrides),
_ => continue,
};
d.modify_entity(*h, |e| {
let EntityKind::Dimension(dm) = &mut e.kind else { return };
dm.block = None;
if let Some(q) = at {
dm.text_mid = v3(q);
dm.user_text_pos = true;
// DIMTMOVE 0: the dimension line moves with the text.
st.follow_text(dm, dimscale);
}
match mode.as_str() {
"home" => {
dm.user_text_pos = false;
dm.text_rotation = 0.0;
dm.overrides.remove("textJust");
}
"angle" => dm.text_rotation = angle.unwrap_or(0.0),
"left" | "center" | "right" => {
dm.user_text_pos = false;
let j = match mode.as_str() {
"left" => 1,
"right" => 2,
_ => 0,
};
dm.overrides.insert("textJust".into(), json!(j));
}
_ => {}
}
})?;
}
Ok(json!({ "changed": hs.len() }))
}
/// DIMSPACE: equal spacing of parallel linear dimensions from a base dimension.
pub(super) fn run_dimspace(s: &mut Session, p: &Value) -> Result<Value> {
let base = hex_param(p, "base").ok_or_else(|| bad("dimspace", "`base` dimension is required"))?;
let hs: Vec<Handle> = dims_of(s, &targets(s, p)?).into_iter().filter(|h| *h != base).collect();
let d = s.doc()?;
let Some(EntityKind::Dimension(b)) = d.entity(base).map(|e| e.kind.clone()) else { return Err(bad("dimspace", "`base` is not a dimension")) };
let st = d.dim_style(&b.style).cloned().unwrap_or_default().with_overrides(&b.overrides);
let spacing = p
.get("spacing")
.and_then(Value::as_f64)
.filter(|v| v.is_finite() && *v >= 0.0)
.unwrap_or(st.baseline_spacing * st.effective_scale(d.header.f64("DIMSCALE", 1.0)) * 2.0);
let dir = match b.kind {
DimKind::Linear { rotation } => Vec2::from_angle(rotation),
DimKind::Aligned => (b.p14.xy() - b.p13.xy()).normalized(),
_ => return Err(bad("dimspace", "base must be a linear or aligned dimension")),
};
let n = dir.perp();
let base_off = (b.defpt.xy() - b.p13.xy()).dot(n);
let side = if base_off >= 0.0 { 1.0 } else { -1.0 };
// Order the others by their current distance from the base line.
let mut others: Vec<(Handle, f64)> = hs
.iter()
.filter_map(|h| match d.entity(*h).map(|e| &e.kind) {
Some(EntityKind::Dimension(o)) if matches!(o.kind, DimKind::Linear { .. } | DimKind::Aligned) => {
Some((*h, (o.defpt.xy() - b.defpt.xy()).dot(n) * side))
}
_ => None,
})
.collect();
others.sort_by(|a, c| a.1.total_cmp(&c.1));
let line_pt = b.defpt.xy();
let dm = s.doc_mut()?;
for (i, (h, _)) in others.iter().enumerate() {
let target = line_pt + n * (side * spacing * (i as f64 + 1.0));
dm.modify_entity(*h, |e| {
if let EntityKind::Dimension(o) = &mut e.kind {
let cur = o.defpt.xy();
let moved = cur + n * (target - cur).dot(n);
o.defpt = v3(moved);
o.user_text_pos = false;
o.block = None;
}
})?;
}
Ok(json!({ "spaced": others.len() }))
}
/// DIMSPACE at the command line: select the base dimension, select the dimensions to space, then
/// the spacing (Enter or Auto takes the DIMDLI-based default).
#[derive(Default)]
pub(super) struct DimSpaceM {
base: Option<Handle>,
sel: SelectPhase,
spacing_phase: bool,
}
impl DimSpaceM {
fn apply(&self, s: &mut Session, base: Handle, spacing: Option<f64>) -> Result<Step> {
let mut p = serde_json::Map::new();
p.insert("base".into(), json!(base.hex()));
p.insert("handles".into(), json!(self.sel.picked.iter().map(|h| h.hex()).collect::<Vec<_>>()));
if let Some(v) = spacing {
p.insert("spacing".into(), json!(v));
}
let r = run_dimspace(s, &Value::Object(p))?;
s.set_selection(Vec::new());
s.echo(format!("{} dimension(s) spaced.", r.get("spaced").and_then(Value::as_u64).unwrap_or(0)));
Ok(Step::Done)
}
}
impl Interactive for DimSpaceM {
fn name(&self) -> &'static str {
"DIMSPACE"
}
fn begin(&mut self, _s: &mut Session) -> Result<Step> {
self.sel = SelectPhase::single();
Ok(Step::Continue)
}
fn prompt(&self, _s: &Session) -> Prompt {
if self.spacing_phase {
return Prompt::new("Enter value", Accept::NUMBER).kw(&["Auto"]).default("Auto");
}
if self.base.is_none() {
return Prompt::new("Select base dimension", Accept::SELECT);
}
Prompt::new("Select dimensions to space", Accept::SELECT)
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
if self.spacing_phase {
let Some(base) = self.base else { return Ok(Step::Cancel) };
return match i {
Input::Enter => self.apply(s, base, None),
Input::Keyword(k) | Input::Text(k) if k.trim().eq_ignore_ascii_case("a") || k.trim().eq_ignore_ascii_case("auto") => {
self.apply(s, base, None)
}
Input::Text(t) => match crate::cmd::machines::number(&t).filter(|v| v.is_finite() && *v >= 0.0) {
Some(v) => self.apply(s, base, Some(v)),
None => {
s.echo("Requires a non-negative number or Auto.");
Ok(Step::Continue)
}
},
_ => Ok(Step::Continue),
};
}
match self.sel.feed(s, &i)? {
SelOutcome::More => Ok(Step::Continue),
SelOutcome::Empty => Ok(Step::Done),
SelOutcome::Done(hs) => {
if self.base.is_none() {
match hs.first().copied().filter(|h| !dims_of(s, &[*h]).is_empty()) {
Some(b) => {
self.base = Some(b);
self.sel = SelectPhase::default();
}
None => {
s.echo("Select a dimension.");
self.sel = SelectPhase::single();
}
}
} else {
self.sel.picked = hs;
self.spacing_phase = true;
}
Ok(Step::Continue)
}
}
}
}
/// DIMOVERRIDE at the command line: variable, value (repeat), Enter, then select dimensions.
pub(super) struct OverrideM {
vars: serde_json::Map<String, Value>,
var: Option<String>,
clear: bool,
selecting: Option<crate::cmd::machines::SelectPhase>,
}
impl OverrideM {
pub(super) fn new(_s: &Session) -> Self {
OverrideM { vars: serde_json::Map::new(), var: None, clear: false, selecting: None }
}
fn apply(&self, s: &mut Session, hs: Vec<Handle>) -> Result<Step> {
let mut p = self.vars.clone();
p.insert("handles".into(), json!(hs.iter().map(|h| h.hex()).collect::<Vec<_>>()));
if self.clear {
p.insert("clear".into(), json!(true));
}
let r = crate::cmd::props::dim_override(s, &Value::Object(p))?;
s.echo(format!("{} dimension(s) changed.", r.get("changed").and_then(Value::as_u64).unwrap_or(0)));
Ok(Step::Done)
}
}
impl Interactive for OverrideM {
fn name(&self) -> &'static str {
"DIMOVERRIDE"
}
fn prompt(&self, _s: &Session) -> Prompt {
if let Some(sel) = &self.selecting {
return sel.prompt();
}
match &self.var {
Some(v) => Prompt::new(format!("Enter new value for dimension variable {}", v.to_ascii_uppercase()), Accept::TEXT),
None => Prompt::new("Enter dimension variable name to override", Accept::TEXT).kw(&["Clear overrides"]),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
if let Some(sel) = &mut self.selecting {
return match sel.feed(s, &i)? {
crate::cmd::machines::SelOutcome::More => Ok(Step::Continue),
crate::cmd::machines::SelOutcome::Empty => Ok(Step::Done),
crate::cmd::machines::SelOutcome::Done(hs) => self.apply(s, hs),
};
}
match (self.var.take(), i) {
(None, Input::Keyword(k)) if k.starts_with("Clear") => {
self.clear = true;
self.selecting = Some(crate::cmd::machines::SelectPhase::default());
}
(None, Input::Text(t)) => {
let t = t.trim().to_string();
if t.eq_ignore_ascii_case("c") {
self.clear = true;
self.selecting = Some(crate::cmd::machines::SelectPhase::default());
} else if cadcraft_doc::DimStyle::field_name(&t).is_some() {
self.var = Some(t);
} else {
s.echo(format!("Unknown dimension variable \"{t}\"."));
}
}
(None, Input::Enter) => {
if self.vars.is_empty() {
return Ok(Step::Cancel);
}
self.selecting = Some(crate::cmd::machines::SelectPhase::default());
}
(Some(v), Input::Text(t)) => {
let val = t.trim().parse::<f64>().map(|x| json!(x)).unwrap_or_else(|_| json!(t.trim()));
self.vars.insert(v, val);
}
(Some(v), _) => self.var = Some(v),
_ => {}
}
Ok(Step::Continue)
}
fn preview(&self, _s: &Session, _c: Vec2) -> Vec<EntityKind> {
Vec::new()
}
}
+363
View File
@@ -0,0 +1,363 @@
use super::common::{DimText, add_dim, assoc_enabled, circle_of, dim, linear_kind, pick_at};
use super::{
Accept, DimKind, Dimension, EngineError, EntityKind, Handle, Input, Interactive, Prim, Prompt, Result, Segment, Session, Step, Value, Vec2, bad,
json, point_req, points_param, str_param, v3,
};
pub(super) fn run_linear(s: &mut Session, p: &Value) -> Result<Value> {
let (a, b) = match p.get("object").and_then(|v| v.as_str().and_then(Handle::parse_hex).or_else(|| v.as_u64().map(Handle))) {
Some(h) => object_extent(s, h).ok_or_else(|| bad("dimlinear", "`object` must be a line, arc, circle or polyline segment"))?,
None => (point_req("dimlinear", p, "p1")?, point_req("dimlinear", p, "p2")?),
};
let at = point_req("dimlinear", p, "at")?;
let rot = p.get("rotation").and_then(Value::as_f64).map(f64::to_radians);
let k = dim(s, linear_kind(a, b, at, rot, false), at, a, b, Vec2::ZERO, str_param(p, "text").unwrap_or(""));
Ok(json!({ "handle": add_dim(s, k)?.hex() }))
}
pub(super) fn run_aligned(s: &mut Session, p: &Value) -> Result<Value> {
let a = point_req("dimaligned", p, "p1")?;
let b = point_req("dimaligned", p, "p2")?;
let at = point_req("dimaligned", p, "at")?;
let k = dim(s, DimKind::Aligned, at, a, b, Vec2::ZERO, str_param(p, "text").unwrap_or(""));
Ok(json!({ "handle": add_dim(s, k)?.hex() }))
}
pub(super) fn run_ordinate(s: &mut Session, p: &Value) -> Result<Value> {
let f = point_req("dimordinate", p, "feature")?;
let l = point_req("dimordinate", p, "leader")?;
let xtype = p.get("xtype").and_then(Value::as_bool).unwrap_or((l - f).y.abs() > (l - f).x.abs());
let k = dim(s, DimKind::Ordinate { x_type: xtype }, Vec2::ZERO, f, l, Vec2::ZERO, "");
Ok(json!({ "handle": add_dim(s, k)?.hex() }))
}
/// The last linear/aligned dimension (for CONTINUE/BASELINE).
fn last_linear(s: &Session) -> Option<Dimension> {
let h = s.last_dim?;
match &s.doc().ok()?.entity(h)?.kind {
EntityKind::Dimension(d) if matches!(d.kind, DimKind::Linear { .. } | DimKind::Aligned) => Some(d.clone()),
_ => None,
}
}
/// Next dimension in a chain from `prev` to a new second origin.
fn chained(s: &Session, prev: &Dimension, next: Vec2, baseline: bool) -> EntityKind {
// DIMDLI × the overall scale of the dimension being continued: its overrides (SETVAR DIM*)
// included, and DIMSCALE 0 falling back to the drawing's DIMSCALE as when it is drawn.
let spacing = s
.doc()
.ok()
.map(|d| {
let st = d.dim_style(&prev.style).cloned().unwrap_or_default().with_overrides(&prev.overrides);
st.baseline_spacing * st.effective_scale(d.header.f64("DIMSCALE", 1.0))
})
.filter(|v| v.is_finite())
.unwrap_or(0.38);
let dir = match prev.kind {
DimKind::Linear { rotation } => Vec2::from_angle(rotation),
_ => (prev.p14.xy() - prev.p13.xy()).normalized(),
};
let first = if baseline { prev.p13.xy() } else { prev.p14.xy() };
let mut defpt = prev.defpt.xy();
if baseline {
// Offset the dimension line away from the measured points.
let n = dir.perp();
let side = if (defpt - prev.p13.xy()).dot(n) >= 0.0 { 1.0 } else { -1.0 };
defpt += n * (spacing * side);
}
EntityKind::Dimension(Dimension {
p13: v3(first),
p14: v3(next),
defpt: v3(defpt),
text: String::new(),
block: None,
assoc: Vec::new(),
marks: Default::default(),
user_text_pos: false,
..prev.clone()
})
}
/// Most dimensions one DIMCONTINUE/DIMBASELINE call adds.
pub(super) const MAX_CHAIN: usize = 100_000;
fn chain_run(s: &mut Session, p: &Value, baseline: bool) -> Result<Value> {
let id = if baseline { "dimbaseline" } else { "dimcontinue" };
let pts = p.get("points").and_then(Value::as_array).ok_or_else(|| bad(id, "`points` is required"))?;
if pts.len() > MAX_CHAIN {
return Err(bad(id, format!("at most {MAX_CHAIN} `points` per call")));
}
let pts = points_param(p, "points").ok_or_else(|| bad(id, "`points` must be a list of points"))?;
// One snap index for the whole chain: looking up each new point's object by scanning the
// drawing made long chains quadratic (the added dimensions are no snap targets).
let snaps = if assoc_enabled(s) { Some(crate::assoc::SnapIndex::new(s.doc()?, &s.space())) } else { None };
let mut out = Vec::new();
for q in pts {
let prev = last_linear(s).ok_or_else(|| bad(id, "no linear dimension to continue"))?;
let mut k = chained(s, &prev, q, baseline);
if let (Some(ix), EntityKind::Dimension(dm)) = (&snaps, &mut k) {
dm.assoc = ix.auto_assoc(&[("p13", dm.p13.xy()), ("p14", dm.p14.xy())]);
}
let h = s.add_entity(k)?;
s.last_dim = Some(h);
out.push(h.hex());
}
Ok(json!({ "handles": out }))
}
pub(super) fn run_continue(s: &mut Session, p: &Value) -> Result<Value> {
chain_run(s, p, false)
}
pub(super) fn run_baseline(s: &mut Session, p: &Value) -> Result<Value> {
chain_run(s, p, true)
}
/// The two points a picked object is dimensioned between (DIMLINEAR "select object").
fn object_extent(s: &Session, h: Handle) -> Option<(Vec2, Vec2)> {
let e = s.doc().ok()?.entity(h)?;
if let Some((c, r)) = circle_of(s, h).filter(|_| matches!(e.kind, EntityKind::Circle(_))) {
return Some((c - Vec2::X * r, c + Vec2::X * r));
}
e.kind.prims().into_iter().find_map(|pr| match pr {
Prim::Seg(sg @ (Segment::Line(_) | Segment::Arc { .. })) => Some((sg.start(), sg.end())),
_ => None,
})
}
pub(super) struct LinearM {
aligned: bool,
pts: Vec<Vec2>,
rotation: Option<f64>,
text: DimText,
selecting: bool,
/// Asking for the dimension line angle (Rotated).
rotating: bool,
}
impl LinearM {
pub(super) fn new(aligned: bool) -> Self {
LinearM { aligned, pts: Vec::new(), rotation: None, text: DimText::default(), selecting: false, rotating: false }
}
}
impl Interactive for LinearM {
fn name(&self) -> &'static str {
if self.aligned { "DIMALIGNED" } else { "DIMLINEAR" }
}
fn prompt(&self, _s: &Session) -> Prompt {
if self.rotating {
return Prompt::new("Specify angle of dimension line", Accept::NUMBER);
}
if let Some(p) = self.text.prompt() {
return p;
}
if self.selecting {
return Prompt::new("Select object to dimension", Accept::POINT);
}
match self.pts.len() {
0 => Prompt::new("Specify first extension line origin", Accept::POINT).default("select object"),
1 => Prompt::new("Specify second extension line origin", Accept::POINT).base_opt(self.pts.first().copied()),
// Once the orientation is forced, only the text options remain.
_ if self.aligned || self.rotation.is_some() => {
Prompt::new("Specify dimension line location", Accept::POINT).kw(&["Mtext", "Text", "Angle"])
}
_ => Prompt::new("Specify dimension line location", Accept::POINT).kw(&["Mtext", "Text", "Angle", "Horizontal", "Vertical", "Rotated"]),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
if self.rotating {
if let Input::Text(t) = &i {
let Some(r) = s.angle_settings().direction(t) else {
s.echo("Requires a valid angle.");
return Ok(Step::Continue);
};
self.rotation = Some(r);
}
self.rotating = false;
return Ok(Step::Continue);
}
if self.text.answer(s, &i) {
return Ok(Step::Continue);
}
if self.selecting {
if let Input::Point(p) = i {
let Some(h) = pick_at(s, p) else {
s.echo("*Invalid selection*");
return Ok(Step::Continue);
};
let seg =
s.doc()?.entity(h).and_then(|e| e.kind.prims().into_iter().find_map(|pr| if let Prim::Seg(sg) = pr { Some(sg) } else { None }));
match seg {
Some(sg @ Segment::Line(_)) | Some(sg @ Segment::Arc { .. }) => {
self.pts = vec![sg.start(), sg.end()];
self.selecting = false;
}
None => {
if let Some((c, r)) = circle_of(s, h) {
self.pts = vec![c - Vec2::X * r, c + Vec2::X * r];
self.selecting = false;
} else {
s.echo("Object cannot be dimensioned this way.");
}
}
}
}
return Ok(Step::Continue);
}
match (self.pts.len(), i) {
(0, Input::Enter) => {
self.selecting = true;
Ok(Step::Continue)
}
(n, Input::Point(p)) if n < 2 => {
self.pts.push(p);
Ok(Step::Continue)
}
(_, Input::Point(at)) => {
let (Some(&a), Some(&b)) = (self.pts.first(), self.pts.get(1)) else { return Ok(Step::Continue) };
let k = self.text.apply(dim(s, linear_kind(a, b, at, self.rotation, self.aligned), at, a, b, Vec2::ZERO, ""));
add_dim(s, k)?;
if let Some(EntityKind::Dimension(d)) = s.last_dim.and_then(|h| s.doc().ok()?.entity(h).map(|e| e.kind.clone())) {
let st = s.doc()?.dim_style(&d.style).cloned().unwrap_or_default();
let g = cadcraft_render::dimension_geometry(&d, &st, 1.0);
s.echo(format!("Dimension text = {}", g.value));
}
Ok(Step::Done)
}
(_, Input::Keyword(k)) => {
match k.as_str() {
"Horizontal" => self.rotation = Some(0.0),
"Vertical" => self.rotation = Some(std::f64::consts::FRAC_PI_2),
"Rotated" => self.rotating = true,
other => {
self.text.keyword(other);
}
}
Ok(Step::Continue)
}
(_, Input::Enter) => Ok(Step::Cancel),
_ => Ok(Step::Continue),
}
}
fn preview(&self, s: &Session, c: Vec2) -> Vec<EntityKind> {
match self.pts.as_slice() {
[a] => vec![crate::cmd::helpers::line(*a, c)],
[a, b] => vec![self.text.apply(dim(s, linear_kind(*a, *b, c, self.rotation, self.aligned), c, *a, *b, Vec2::ZERO, ""))],
_ => Vec::new(),
}
}
}
#[derive(Default)]
pub(super) struct OrdinateM {
feature: Option<Vec2>,
/// Xdatum / Ydatum: the forced ordinate type (`true` = X datum).
xtype: Option<bool>,
text: DimText,
}
impl OrdinateM {
fn kind(&self, s: &Session, f: Vec2, l: Vec2) -> EntityKind {
let x = self.xtype.unwrap_or((l - f).y.abs() > (l - f).x.abs());
self.text.apply(dim(s, DimKind::Ordinate { x_type: x }, Vec2::ZERO, f, l, Vec2::ZERO, ""))
}
}
impl Interactive for OrdinateM {
fn name(&self) -> &'static str {
"DIMORDINATE"
}
fn prompt(&self, _s: &Session) -> Prompt {
if let Some(p) = self.text.prompt() {
return p;
}
match (self.feature, self.xtype) {
(None, _) => Prompt::new("Specify feature location", Accept::POINT),
(Some(f), None) => Prompt::new("Specify leader endpoint", Accept::POINT).kw(&["Xdatum", "Ydatum", "Mtext", "Text", "Angle"]).base(f),
(Some(f), Some(_)) => Prompt::new("Specify leader endpoint", Accept::POINT).kw(&["Mtext", "Text", "Angle"]).base(f),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
if self.text.answer(s, &i) {
return Ok(Step::Continue);
}
match (self.feature, i) {
(None, Input::Point(p)) => {
self.feature = Some(p);
Ok(Step::Continue)
}
(Some(_), Input::Keyword(k)) => {
match k.as_str() {
"Xdatum" => self.xtype = Some(true),
"Ydatum" => self.xtype = Some(false),
other => {
self.text.keyword(other);
}
}
Ok(Step::Continue)
}
(Some(f), Input::Point(l)) => {
add_dim(s, self.kind(s, f, l))?;
Ok(Step::Done)
}
(_, Input::Enter) => Ok(Step::Cancel),
_ => Ok(Step::Continue),
}
}
fn preview(&self, s: &Session, c: Vec2) -> Vec<EntityKind> {
self.feature.map(|f| vec![self.kind(s, f, c)]).unwrap_or_default()
}
}
pub(super) struct ChainM {
baseline: bool,
created: Vec<Handle>,
}
impl ChainM {
pub(super) fn new(_s: &Session, baseline: bool) -> Self {
ChainM { baseline, created: Vec::new() }
}
}
impl Interactive for ChainM {
fn name(&self) -> &'static str {
if self.baseline { "DIMBASELINE" } else { "DIMCONTINUE" }
}
fn begin(&mut self, s: &mut Session) -> Result<Step> {
if last_linear(s).is_none() {
s.echo("No linear dimension to continue. Create one with DIMLINEAR first.");
return Ok(Step::Done);
}
Ok(Step::Continue)
}
fn prompt(&self, s: &Session) -> Prompt {
Prompt::new("Specify a second extension line origin", Accept::POINT)
.kw(&["Select", "Undo"])
.default("Select")
.base_opt(last_linear(s).map(|d| if self.baseline { d.p13.xy() } else { d.p14.xy() }))
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
match i {
Input::Point(p) => {
let prev = last_linear(s).ok_or_else(|| EngineError::Other("no linear dimension".into()))?;
let k = chained(s, &prev, p, self.baseline);
self.created.push(add_dim(s, k)?);
Ok(Step::Continue)
}
Input::Keyword(k) if k == "Undo" => {
if let Some(h) = self.created.pop() {
s.doc_mut()?.remove_entity(h);
s.last_dim = self.created.last().copied();
}
Ok(Step::Continue)
}
Input::Enter => Ok(Step::Done),
_ => Ok(Step::Continue),
}
}
fn preview(&self, s: &Session, c: Vec2) -> Vec<EntityKind> {
last_linear(s).map(|d| vec![chained(s, &d, c, self.baseline)]).unwrap_or_default()
}
}
+392
View File
@@ -0,0 +1,392 @@
use super::common::style;
use super::{
Accept, EngineError, EntityKind, Input, Interactive, LeaderOrder, MLeader, MLeaderOptions, MText, Prompt, Result, Session, Step, Value, Vec2,
bad, json, points_param, str_param, v3,
};
/// A multileader from arrowhead to landing; `landing` false leaves out the landing line (dogleg).
fn mleader_kind(s: &Session, pts: &[Vec2], text: &str, landing: bool) -> Option<EntityKind> {
let (landing_pt, arrow_pts) = pts.split_last()?;
let d = s.doc().ok()?;
let cur = d.header.str("CMLEADERSTYLE", "Standard");
let st = d.mleader_styles.iter().find(|m| m.name.eq_ignore_ascii_case(&cur)).or(d.mleader_styles.first()).cloned().unwrap_or_default();
// The style's sizes are the drawn sizes: a multileader style has no scale of its own and
// DIMSCALE (a dimension-style setting) does not apply, as in AutoCAD.
let th = st.text_height;
let dir = if arrow_pts.first().is_some_and(|a| a.x > landing_pt.x) { -1.0 } else { 1.0 };
let attach = if dir > 0.0 { 4 } else { 6 };
let dogleg = if landing { st.dogleg } else { 0.0 };
let tpos = *landing_pt + Vec2::new((dogleg + st.landing_gap) * dir, 0.0);
Some(EntityKind::MLeader(MLeader {
leaders: vec![arrow_pts.iter().map(|p| v3(*p)).collect()],
landing: v3(*landing_pt),
dogleg,
text: (!text.is_empty()).then(|| MText {
insert: v3(tpos),
height: th,
width: 0.0,
attach,
rotation: 0.0,
style: st.text_style.clone(),
contents: text.replace('\n', "\\P"),
line_spacing: 1.0,
line_spacing_exact: false,
columns: None,
}),
style: st.name.clone(),
arrow_size: st.arrow_size,
spline: false,
block: None,
}))
}
pub(super) fn run_mleader(s: &mut Session, p: &Value) -> Result<Value> {
let pts = points_param(p, "points").ok_or_else(|| bad("mleader", "`points` (arrow … landing) is required"))?;
let content = crate::cmd::mleader_content::json_content(s, p)?;
// Leader type None places the content at the one point given.
if pts.len() < 2 && !(content.leader == crate::LeaderType::None && pts.len() == 1) {
return Err(bad("mleader", "need an arrowhead and a landing point"));
}
let k = mleader_kind(s, &pts, str_param(p, "text").unwrap_or(""), true).ok_or_else(|| bad("mleader", "bad points"))?;
let k = crate::cmd::mleader_content::apply(s, k, &content)?;
Ok(json!({ "handle": s.add_entity(k)?.hex() }))
}
pub(super) fn run_leader(s: &mut Session, p: &Value) -> Result<Value> {
let pts = points_param(p, "points").ok_or_else(|| bad("leader", "`points` is required"))?;
if pts.len() < 2 {
return Err(bad("leader", "need 2+ points"));
}
let st = style(s);
let h = s.add_entity(EntityKind::Leader(cadcraft_doc::Leader {
vertices: pts.iter().map(|q| v3(*q)).collect(),
arrow: true,
spline: false,
style: st,
}))?;
let mut out = vec![h.hex()];
if let (Some(t), Some(last)) = (str_param(p, "text"), pts.last()) {
let th = s.doc()?.dim_style(&style(s)).map(|d| d.text_height).unwrap_or(0.18);
out.push(
s.add_entity(EntityKind::MText(MText {
insert: v3(*last + Vec2::new(th * 0.5, th * 0.5)),
height: th,
width: 0.0,
attach: 4,
rotation: 0.0,
style: "Standard".into(),
contents: t.into(),
line_spacing: 1.0,
line_spacing_exact: false,
columns: None,
}))?
.hex(),
);
}
Ok(json!({ "handles": out }))
}
/// The MLEADER Options prompt being answered.
#[derive(Clone, Copy, PartialEq)]
enum LeaderOpt {
Menu,
LeaderType,
Landing,
ContentType,
MaxPoints,
FirstAngle,
SecondAngle,
BlockName,
BlockAttach,
}
/// Upper bound for MLEADER Maxpoints.
const MAX_LEADER_POINTS: usize = 64;
pub(super) struct MLeaderM {
order: LeaderOrder,
/// Points in the order they were placed.
pts: Vec<Vec2>,
/// All leader points are placed.
leader_done: bool,
/// The leader text once entered (Content first enters it before the leader).
text: Option<String>,
opt: Option<LeaderOpt>,
max_points: usize,
/// First and second angle constraints (radians; 0 = free).
angles: [f64; 2],
landing: bool,
content: bool,
leader_type: crate::LeaderType,
/// Block content: the block (`block_name`) instead of text.
block: bool,
block_name: String,
block_center: bool,
}
impl MLeaderM {
/// Starts with the order and Options chosen the last time (as AutoCAD does).
pub(super) fn new(s: &Session) -> Self {
let o = s.last_used.mleader;
let max_points = o.max_points.clamp(2, MAX_LEADER_POINTS);
MLeaderM {
order: o.order,
pts: Vec::new(),
leader_done: false,
text: None,
opt: None,
max_points,
angles: o.angles,
landing: o.landing,
content: o.content,
leader_type: o.leader_type,
// Block content needs a block that still exists.
block: o.block && s.doc().is_ok_and(|d| d.block(&s.last_used.mleader_block).is_some()),
block_name: s.last_used.mleader_block.clone(),
block_center: o.block_center,
}
}
/// Keep the order and Options for the next MLEADER.
fn remember(&self, s: &mut Session) {
s.last_used.mleader = MLeaderOptions {
order: self.order,
max_points: self.max_points,
angles: self.angles,
landing: self.landing,
content: self.content,
leader_type: self.leader_type,
block: self.block,
block_center: self.block_center,
};
s.last_used.mleader_block = self.block_name.clone();
}
/// The leader type and block content for `mleader_content::apply`.
fn content_spec(&self) -> crate::cmd::mleader_content::Content {
crate::cmd::mleader_content::Content {
leader: self.leader_type,
block: (self.content && self.block).then(|| (self.block_name.clone(), self.block_center)),
}
}
/// Leader type None: the one point placed is the content location.
fn no_leader(&self) -> bool {
self.leader_type == crate::LeaderType::None
}
/// The leader from arrowhead to landing, with `extra` placed next.
fn leader(&self, extra: Option<Vec2>) -> Vec<Vec2> {
let mut pts = self.pts.clone();
pts.extend(extra);
if self.order != LeaderOrder::Arrowhead {
pts.reverse();
}
pts
}
/// `p` with the first/second angle constraint applied to the segment it ends (counted from the
/// first point placed).
fn constrained(&self, p: Vec2) -> Vec2 {
let (Some(&prev), Some(&inc)) = (self.pts.last(), self.angles.get(self.pts.len().wrapping_sub(1))) else { return p };
let len = prev.dist(p);
if inc <= 1e-9 || !inc.is_finite() || len <= 1e-12 {
return p;
}
Vec2::polar(prev, len, ((p - prev).angle() / inc).round() * inc)
}
/// The text is asked now: right after the content location (Content first) or after the leader.
fn wants_text(&self) -> bool {
self.content
&& !self.block
&& self.text.is_none()
&& (self.leader_done || (self.order == LeaderOrder::Content && self.pts.len() == 1 && !self.no_leader()))
}
fn finish(&self, s: &mut Session) -> Result<Step> {
if self.no_leader() && !self.content {
s.echo("A multileader needs a leader line or content; nothing was drawn.");
return Ok(Step::Cancel);
}
let text = self.text.clone().unwrap_or_default();
let k = mleader_kind(s, &self.leader(None), &text, self.landing).ok_or_else(|| EngineError::Other("bad leader".into()))?;
let k = crate::cmd::mleader_content::apply(s, k, &self.content_spec())?;
s.add_entity(k)?;
Ok(Step::Done)
}
fn option_prompt(o: LeaderOpt) -> Prompt {
let kw = crate::cmd::curves::KW;
match o {
LeaderOpt::Menu => Prompt::new("Enter an option", kw)
.kw(&["Leader type", "leader lAnding", "Content type", "Maxpoints", "First angle", "Second angle", "eXit options"])
.default("eXit options"),
LeaderOpt::LeaderType => Prompt::new("Select a leader type", kw).kw(&["Straight", "sPline", "None"]).default("Straight"),
LeaderOpt::Landing => Prompt::new("Use landing", kw).kw(&["Yes", "No"]).default("Yes"),
LeaderOpt::ContentType => Prompt::new("Select a content type", kw).kw(&["Block", "Mtext", "None"]).default("Mtext"),
LeaderOpt::MaxPoints => Prompt::new("Enter the maximum points for leader line", Accept::NUMBER),
LeaderOpt::FirstAngle => Prompt::new("Enter first angle constraint", Accept::NUMBER),
LeaderOpt::SecondAngle => Prompt::new("Enter second angle constraint", Accept::NUMBER),
LeaderOpt::BlockName => Prompt::new("Enter block name", kw),
LeaderOpt::BlockAttach => {
Prompt::new("Specify block attachment", kw).kw(&["Center extents", "Insertion point"]).default("Center extents")
}
}
}
/// One answer at the Options prompts; Enter keeps the current value.
fn option(&mut self, s: &mut Session, o: LeaderOpt, i: Input) -> Option<LeaderOpt> {
use LeaderOpt::*;
match (o, i) {
(Menu, Input::Keyword(k)) => match k.as_str() {
"Leader type" => Some(LeaderType),
"leader lAnding" => Some(Landing),
"Content type" => Some(ContentType),
"Maxpoints" => Some(MaxPoints),
"First angle" => Some(FirstAngle),
"Second angle" => Some(SecondAngle),
_ => None,
},
(Menu, Input::Enter) => None,
(LeaderType, Input::Keyword(k)) => {
self.leader_type = match k.as_str() {
"sPline" => crate::LeaderType::Spline,
"None" => crate::LeaderType::None,
_ => crate::LeaderType::Straight,
};
Some(Menu)
}
(Landing, Input::Keyword(k)) => {
self.landing = k == "Yes";
Some(Menu)
}
(ContentType, Input::Keyword(k)) => {
self.content = k != "None";
self.block = false;
// Block content asks which block, then how it attaches.
if k == "Block" { Some(BlockName) } else { Some(Menu) }
}
(BlockName, Input::Text(t)) => match crate::cmd::mleader_content::block_name(s, &t) {
Ok(n) => {
self.block_name = n;
self.block = true;
Some(BlockAttach)
}
Err(e) => {
s.echo(e.to_string());
Some(BlockName)
}
},
(BlockName, Input::Enter) => {
// Enter keeps the block chosen before, if there is one.
if crate::cmd::mleader_content::block_name(s, &self.block_name).is_ok() {
self.block = true;
Some(BlockAttach)
} else {
s.echo("Requires the name of a block defined in the drawing.");
Some(BlockName)
}
}
(BlockAttach, Input::Keyword(k)) => {
self.block_center = k == "Center extents";
Some(Menu)
}
(MaxPoints, Input::Text(t)) => match t.trim().parse::<usize>() {
Ok(n) if (2..=MAX_LEADER_POINTS).contains(&n) => {
self.max_points = n;
Some(Menu)
}
_ => {
s.echo(format!("Requires an integer between 2 and {MAX_LEADER_POINTS}."));
Some(MaxPoints)
}
},
(FirstAngle | SecondAngle, Input::Text(t)) => match s.angle_settings().amount(&t).filter(|a| *a >= 0.0) {
Some(a) => {
self.angles[usize::from(o == SecondAngle)] = a;
Some(Menu)
}
None => {
s.echo("Requires a valid angle.");
Some(o)
}
},
(Menu | LeaderType | Landing | ContentType | BlockAttach, Input::Text(_)) => {
s.echo("Invalid option keyword.");
Some(o)
}
(_, Input::Enter) => Some(Menu),
_ => Some(o),
}
}
}
impl Interactive for MLeaderM {
fn name(&self) -> &'static str {
"MLEADER"
}
fn prompt(&self, _s: &Session) -> Prompt {
if let Some(o) = self.opt {
return Self::option_prompt(o);
}
if self.wants_text() {
return Prompt::new("Enter leader text", Accept::TEXT);
}
if self.no_leader() && self.pts.is_empty() {
let what = if self.content && self.block { "Specify location of the block" } else { "Specify location of the text" };
return Prompt::new(what, Accept::POINT).kw(&["Options"]).default("Options");
}
let last = self.pts.len() + 1 >= self.max_points;
match (self.pts.last(), self.order) {
(None, LeaderOrder::Arrowhead) => Prompt::new("Specify leader arrowhead location", Accept::POINT)
.kw(&["leader Landing first", "Content first", "Options"])
.default("Options"),
(None, LeaderOrder::Landing) => Prompt::new("Specify leader landing location", Accept::POINT)
.kw(&["leader arrowHead first", "Content first", "Options"])
.default("Options"),
(None, LeaderOrder::Content) => Prompt::new("Specify location of the text", Accept::POINT)
.kw(&["leader arrowHead first", "leader Landing first", "Options"])
.default("Options"),
(Some(&b), _) if !last => Prompt::new("Specify next point", Accept::POINT).base(b),
(Some(&b), LeaderOrder::Arrowhead) => Prompt::new("Specify leader landing location", Accept::POINT).base(b),
(Some(&b), _) => Prompt::new("Specify leader arrowhead location", Accept::POINT).base(b),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
if let Some(o) = self.opt {
self.opt = self.option(s, o, i);
self.remember(s);
return Ok(Step::Continue);
}
if self.wants_text() {
self.text = Some(if let Input::Text(t) = i { t } else { String::new() });
return if self.leader_done { self.finish(s) } else { Ok(Step::Continue) };
}
match i {
Input::Point(p) => {
let p = self.constrained(p);
self.pts.push(p);
if self.pts.len() >= self.max_points || self.no_leader() {
self.leader_done = true;
}
}
Input::Keyword(k) if self.pts.is_empty() => {
match k.as_str() {
"leader Landing first" => self.order = LeaderOrder::Landing,
"leader arrowHead first" => self.order = LeaderOrder::Arrowhead,
"Content first" => self.order = LeaderOrder::Content,
_ => self.opt = Some(LeaderOpt::Menu),
}
self.remember(s);
}
Input::Enter if self.pts.is_empty() => self.opt = Some(LeaderOpt::Menu),
// Enter at "next point" ends the leader at the last point.
Input::Enter if self.pts.len() >= 2 => self.leader_done = true,
Input::Enter => return Ok(Step::Cancel),
_ => {}
}
if self.leader_done && !self.wants_text() { self.finish(s) } else { Ok(Step::Continue) }
}
fn preview(&self, s: &Session, c: Vec2) -> Vec<EntityKind> {
if self.opt.is_some() || self.wants_text() || self.leader_done || self.pts.is_empty() {
return Vec::new();
}
let text = self.text.as_deref().unwrap_or("");
mleader_kind(s, &self.leader(Some(self.constrained(c))), text, self.landing)
.and_then(|k| crate::cmd::mleader_content::apply(s, k, &self.content_spec()).ok())
.into_iter()
.collect()
}
}
+100
View File
@@ -0,0 +1,100 @@
use super::common::{DimText, add_dim, circle_of, dim, pick_at, text_at};
use super::{Accept, DimKind, EntityKind, Input, Interactive, Prompt, Result, Session, Step, Value, Vec2, bad, json, point_param, targets};
fn radial(s: &mut Session, p: &Value, diameter: bool) -> Result<Value> {
let id = if diameter { "dimdiameter" } else { "dimradius" };
let (c, pt, at) = if let (Some(c), Some(pt)) = (point_param(p, "center"), point_param(p, "point")) {
(c, pt, None)
} else {
let h = targets(s, p)?.first().copied().ok_or_else(|| bad(id, "`handle` of a circle/arc, or {center, point}"))?;
let (c, r) = circle_of(s, h).ok_or_else(|| bad(id, "object is not a circle or arc"))?;
let at = point_param(p, "at");
let dir = at.unwrap_or(c + Vec2::from_angle(std::f64::consts::FRAC_PI_4));
(c, c + (dir - c).normalized() * r, at)
};
if c.near(pt, 1e-12) {
return Err(bad(id, "`point` must differ from `center`"));
}
let k = if diameter {
dim(s, DimKind::Diameter, c + (c - pt), Vec2::ZERO, Vec2::ZERO, pt, "")
} else {
dim(s, DimKind::Radius, c, Vec2::ZERO, Vec2::ZERO, pt, "")
};
let k = match at {
Some(at) => text_at(k, at),
None => k,
};
Ok(json!({ "handle": add_dim(s, k)?.hex() }))
}
pub(super) fn run_radius(s: &mut Session, p: &Value) -> Result<Value> {
radial(s, p, false)
}
pub(super) fn run_diameter(s: &mut Session, p: &Value) -> Result<Value> {
radial(s, p, true)
}
pub(super) struct RadialM {
diameter: bool,
target: Option<(Vec2, f64)>,
text: DimText,
}
impl RadialM {
pub(super) fn new(diameter: bool) -> Self {
RadialM { diameter, target: None, text: DimText::default() }
}
fn kind(&self, s: &Session, c: Vec2, r: f64, at: Vec2) -> EntityKind {
let pt = c + (at - c).normalized() * r;
let k = self.text.apply(if self.diameter {
dim(s, DimKind::Diameter, c + (c - pt), Vec2::ZERO, Vec2::ZERO, pt, "")
} else {
dim(s, DimKind::Radius, c, Vec2::ZERO, Vec2::ZERO, pt, "")
});
text_at(k, at)
}
}
impl Interactive for RadialM {
fn name(&self) -> &'static str {
if self.diameter { "DIMDIAMETER" } else { "DIMRADIUS" }
}
fn prompt(&self, _s: &Session) -> Prompt {
if let Some(p) = self.text.prompt() {
return p;
}
match self.target {
None => Prompt::new("Select arc or circle", Accept::POINT),
Some((c, _)) => Prompt::new("Specify dimension line location", Accept::POINT).kw(&["Mtext", "Text", "Angle"]).base(c),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
if self.text.answer(s, &i) {
return Ok(Step::Continue);
}
match (self.target, i) {
(None, Input::Point(p)) => {
match pick_at(s, p).and_then(|h| circle_of(s, h)) {
Some(t) => self.target = Some(t),
None => s.echo("Object selected is not a circle or arc."),
}
Ok(Step::Continue)
}
(Some((c, r)), Input::Point(at)) => {
let k = self.kind(s, c, r, at);
add_dim(s, k)?;
Ok(Step::Done)
}
(Some(_), Input::Keyword(k)) => {
self.text.keyword(&k);
Ok(Step::Continue)
}
(_, Input::Enter) => Ok(Step::Cancel),
_ => Ok(Step::Continue),
}
}
fn preview(&self, s: &Session, c: Vec2) -> Vec<EntityKind> {
self.target.map(|(ctr, r)| vec![self.kind(s, ctr, r, c)]).unwrap_or_default()
}
}
+6 -3
View File
@@ -5,8 +5,11 @@ use cadcraft_doc::{DimBreak, DimKind, Dimension, EntityKind, Handle, Inspection,
use cadcraft_geom::{Segment, Vec2};
use serde_json::{Value, json};
use super::machines::{SelOutcome, SelectPhase};
use super::*;
use super::super::helpers::v3;
use super::super::machines::{SelOutcome, SelectPhase};
use super::super::{CommandSpec, bad, bool_or, point_param, point_req, size_param, str_param, targets};
use super::{DimText, add_dim, circle_of, dim, dims_of, pick_at};
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
/// Most objects DIMBREAK Auto looks at, and most breaks it adds to one dimension.
@@ -280,7 +283,7 @@ fn jogged_kind(s: &Session, c: Vec2, r: f64, center: Vec2, at: Vec2, jog: Option
}
let tip = c + dir * r;
let jog = jog.unwrap_or_else(|| center.mid(tip));
let mut k = dim(s, DimKind::Radius, c, Vec2::ZERO, Vec2::ZERO, tip, Vec2::ZERO, text);
let mut k = dim(s, DimKind::Radius, c, Vec2::ZERO, Vec2::ZERO, tip, text);
if let EntityKind::Dimension(dm) = &mut k {
dm.marks.jogged = Some(JoggedRadius { center: v3(center), jog: v3(jog) });
if at.dist(c) > r + 1e-9 {
+8 -7
View File
@@ -6,8 +6,10 @@ use cadcraft_doc::{DimKind, EntityKind, Handle, Prim};
use cadcraft_geom::{Segment, Vec2};
use serde_json::{Value, json};
use super::machines::{SelOutcome, SelectPhase};
use super::*;
use super::super::machines::{SelOutcome, SelectPhase};
use super::super::{bad, point_param, point_req, points_param, str_param, targets};
use super::{MAX_CHAIN, assoc_enabled, dim};
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
pub(super) const PARAMS: &str = "{handles?, at: [x,y] (dimension line position), mode?: continuous|staggered|baseline|ordinate|radius|diameter (default continuous), vertical?: bool (default: from `at`), datum?: [x,y] (baseline and ordinate; default the first point, or the origin for ordinate), add?: [[x,y]], remove?: [[x,y]] (dimension points; removes those within `tolerance`)}";
@@ -94,9 +96,9 @@ fn plan(
let dir = if dir == Vec2::ZERO || !dir.is_finite() { Vec2::from_angle(std::f64::consts::FRAC_PI_4) } else { dir };
let p = *c + dir * *r;
out.push(if mode == Mode::Diameter {
dim(s, DimKind::Diameter, *c - dir * *r, Vec2::ZERO, Vec2::ZERO, p, Vec2::ZERO, "")
dim(s, DimKind::Diameter, *c - dir * *r, Vec2::ZERO, Vec2::ZERO, p, "")
} else {
dim(s, DimKind::Radius, *c, Vec2::ZERO, Vec2::ZERO, p, Vec2::ZERO, "")
dim(s, DimKind::Radius, *c, Vec2::ZERO, Vec2::ZERO, p, "")
});
}
if out.is_empty() {
@@ -129,7 +131,7 @@ fn plan(
.iter()
.map(|p| {
let end = if vertical { Vec2::new(at.x, p.y) } else { Vec2::new(p.x, at.y) };
dim(s, DimKind::Ordinate { x_type: !vertical }, origin, *p, end, Vec2::ZERO, Vec2::ZERO, "")
dim(s, DimKind::Ordinate { x_type: !vertical }, origin, *p, end, Vec2::ZERO, "")
})
.collect());
}
@@ -145,8 +147,7 @@ fn plan(
let mid = bounds.center();
let out_dir =
if vertical { Vec2::new(if at.x >= mid.x { 1.0 } else { -1.0 }, 0.0) } else { Vec2::new(0.0, if at.y >= mid.y { 1.0 } else { -1.0 }) };
let lin =
|a: Vec2, b: Vec2, k: usize| dim(s, DimKind::Linear { rotation: rot }, at + out_dir * (step * k as f64), a, b, Vec2::ZERO, Vec2::ZERO, "");
let lin = |a: Vec2, b: Vec2, k: usize| dim(s, DimKind::Linear { rotation: rot }, at + out_dir * (step * k as f64), a, b, Vec2::ZERO, "");
Ok(match mode {
Mode::Baseline => {
// From the datum (the point nearest it, by default the first) to every other point.
+2 -1
View File
@@ -17,7 +17,8 @@ use serde_json::{Value, json};
use super::helpers::{line, v3};
use super::machines::{SelOutcome, SelectPhase, number};
use super::modify::transform_entities;
use super::*;
use super::{CommandSpec, bad, bool_or, f64_or, find_command, point_param, point_req, str_param, targets};
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
mod edit;
+2 -1
View File
@@ -16,7 +16,8 @@ use serde_json::{Value, json};
use super::super::helpers::v3;
use super::super::machines::{SelOutcome, SelectPhase, number};
use super::super::*;
use super::super::{CommandSpec, bad, bool_or, point_param, targets};
use super::{KEYWORDS, angle, check_size, count, fmt, nesting, other, yes, yes_no};
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
+3 -1
View File
@@ -5,7 +5,9 @@ use std::sync::Arc;
use serde_json::{Value, json};
use super::*;
use super::{CommandSpec, bad, bool_or, file, str_param};
use super::always;
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
pub fn specs() -> Vec<CommandSpec> {
+10 -4
View File
@@ -12,11 +12,14 @@ use serde_json::{Value, json};
use super::helpers::v3;
use super::machines::{SelOutcome, SelectPhase, number};
use super::*;
use super::{CommandSpec, bad, bool_or, f64_or, find_command, ok, point_param, point_req, point_value, str_param, targets};
use super::has_doc;
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
mod attedit;
mod burst;
mod description;
mod dyngrip;
mod extract;
pub(crate) mod refedit;
@@ -26,12 +29,12 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("block", "Make...", run_block)
.menu(&["Draw", "Block", "Make..."])
.alias(&["b", "bmake"])
.params("{name, base: [x,y], handles?, keep?: \"convert\"|\"retain\"|\"delete\" (default \"convert\"), description?, annotative?}")
.params("{name, base: [x,y], handles?, keep?: \"convert\"|\"retain\"|\"delete\" (default \"convert\"), description? (up to 255 characters, one line), annotative?}")
.interactive(|_| Ok(Box::new(BlockM::default()))),
// -BLOCK erases the selected objects once the block is defined.
CommandSpec::new("-block", "Create Block", run_dash_block)
.alias(&["-b"])
.params("{name, base: [x,y], handles?, keep?: \"delete\"|\"retain\"|\"convert\" (default \"delete\"), description?}")
.params("{name, base: [x,y], handles?, keep?: \"delete\"|\"retain\"|\"convert\" (default \"delete\"), description? (up to 255 characters, one line)}")
.interactive(|_| Ok(Box::new(BlockM { dash: true, ..BlockM::default() }))),
CommandSpec::new("insert", "Block...", run_insert)
.menu(&["Insert", "Block..."])
@@ -77,6 +80,7 @@ pub fn specs() -> Vec<CommandSpec> {
blockreplace::blockreplace_spec(),
extract::dataextraction_spec(),
extract::eattext_spec(),
description::description_spec(),
]
.into_iter()
.chain(attribs::attdisp_menu_specs())
@@ -264,7 +268,9 @@ fn run_block(s: &mut Session, p: &Value) -> Result<Value> {
let base = point_param(p, "base").unwrap_or(Vec2::ZERO);
let hs = targets(s, p)?;
let keep = keep_param("block", p, Keep::Convert)?;
let r = make_block(s, &name, base, &hs, keep, str_param(p, "description").unwrap_or(""))?;
let description = str_param(p, "description").unwrap_or("");
description::check_text("block", description)?;
let r = make_block(s, &name, base, &hs, keep, description)?;
// An annotative block: its references (the converted selection too) are annotative.
if bool_or(p, "annotative", false) {
let d = s.doc_mut()?;
+2 -2
View File
@@ -85,7 +85,7 @@ pub(crate) fn refused_in_editor(s: &Session, id: &str) -> Option<String> {
}
/// The block a block reference refers to.
fn block_of(d: &Drawing, h: Handle) -> Option<String> {
pub(super) fn block_of(d: &Drawing, h: Handle) -> Option<String> {
match d.entity(h).map(|e| &e.kind) {
Some(EntityKind::Insert(i)) => Some(dynamic_definition(d, &i.block).unwrap_or_else(|| i.block.clone())),
_ => None,
@@ -129,7 +129,7 @@ fn open(s: &mut Session, name: &str, create: bool) -> Result<Value> {
}
/// The one block reference selected, if exactly one is.
fn selected_reference(s: &Session) -> Option<String> {
pub(super) fn selected_reference(s: &Session) -> Option<String> {
let d = s.doc().ok()?;
let refs: Vec<String> = s.selection().iter().filter_map(|h| block_of(d, *h)).collect();
match refs.as_slice() {
+188
View File
@@ -0,0 +1,188 @@
//! Block Description (Modify > Object > Block Description...): changes the description of a
//! block definition. In the Block Editor it changes the edited block's description, which BSAVE
//! saves into the drawing as it saves the block's objects.
use std::sync::Arc;
use cadcraft_doc::Block;
use serde_json::{Value, json};
use super::bedit::selected_reference;
use crate::cmd::{CommandSpec, bad, has_doc, str_param};
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
/// The longest description a block takes, in characters.
const MAX_LEN: usize = 255;
pub(super) fn description_spec() -> CommandSpec {
CommandSpec::new("blocks.description", "Block Description...", run)
.menu(&["Modify", "Object", "Block Description..."])
.params("{name? (default the block the Block Editor is editing, else the one selected block reference's block), description (up to 255 characters, one line; \"\" clears it)} → {block, description}")
.enabled(has_doc)
.interactive(|_| Ok(Box::new(DescriptionM::default())))
}
/// The block the Block Editor is editing, when it is open on a block (not on the drawing itself).
fn edited_block(s: &Session) -> Option<String> {
s.state().ok()?.block_editor().filter(|n| !n.eq_ignore_ascii_case(crate::blockedit::CURRENT_DRAWING)).map(str::to_string)
}
/// The block that a call without a name is about: the edited block, else the selected reference's.
fn default_block(s: &Session) -> Option<String> {
edited_block(s).or_else(|| selected_reference(s))
}
/// Block `name` as the drawing spells it, when its description can be changed: while the Block
/// Editor edits a block, only that block's. The block the editor has started and not saved yet has
/// no definition, and is named as the editor names it.
fn check_block(s: &Session, name: &str) -> Result<String> {
let name = name.trim();
if let Some(edited) = edited_block(s)
&& !edited.eq_ignore_ascii_case(name)
{
return Err(bad(
"blocks.description",
format!("The Block Editor is editing \"{edited}\"; close it (BCLOSE) to change another block's description."),
));
}
let d = s.doc()?;
match d.block(name) {
Some(b) if b.xref_path.is_some() => {
Err(bad("blocks.description", format!("\"{}\" is an external reference; its description comes from its drawing.", b.name)))
}
Some(b) if b.anonymous || b.name.starts_with('*') => {
Err(bad("blocks.description", format!("\"{}\" is an anonymous block made by another object; it has no description to change.", b.name)))
}
Some(b) => Ok(b.name.clone()),
None => match edited_block(s) {
Some(e) if e.eq_ignore_ascii_case(name) => Ok(e),
_ => Err(bad("blocks.description", format!("Block \"{name}\" not found."))),
},
}
}
/// A description a block can take (command `cmd`'s): one line of up to [`MAX_LEN`] characters.
pub(super) fn check_text(cmd: &str, text: &str) -> Result<()> {
if text.contains(['\n', '\r']) {
return Err(bad(cmd, "the description must be one line"));
}
if text.chars().count() > MAX_LEN {
return Err(bad(cmd, format!("the description can be up to {MAX_LEN} characters")));
}
Ok(())
}
/// Block `name`'s definition: the drawing's, else, for the block the Block Editor edits, the one
/// outside the editor (a block saved under a new name with BSAVEAS is not in the editor's tables).
fn definition(s: &Session, name: &str) -> Option<Arc<Block>> {
let st = s.state().ok()?;
st.doc.block(name).or_else(|| st.block_edit.as_ref().and_then(|be| be.host.doc.block(name))).cloned()
}
/// The description of block `name` (as [`check_block`] gave it), or "" for one not yet saved.
fn current(s: &Session, name: &str) -> String {
definition(s, name).map(|b| b.description.clone()).unwrap_or_default()
}
/// Give block `name` (as [`check_block`] gave it) the description `text`.
fn set(s: &mut Session, name: &str, text: &str) -> Result<Value> {
check_text("blocks.description", text)?;
let outside = definition(s, name);
let doc = s.doc_mut()?;
match doc.blocks.iter_mut().find(|(k, _)| k.eq_ignore_ascii_case(name)) {
Some((_, b)) => {
if b.description != text {
Arc::make_mut(b).description = text.to_string();
}
}
// The block the Block Editor edits and the editor's tables don't hold (one it started, or
// one BSAVEAS made): BSAVE saves it with this description.
None => {
let mut b = outside.map(|b| (*b).clone()).unwrap_or_else(|| Block::new(name));
b.description = text.to_string();
doc.blocks.insert(name.to_string(), Arc::new(b));
}
}
if s.state()?.block_editor().is_some() {
s.echo(format!("Description of block \"{name}\" changed; BSAVE saves it into the drawing."));
} else {
s.echo(format!("Description of block \"{name}\" changed."));
}
Ok(json!({ "block": name, "description": text }))
}
fn run(s: &mut Session, p: &Value) -> Result<Value> {
let name = match str_param(p, "name") {
Some(n) => n.to_string(),
None => default_block(s)
.ok_or_else(|| bad("blocks.description", "`name` is required (or open the block in the Block Editor, or select one block reference)"))?,
};
let text = str_param(p, "description").ok_or_else(|| bad("blocks.description", "`description` is required"))?;
let name = check_block(s, &name)?;
set(s, &name, text)
}
// ---------------- interactive ----------------
/// Block Description: the block (the edited one, the selected reference's, or a name typed or a
/// reference picked), then its new description; Enter keeps the description it has.
#[derive(Default)]
struct DescriptionM {
/// The block whose description is asked for, once known.
block: Option<String>,
}
impl Interactive for DescriptionM {
fn name(&self) -> &'static str {
"BLOCKS.DESCRIPTION"
}
fn begin(&mut self, s: &mut Session) -> Result<Step> {
if let Some(name) = default_block(s) {
self.block = Some(check_block(s, &name)?);
}
Ok(Step::Continue)
}
fn prompt(&self, s: &Session) -> Prompt {
match &self.block {
Some(name) => {
let cur = current(s, name);
let p = Prompt::new(format!("Enter description for block \"{name}\""), Accept::TEXT);
if cur.is_empty() { p } else { p.default(cur) }
}
None => Prompt::new(
"Enter block name or select a block reference",
Accept { point: true, number: false, text: true, select: true, enter: true },
),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
if let Some(name) = self.block.clone() {
return match i {
Input::Text(t) => set(s, &name, t.trim()).map(|_| Step::Done),
Input::Enter => Ok(Step::Done),
_ => Ok(Step::Continue),
};
}
match i {
Input::Text(t) | Input::Keyword(t) => {
self.block = Some(check_block(s, &t)?);
Ok(Step::Continue)
}
Input::Pick(hs) => {
let picked = {
let d = s.doc()?;
hs.iter().find_map(|h| super::bedit::block_of(d, *h))
};
match picked {
Some(n) => {
self.block = Some(check_block(s, &n)?);
Ok(Step::Continue)
}
None => Err(EngineError::Other("Select a block reference.".into())),
}
}
Input::Enter => Ok(Step::Done),
_ => Ok(Step::Continue),
}
}
}
+2 -2
View File
@@ -14,7 +14,8 @@ use cadcraft_geom::{Line, Vec2};
use serde_json::{Value, json};
use super::machines::{SelOutcome, SelectPhase};
use super::*;
use super::{CommandSpec, bad, bool_or, f64_or, point_value, str_param, targets};
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
macro_rules! gc {
@@ -1199,5 +1200,4 @@ pub fn after_command(s: &mut Session, before: Option<&Arc<Drawing>>, ok: bool) {
}
#[cfg(test)]
#[path = "constraints_tests.rs"]
mod tests;
File diff suppressed because it is too large Load Diff
+181
View File
@@ -0,0 +1,181 @@
use super::{
Accept, Arc, Circle, EntityKind, Input, Interactive, Prompt, Result, Session, Step, Value, Vec2, added, arc, bad, circle, f64_req, line, number,
point_param, size_param,
};
pub(super) fn run_circle(s: &mut Session, p: &Value) -> Result<Value> {
let c = if let Some(center) = point_param(p, "center") {
// Checked before `Circle::new`, which would turn a negative radius positive.
let r = match size_param("circle", p, "radius", false)? {
Some(r) => r,
None => size_param("circle", p, "diameter", false)?.ok_or_else(|| bad("circle", "`radius` or `diameter` is required"))? / 2.0,
};
Circle::new(center, r)
} else if let (Some(a), Some(b)) = (point_param(p, "p1"), point_param(p, "p2")) {
match point_param(p, "p3") {
Some(c3) => Circle::from_3_points(a, b, c3).ok_or_else(|| bad("circle", "points are collinear"))?,
None => Circle::from_2_points(a, b),
}
} else {
return Err(bad("circle", "give {center, radius} or {p1, p2[, p3]}"));
};
if !(c.radius > 1e-12 && c.radius.is_finite()) {
return Err(bad("circle", "radius must be positive"));
}
added(s.add_entity(circle(c.center, c.radius))?)
}
pub(super) fn run_arc(s: &mut Session, p: &Value) -> Result<Value> {
let a = if let (Some(a), Some(b), Some(c)) = (point_param(p, "p1"), point_param(p, "p2"), point_param(p, "p3")) {
Arc::from_3_points(a, b, c).ok_or_else(|| bad("arc", "points are collinear"))?
} else if let (Some(st), Some(c), Some(e)) = (point_param(p, "start"), point_param(p, "center"), point_param(p, "end")) {
Arc::from_start_center_end(st, c, e)
} else if let Some(c) = point_param(p, "center") {
let r = f64_req("arc", p, "radius")?;
// Checked before `Arc::new`, which would turn a negative radius positive (zero is
// refused below).
if r < 0.0 {
return Err(bad("arc", "`radius` must be positive"));
}
Arc::new(c, r, f64_req("arc", p, "start")?.to_radians(), f64_req("arc", p, "end")?.to_radians())
} else {
return Err(bad("arc", "give {p1,p2,p3}, {start,center,end} or {center,radius,start,end}"));
};
// Zero or non-finite radius (e.g. start == center): refuse, like the interactive ARC and the
// arc.* variants, before anything is added (#85).
let a = crate::cmd::curves::finite_arc(a).ok_or_else(|| bad("arc", "these values do not define an arc"))?;
added(s.add_entity(arc(&a))?)
}
#[derive(Default)]
pub(super) struct CircleM {
mode: u8, // 0 center-radius, 1 = 3P, 2 = 2P, 3 = TTR
pts: Vec<Vec2>,
diameter: bool,
/// The `Ttr` option hands over to the tangent-circle machine.
delegate: Option<Box<dyn Interactive>>,
}
impl Interactive for CircleM {
fn name(&self) -> &'static str {
"CIRCLE"
}
fn prompt(&self, s: &Session) -> Prompt {
if let Some(d) = &self.delegate {
return d.prompt(s);
}
let n = self.pts.len();
let last_r = s.doc().map(|d| d.header.f64("CIRCLERAD", 0.0)).unwrap_or(0.0);
match (self.mode, n) {
(0, 0) => Prompt::new("Specify center point for circle", Accept::POINT).kw(&["3P", "2P", "Ttr (tan tan radius)"]),
(0, _) if self.diameter => {
Prompt::new("Specify diameter of circle", Accept::POINT_OR_NUMBER).base_opt(self.pts.first().copied()).distance()
}
(0, _) => {
let p =
Prompt::new("Specify radius of circle", Accept::POINT_OR_NUMBER).kw(&["Diameter"]).base_opt(self.pts.first().copied()).distance();
if last_r > 0.0 { p.default(format!("{last_r:.4}")) } else { p }
}
(1, k) => Prompt::new(
["Specify first point on circle", "Specify second point on circle", "Specify third point on circle"].get(k).copied().unwrap_or(""),
Accept::POINT,
)
.base_opt(self.pts.last().copied()),
(2, k) => Prompt::new(
if k == 0 { "Specify first end point of circle's diameter" } else { "Specify second end point of circle's diameter" },
Accept::POINT,
)
.base_opt(self.pts.last().copied()),
_ => Prompt::new("Specify center point for circle", Accept::POINT),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
if let Some(d) = &mut self.delegate {
return d.input(s, i);
}
match i {
Input::Keyword(k) => {
self.mode = match k.as_str() {
"3P" => 1,
"2P" => 2,
"Diameter" => {
self.diameter = true;
self.mode
}
_ => {
self.delegate = Some(Box::new(crate::cmd::draw2::TanCircleM::new(true)));
3
}
};
Ok(Step::Continue)
}
Input::Point(p) => {
if self.mode == 0 && self.pts.first().is_some_and(|c| c.near(p, 1e-12)) {
return Err(crate::EngineError::Other("Value must be positive and nonzero.".into()));
}
self.pts.push(p);
let done = match self.mode {
0 if self.pts.len() == 2 => {
let (Some(c), Some(e)) = (self.pts.first(), self.pts.get(1)) else { return Ok(Step::Continue) };
let r = if self.diameter { c.dist(*e) / 2.0 } else { c.dist(*e) };
Some(Circle::new(*c, r))
}
1 if self.pts.len() == 3 => Circle::from_3_points(self.pts[0], self.pts[1], self.pts[2]),
2 if self.pts.len() == 2 => Some(Circle::from_2_points(self.pts[0], self.pts[1])),
_ => None,
};
if let Some(c) = done {
finish_circle(s, c)?;
return Ok(Step::Done);
}
Ok(Step::Continue)
}
Input::Text(t) if self.mode == 0 && self.pts.len() == 1 => {
let v = number(&t).ok_or_else(|| crate::EngineError::Other("Requires numeric distance or second point.".into()))?;
let r = if self.diameter { v / 2.0 } else { v };
if r <= 0.0 {
return Err(crate::EngineError::Other("Value must be positive and nonzero.".into()));
}
finish_circle(s, Circle::new(self.pts[0], r))?;
Ok(Step::Done)
}
Input::Enter if self.mode == 0 && self.pts.len() == 1 => {
let r = s.doc()?.header.f64("CIRCLERAD", 0.0);
if r > 0.0 {
finish_circle(s, Circle::new(self.pts[0], r))?;
Ok(Step::Done)
} else {
Ok(Step::Continue)
}
}
Input::Enter => Ok(Step::Cancel),
// Garbage at a point prompt: report it and ask again.
Input::Text(_) if self.mode == 0 && self.pts.is_empty() => Err(crate::EngineError::Other("Point or option keyword required.".into())),
Input::Text(_) => Err(crate::EngineError::Other("Invalid point.".into())),
_ => Ok(Step::Continue),
}
}
fn preview(&self, s: &Session, c: Vec2) -> Vec<EntityKind> {
if let Some(d) = &self.delegate {
return d.preview(s, c);
}
match (self.mode, self.pts.as_slice()) {
(0, [center]) => {
let r = if self.diameter { center.dist(c) / 2.0 } else { center.dist(c) };
vec![circle(*center, r), line(*center, c)]
}
(1, [a, b]) => Circle::from_3_points(*a, *b, c).map(|ci| vec![circle(ci.center, ci.radius)]).unwrap_or_default(),
(1, [a]) | (2, [a]) => {
let ci = Circle::from_2_points(*a, c);
vec![circle(ci.center, ci.radius)]
}
_ => Vec::new(),
}
}
}
fn finish_circle(s: &mut Session, c: Circle) -> Result<()> {
s.add_entity(circle(c.center, c.radius))?;
s.doc_mut()?.header.set_f64("CIRCLERAD", c.radius);
Ok(())
}
+217
View File
@@ -0,0 +1,217 @@
use super::{
Accept, Ellipse, EntityKind, Input, Interactive, Prompt, Result, Session, Step, Value, Vec2, added, bad, f64_or, f64_req, line, number,
point_req, v3,
};
pub(super) fn run_ellipse(s: &mut Session, p: &Value) -> Result<Value> {
if p.get("isocircle").is_some() {
return crate::cmd::settings::iso::run_isocircle(s, p);
}
let c = point_req("ellipse", p, "center")?;
let m = point_req("ellipse", p, "major")?;
let ratio = f64_req("ellipse", p, "ratio")?;
if !(ratio > 0.0 && ratio <= 1.0) || m.len() < 1e-12 {
return Err(bad("ellipse", "ratio must be in (0, 1] and major non-zero"));
}
let start = f64_or(p, "start", 0.0).to_radians();
let end = f64_or(p, "end", 360.0).to_radians();
added(s.add_entity(EntityKind::Ellipse(cadcraft_doc::Ellipse { center: v3(c), major: v3(m), ratio, start, end }))?)
}
/// ELLIPSE prompt machine starting in axis-end or center mode, optionally for an elliptical arc.
pub(crate) fn ellipse_machine(center: bool, arc: bool) -> Box<dyn Interactive> {
Box::new(EllipseM { center_mode: center, arc_mode: arc, ..Default::default() })
}
#[derive(Default)]
pub(super) struct EllipseM {
center_mode: bool,
arc_mode: bool,
pts: Vec<Vec2>,
/// The `Rotation` option: the next value is the rotation around the major axis.
rotation: bool,
ellipse: Option<Ellipse>,
/// The `Parameter` option: arc start/end values are ellipse parameters, not true angles.
param: bool,
start: Option<f64>,
/// The Isocircle option (isometric snap style) in progress.
iso: Option<crate::cmd::settings::iso::IsoCircle>,
}
impl EllipseM {
fn axis(&self) -> Option<(Vec2, Vec2)> {
// (center, major vector)
match (self.center_mode, self.pts.as_slice()) {
(true, [c, e, ..]) => Some((*c, *e - *c)),
(false, [a, b, ..]) => Some((a.mid(*b), *b - a.mid(*b))),
_ => None,
}
}
fn build(&self, other: Vec2) -> Option<Ellipse> {
let (c, m) = self.axis()?;
let ml = m.len();
if ml < 1e-12 {
return None;
}
let u = m / ml;
let minor = ((other - c).dot(u.perp())).abs();
let (major, ratio) = if minor > ml { (u.perp() * minor, ml / minor) } else { (m, minor / ml) };
(ratio > 1e-9).then_some(Ellipse::full(c, major, ratio.min(1.0)))
}
/// The ellipse seen as a circle on the first axis rotated by `a` around it: ratio = cos(a),
/// for rotations up to 89.4°.
fn rotated(&self, a: f64) -> Option<Ellipse> {
let (c, m) = self.axis()?;
let ratio = a.cos().abs();
(m.len() >= 1e-12 && ratio >= 89.4f64.to_radians().cos()).then_some(Ellipse::full(c, m, ratio.min(1.0)))
}
/// The full ellipse is known: draw it, or go on to the arc angles.
fn place(&mut self, s: &mut Session, e: Ellipse) -> Result<Step> {
if self.arc_mode {
self.ellipse = Some(e);
return Ok(Step::Continue);
}
s.add_entity(EntityKind::Ellipse(cadcraft_doc::Ellipse {
center: v3(e.center),
major: v3(e.major),
ratio: e.ratio,
start: 0.0,
end: cadcraft_geom::TAU,
}))?;
Ok(Step::Done)
}
}
impl Interactive for EllipseM {
fn name(&self) -> &'static str {
"ELLIPSE"
}
fn prompt(&self, s: &Session) -> Prompt {
if let Some(e) = &self.ellipse {
let msg = match (self.start.is_none(), self.param) {
(true, false) => "Specify start angle",
(false, false) => "Specify end angle",
(true, true) => "Specify start parameter",
(false, true) => "Specify end parameter",
};
return Prompt::new(msg, Accept::POINT_OR_NUMBER).kw(if self.param { &["Angle"] } else { &["Parameter"] }).base(e.center);
}
if let Some(iso) = &self.iso {
return iso.prompt();
}
match (self.center_mode, self.pts.len()) {
(false, 0) if s.settings.isodraft => Prompt::new("Specify axis endpoint of ellipse", Accept::POINT).kw(&["Arc", "Center", "Isocircle"]),
(false, 0) => Prompt::new("Specify axis endpoint of ellipse", Accept::POINT).kw(&["Arc", "Center"]),
(false, 1) => Prompt::new("Specify other endpoint of axis", Accept::POINT).base_opt(self.pts.first().copied()),
(true, 0) => Prompt::new("Specify center of ellipse", Accept::POINT),
(true, 1) => Prompt::new("Specify endpoint of axis", Accept::POINT).base_opt(self.pts.first().copied()),
_ if self.rotation => Prompt::new("Specify rotation around major axis", Accept::POINT_OR_NUMBER).base_opt(self.axis().map(|a| a.0)),
_ => Prompt::new("Specify distance to other axis", Accept::POINT_OR_NUMBER).kw(&["Rotation"]).base_opt(self.axis().map(|a| a.0)),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
if let Some(e) = self.ellipse {
let ge = Ellipse { start: 0.0, end: cadcraft_geom::TAU, ..e };
let ang = match i {
Input::Keyword(k) => {
match k.as_str() {
"Parameter" => self.param = true,
"Angle" => self.param = false,
_ => {}
}
return Ok(Step::Continue);
}
// A parameter is the angle from the major axis taken as the parameter itself.
Input::Point(p) if self.param => cadcraft_geom::norm_angle((p - e.center).angle() - e.major.angle()),
Input::Point(p) => ge.param_of(p),
Input::Text(t) => {
let a = s.angle_settings().amount(&t).ok_or_else(|| crate::EngineError::Other("Requires an angle.".into()))?;
if self.param {
cadcraft_geom::norm_angle(a)
} else {
// A typed angle is a true angle from the major axis; convert it to an ellipse parameter like a picked point.
ge.param_of(e.center + Vec2::from_angle(a + e.major.angle()))
}
}
_ => return Ok(Step::Continue),
};
match self.start {
None => {
self.start = Some(ang);
return Ok(Step::Continue);
}
Some(st) => {
s.add_entity(EntityKind::Ellipse(cadcraft_doc::Ellipse {
center: v3(e.center),
major: v3(e.major),
ratio: e.ratio,
start: st,
end: ang,
}))?;
return Ok(Step::Done);
}
}
}
if let Some(iso) = &mut self.iso {
return match iso.input(s, i)? {
Some(e) => self.place(s, e),
None => Ok(Step::Continue),
};
}
match i {
Input::Keyword(k) if k == "Isocircle" && self.pts.is_empty() => self.iso = Some(Default::default()),
Input::Keyword(k) if k == "Center" => self.center_mode = true,
Input::Keyword(k) if k == "Arc" => self.arc_mode = true,
Input::Keyword(k) if k == "Rotation" && self.pts.len() == 2 => self.rotation = true,
Input::Point(p) if self.pts.len() < 2 => self.pts.push(p),
Input::Point(p) if self.rotation => {
if let Some(e) = self.axis().and_then(|(c, _)| self.rotated((p - c).angle())) {
return self.place(s, e);
}
}
Input::Point(p) => {
if let Some(e) = self.build(p) {
return self.place(s, e);
}
}
Input::Text(t) if self.pts.len() == 2 && self.rotation => {
let e = s
.angle_settings()
.amount(&t)
.and_then(|a| self.rotated(a))
.ok_or_else(|| crate::EngineError::Other("Requires an angle between 0 and 89.4 degrees.".into()))?;
return self.place(s, e);
}
Input::Text(t) if self.pts.len() == 2 => {
let d = number(&t).ok_or_else(|| crate::EngineError::Other("Requires a distance.".into()))?;
if let Some((c, m)) = self.axis()
&& let Some(e) = self.build(c + m.normalized().perp() * d)
{
return self.place(s, e);
}
}
Input::Enter => return Ok(Step::Cancel),
_ => {}
}
Ok(Step::Continue)
}
fn preview(&self, s: &Session, c: Vec2) -> Vec<EntityKind> {
if let Some(iso) = &self.iso {
return iso.preview(s, c).map(|e| crate::cmd::settings::iso::ellipse_entity(&e)).into_iter().collect();
}
if self.pts.len() < 2 {
return self.pts.first().map(|a| vec![line(*a, c)]).unwrap_or_default();
}
let e = if self.rotation { self.axis().and_then(|(ctr, _)| self.rotated((c - ctr).angle())) } else { self.build(c) };
e.map(|e| {
vec![EntityKind::Ellipse(cadcraft_doc::Ellipse {
center: v3(e.center),
major: v3(e.major),
ratio: e.ratio,
start: 0.0,
end: cadcraft_geom::TAU,
})]
})
.unwrap_or_default()
}
}
+406
View File
@@ -0,0 +1,406 @@
use super::{
Accept, EntityKind, Input, Interactive, Outcome, PlineAsk, PolyVertex, Prompt, Result, Session, Step, Value, Vec2, added, arc_to_bulge, bad,
bool_or, bulge_to_arc, distinct_points, f64_or, json, line, lwpoly, number, point_value, points_param, size_param, snap,
};
// ---------------- JSON forms ----------------
pub(super) fn run_line(s: &mut Session, p: &Value) -> Result<Value> {
let pts = points_param(p, "points").ok_or_else(|| bad("line", "`points` [[x,y],...] (2 or more) is required"))?;
if pts.len() < 2 {
return Err(bad("line", "need at least 2 points"));
}
// A repeated point would make a zero-length line.
let pts = distinct_points(&pts);
if pts.len() < 2 {
return Err(bad("line", "need at least 2 different points"));
}
let mut hs = Vec::new();
for w in pts.windows(2) {
if let [a, b] = w {
hs.push(s.add_entity(line(*a, *b))?.hex());
}
}
if bool_or(p, "closed", false)
&& pts.len() > 2
&& let (Some(a), Some(b)) = (pts.last(), pts.first())
&& !a.near(*b, 1e-12)
{
hs.push(s.add_entity(line(*a, *b))?.hex());
}
Ok(json!({ "handles": hs }))
}
fn vertex_value(v: &Value) -> Option<PolyVertex> {
if let Some(p) = point_value(v) {
return Some(PolyVertex::new(p));
}
let p = point_value(v.get("p")?)?;
Some(PolyVertex { p, bulge: f64_or(v, "bulge", 0.0), start_width: f64_or(v, "startWidth", 0.0), end_width: f64_or(v, "endWidth", 0.0) })
}
pub(super) fn run_pline(s: &mut Session, p: &Value) -> Result<Value> {
let raw = p.get("vertices").or_else(|| p.get("points")).and_then(Value::as_array);
for v in raw.into_iter().flatten() {
size_param("pline", v, "startWidth", true)?;
size_param("pline", v, "endWidth", true)?;
}
let vs: Vec<PolyVertex> = raw.map(|a| a.iter().filter_map(vertex_value).collect()).unwrap_or_default();
if vs.len() < 2 {
return Err(bad("pline", "`vertices` needs at least 2 points"));
}
let width = size_param("pline", p, "width", true)?.unwrap_or(0.0);
let mut k = lwpoly(vs, bool_or(p, "closed", false));
if let EntityKind::LwPolyline(pl) = &mut k {
pl.const_width = width;
}
added(s.add_entity(k)?)
}
// ---------------- interactive ----------------
#[derive(Default)]
pub(super) struct LineM {
pts: Vec<Vec2>,
handles: Vec<cadcraft_doc::Handle>,
/// A first point picked as a deferred tangent/perpendicular, resolved by the next point.
first: Option<snap::Deferred>,
}
impl LineM {
/// Draw the first segment from the deferred first point to `end`.
fn resolve_first(&mut self, s: &mut Session, first: snap::Deferred, end: snap::LineEnd) -> Result<Step> {
let Some((a, b)) = snap::resolve_deferred(snap::LineEnd::Deferred(first), end) else {
let what = if first.mode == snap::mode::TAN { "tangent" } else { "perpendicular" };
return Err(crate::EngineError::Other(format!("No {what} line through that point; pick another point.")));
};
self.handles.push(s.add_entity(line(a, b))?);
self.pts = vec![a, b];
self.first = None;
s.last_point = b;
Ok(Step::Continue)
}
}
impl Interactive for LineM {
fn name(&self) -> &'static str {
"LINE"
}
fn prompt(&self, _s: &Session) -> Prompt {
match self.pts.len() {
0 if self.first.is_some() => Prompt::new("Specify next point", Accept::POINT).kw(&["Undo"]).deferred(),
0 => Prompt::new("Specify first point", Accept::POINT).deferred(),
1 | 2 => Prompt::new("Specify next point", Accept::POINT).kw(&["Undo"]).base_opt(self.pts.last().copied()),
_ => Prompt::new("Specify next point", Accept::POINT).kw(&["Close", "Undo"]).base_opt(self.pts.last().copied()),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
if self.pts.is_empty()
&& let Some(first) = self.first
{
match i {
Input::Point(p) => {
// A deferred TAN first point with a typed `@d<a` next point: resolve
// exactly from the direction and distance the user gave.
if let Some((dist, ang)) = s.last_polar.take()
&& let Some(t) = snap::tangent_point_in_direction(&first.curve, ang, first.at)
{
let e = t + Vec2::new(ang.cos(), ang.sin()) * dist;
self.handles.push(s.add_entity(line(t, e))?);
self.pts = vec![t, e];
s.last_point = e;
self.first = None;
return Ok(Step::Continue);
}
return self.resolve_first(s, first, snap::LineEnd::Point(p));
}
Input::Deferred(d) => return self.resolve_first(s, first, snap::LineEnd::Deferred(d)),
_ => {}
}
}
match i {
Input::Deferred(d) if self.pts.is_empty() => {
self.first = Some(d);
Ok(Step::Continue)
}
// A deferred pick with a known last point (not offered by the prompt, but harmless).
Input::Deferred(d) => match self.pts.last().copied() {
Some(last) => match snap::resolve_deferred(snap::LineEnd::Point(last), snap::LineEnd::Deferred(d)) {
Some((_, p)) => self.input(s, Input::Point(p)),
None => Err(crate::EngineError::Other("No tangent or perpendicular from the last point.".into())),
},
None => Ok(Step::Continue),
},
Input::Point(p) => {
if let Some(last) = self.pts.last().copied() {
if last.near(p, 1e-12) {
return Ok(Step::Continue);
}
self.handles.push(s.add_entity(line(last, p))?);
}
self.pts.push(p);
Ok(Step::Continue)
}
Input::Keyword(k) if k == "Undo" => {
if self.first.take().is_some() {
return Ok(Step::Continue);
}
if let Some(h) = self.handles.pop() {
s.doc_mut()?.remove_entity(h);
}
self.pts.pop();
Ok(Step::Continue)
}
Input::Keyword(k) if k == "Close" => {
if let (Some(a), Some(b)) = (self.pts.last().copied(), self.pts.first().copied()) {
s.add_entity(line(a, b))?;
}
Ok(Step::Done)
}
Input::Enter => Ok(Step::Done),
_ => Err(crate::EngineError::Other("Point or option keyword required.".into())),
}
}
fn preview(&self, s: &Session, c: Vec2) -> Vec<EntityKind> {
if let Some(first) = self.first {
// Over a second curve, show the tangent-to-tangent line the click would draw.
let end = s.cursor_deferred.map_or(snap::LineEnd::Point(c), snap::LineEnd::Deferred);
return snap::resolve_deferred(snap::LineEnd::Deferred(first), end).map(|(a, b)| vec![line(a, b)]).unwrap_or_default();
}
self.pts.last().map(|l| vec![line(*l, c)]).unwrap_or_default()
}
}
#[derive(Default)]
pub(super) struct PlineM {
verts: Vec<PolyVertex>,
arc_mode: bool,
/// Total width at the start and end of the next segment (Width / Halfwidth answers).
start_w: f64,
end_w: f64,
handle: Option<cadcraft_doc::Handle>,
/// 1 = asking the starting width, 2 = the ending width.
asking_width: u8,
/// The widths are being asked as half-widths (centre line to edge).
half: bool,
/// An option asking further questions (arc mode's Angle, CEnter…, line mode's Length).
ask: Option<PlineAsk>,
}
impl PlineM {
fn entity(&self, closed: bool) -> EntityKind {
let mut k = lwpoly(self.verts.clone(), closed);
if let EntityKind::LwPolyline(pl) = &mut k {
// Every segment the same uniform width: store it as the constant width, as before.
let segs = if closed { pl.vertices.len() } else { pl.vertices.len().saturating_sub(1) };
let w = pl.vertices.first().map_or(0.0, |v| v.start_width);
if pl.vertices.iter().take(segs).all(|v| v.start_width == w && v.end_width == w) {
pl.const_width = w;
for v in &mut pl.vertices {
(v.start_width, v.end_width) = (0.0, 0.0);
}
}
}
k
}
/// The next segment, starting at the last vertex, takes the requested widths (#104); the
/// segments after it are uniform at the ending width, as in AutoCAD.
fn begin_segment(&mut self) {
if let Some(last) = self.verts.last_mut() {
(last.start_width, last.end_width) = (self.start_w, self.end_w);
self.start_w = self.end_w;
}
}
fn sync(&mut self, s: &mut Session, closed: bool) -> Result<()> {
if self.verts.len() < 2 {
if let Some(h) = self.handle.take() {
s.doc_mut()?.remove_entity(h);
}
return Ok(());
}
let k = self.entity(closed);
match self.handle {
Some(h) => {
s.doc_mut()?.modify_entity(h, |e| e.kind = k)?;
}
None => self.handle = Some(s.add_entity(k)?),
}
Ok(())
}
/// The direction the polyline runs in at its last vertex (+X before the second vertex).
fn tangent(&self) -> Vec2 {
let n = self.verts.len();
let Some(last) = self.verts.last() else { return Vec2::X };
if n >= 2 {
let prev = self.verts.get(n - 2).copied().unwrap_or_default();
match bulge_to_arc(prev.p, last.p, prev.bulge) {
Some((a, ccw)) => {
let r = (last.p - a.center).perp().normalized();
if ccw { r } else { -r }
}
None => (last.p - prev.p).normalized(),
}
} else {
Vec2::X
}
}
/// Bulge for a tangent-continuing arc from the last vertex to `p`.
fn tangent_bulge(&self, p: Vec2) -> f64 {
let Some(last) = self.verts.last() else { return 0.0 };
let dir = self.tangent();
let chord = p - last.p;
// Included angle = 2 × angle between tangent and chord.
let ang = dir.cross(chord).atan2(dir.dot(chord));
arc_to_bulge(2.0 * ang)
}
}
impl Interactive for PlineM {
fn name(&self) -> &'static str {
"PLINE"
}
fn prompt(&self, s: &Session) -> Prompt {
if self.asking_width > 0 {
let unit = if self.half { "half-width" } else { "width" };
let (which, w) = if self.asking_width == 1 { ("starting", self.start_w) } else { ("ending", self.end_w) };
let shown = if self.half { w / 2.0 } else { w };
return Prompt::new(format!("Specify {which} {unit}"), Accept::NUMBER).default(format!("{shown:.4}"));
}
let base = self.verts.last().map(|v| v.p);
if let (Some(a), Some(st)) = (self.ask, base) {
return a.prompt(st, self.tangent(), s);
}
match (self.verts.len(), self.arc_mode) {
(0, _) => Prompt::new("Specify start point", Accept::POINT),
(n, false) => {
let mut kw = vec!["Arc", "Halfwidth", "Length", "Undo", "Width"];
if n >= 2 {
kw.insert(1, "Close");
}
Prompt::new("Specify next point", Accept::POINT).kw(&kw).base_opt(base)
}
(_, true) => Prompt::new("Specify endpoint of arc", Accept::POINT)
.kw(&["Angle", "CEnter", "CLose", "Direction", "Halfwidth", "Line", "Radius", "Second pt", "Undo", "Width"])
.base_opt(base),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
if self.asking_width > 0 {
match i {
Input::Text(t) => {
let w = number(&t).filter(|w| w.is_finite()).ok_or_else(|| crate::EngineError::Other("Requires a distance.".into()))?;
// Halfwidth is centre line to edge: the total width is twice the answer (#83).
let w = w.max(0.0) * if self.half { 2.0 } else { 1.0 };
if self.asking_width == 1 {
self.start_w = w;
} else {
self.end_w = w;
}
}
Input::Enter => {}
_ => return Ok(Step::Continue),
}
if self.asking_width == 1 {
// The ending width defaults to the starting width.
self.end_w = self.start_w;
}
self.asking_width = if self.asking_width == 1 { 2 } else { 0 };
return Ok(Step::Continue);
}
if let (Some(a), Some(st)) = (self.ask, self.verts.last().map(|v| v.p)) {
match a.input(s, i, st, self.tangent())? {
Outcome::Ask(next) => self.ask = Some(next),
Outcome::Back => self.ask = None,
Outcome::Segment(end, bulge) => {
self.ask = None;
self.begin_segment();
if let Some(last) = self.verts.last_mut() {
last.bulge = bulge;
}
self.verts.push(PolyVertex::new(end));
self.sync(s, false)?;
}
}
return Ok(Step::Continue);
}
match i {
Input::Point(p) => {
if self.verts.is_empty() {
s.echo(format!("Current line-width is {:.4}", self.start_w));
}
self.begin_segment();
if self.arc_mode && !self.verts.is_empty() {
let b = self.tangent_bulge(p);
if let Some(last) = self.verts.last_mut() {
last.bulge = b;
}
}
self.verts.push(PolyVertex::new(p));
self.sync(s, false)?;
Ok(Step::Continue)
}
Input::Keyword(k) => match k.as_str() {
"Arc" => {
self.arc_mode = true;
Ok(Step::Continue)
}
"Line" => {
self.arc_mode = false;
Ok(Step::Continue)
}
"Close" | "CLose" => {
if self.arc_mode
&& let Some(first) = self.verts.first().map(|v| v.p)
{
let b = self.tangent_bulge(first);
if let Some(last) = self.verts.last_mut() {
last.bulge = b;
}
}
// The closing segment starts at the last vertex.
self.begin_segment();
self.sync(s, true)?;
Ok(Step::Done)
}
"Undo" => {
self.verts.pop();
if let Some(l) = self.verts.last_mut() {
l.bulge = 0.0;
(l.start_width, l.end_width) = (0.0, 0.0);
}
self.sync(s, false)?;
Ok(Step::Continue)
}
"Width" | "Halfwidth" => {
self.half = k == "Halfwidth";
self.asking_width = 1;
Ok(Step::Continue)
}
k => {
match PlineAsk::from_keyword(k, self.arc_mode) {
Some(a) if !self.verts.is_empty() => self.ask = Some(a),
_ => return Err(crate::EngineError::Other("Point or option keyword required.".into())),
}
Ok(Step::Continue)
}
},
Input::Enter => Ok(Step::Done),
_ => Err(crate::EngineError::Other("Point or option keyword required.".into())),
}
}
fn preview(&self, _s: &Session, c: Vec2) -> Vec<EntityKind> {
let Some(last) = self.verts.last() else { return Vec::new() };
if let Some(a) = self.ask {
return a
.preview(last.p, self.tangent(), c)
.map(|(e, b)| vec![lwpoly(vec![PolyVertex::with_bulge(last.p, b), PolyVertex::new(e)], false)])
.unwrap_or_default();
}
if self.arc_mode {
let b = self.tangent_bulge(c);
vec![lwpoly(vec![PolyVertex::with_bulge(last.p, b), PolyVertex::new(c)], false)]
} else {
vec![line(last.p, c)]
}
}
}
+168
View File
@@ -0,0 +1,168 @@
use super::{
Accept, EntityKind, Input, Interactive, Point, PolyVertex, Prompt, RayLine, Result, Session, Step, Value, Vec2, added, bad, json, lwpoly, number,
point_param, point_req, points_param, size_param, v3,
};
pub(super) fn run_point(s: &mut Session, p: &Value) -> Result<Value> {
let pts = match point_param(p, "at") {
Some(a) => vec![a],
None => points_param(p, "points").ok_or_else(|| bad("point", "`at` or `points` is required"))?,
};
let mut hs = Vec::new();
for q in pts {
hs.push(s.add_entity(EntityKind::Point(Point { p: v3(q), angle: 0.0 }))?.hex());
}
Ok(json!({ "handles": hs }))
}
pub(super) fn run_ray(s: &mut Session, p: &Value) -> Result<Value> {
let b = point_req("ray", p, "base")?;
let t = point_req("ray", p, "through")?;
let dir = (t - b).normalized();
if dir == Vec2::ZERO {
return Err(bad("ray", "through point equals base"));
}
added(s.add_entity(EntityKind::Ray(RayLine { base: v3(b), dir: v3(dir) }))?)
}
fn donut(center: Vec2, inside: f64, outside: f64) -> EntityKind {
let r = (inside.abs() + outside.abs()) / 4.0;
let w = (outside.abs() - inside.abs()).abs() / 2.0;
let mut k = lwpoly(vec![PolyVertex::with_bulge(center - Vec2::X * r, 1.0), PolyVertex::with_bulge(center + Vec2::X * r, 1.0)], true);
if let EntityKind::LwPolyline(pl) = &mut k {
pl.const_width = w;
}
k
}
pub(super) fn run_donut(s: &mut Session, p: &Value) -> Result<Value> {
let c = point_req("donut", p, "center")?;
let inside = size_param("donut", p, "inside", true)?.unwrap_or(0.5);
let outside = size_param("donut", p, "outside", false)?.unwrap_or(1.0);
if inside > outside {
return Err(bad("donut", "`inside` must not be larger than `outside`"));
}
added(s.add_entity(donut(c, inside, outside))?)
}
pub(super) struct PointM {
pub(super) multiple: bool,
}
impl Interactive for PointM {
fn name(&self) -> &'static str {
"POINT"
}
fn prompt(&self, s: &Session) -> Prompt {
let d = s.doc().ok();
let _ = d;
Prompt::new("Specify a point", Accept::POINT)
}
fn begin(&mut self, s: &mut Session) -> Result<Step> {
let (m, sz) = s.doc().map(|d| (d.header.i64("PDMODE", 0), d.header.f64("PDSIZE", 0.0))).unwrap_or((0, 0.0));
s.echo(format!("Current point modes: PDMODE={m} PDSIZE={sz:.4}"));
Ok(Step::Continue)
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
match i {
Input::Point(p) => {
s.add_entity(EntityKind::Point(Point { p: v3(p), angle: 0.0 }))?;
Ok(if self.multiple { Step::Continue } else { Step::Done })
}
Input::Enter => Ok(Step::Done),
_ => Ok(Step::Continue),
}
}
}
/// RAY: a start point, then through points until Enter (XLINE is `draw2::XlineM2`).
#[derive(Default)]
pub(super) struct RayM {
base: Option<Vec2>,
}
impl Interactive for RayM {
fn name(&self) -> &'static str {
"RAY"
}
fn prompt(&self, _s: &Session) -> Prompt {
match self.base {
None => Prompt::new("Specify start point", Accept::POINT),
Some(b) => Prompt::new("Specify through point", Accept::POINT).base(b),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
match i {
Input::Point(p) => {
match self.base {
None => self.base = Some(p),
Some(b) => {
let d = (p - b).normalized();
if d != Vec2::ZERO {
s.add_entity(EntityKind::Ray(RayLine { base: v3(b), dir: v3(d) }))?;
}
}
}
Ok(Step::Continue)
}
Input::Enter => Ok(Step::Done),
_ => Ok(Step::Continue),
}
}
fn preview(&self, _s: &Session, c: Vec2) -> Vec<EntityKind> {
match self.base {
Some(b) if !b.near(c, 1e-12) => vec![EntityKind::Ray(RayLine { base: v3(b), dir: v3((c - b).normalized()) })],
_ => Vec::new(),
}
}
}
#[derive(Default)]
pub(super) struct DonutM {
inside: Option<f64>,
outside: Option<f64>,
}
impl Interactive for DonutM {
fn name(&self) -> &'static str {
"DONUT"
}
fn prompt(&self, s: &Session) -> Prompt {
let d = s.doc().ok();
let di = d.map(|d| d.header.f64("DONUTID", 0.5)).unwrap_or(0.5);
let dout = d.map(|d| d.header.f64("DONUTOD", 1.0)).unwrap_or(1.0);
match (self.inside, self.outside) {
(None, _) => Prompt::new("Specify inside diameter of donut", Accept::NUMBER).default(format!("{di:.4}")),
(Some(_), None) => Prompt::new("Specify outside diameter of donut", Accept::NUMBER).default(format!("{dout:.4}")),
_ => Prompt::new("Specify center of donut", Accept::POINT).kw(&[]),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
let hdr = |s: &Session, k: &str, d: f64| s.doc().map(|x| x.header.f64(k, d)).unwrap_or(d);
match (self.inside, self.outside, i) {
(None, _, Input::Text(t)) => {
self.inside = Some(number(&t).ok_or_else(|| crate::EngineError::Other("Requires a distance.".into()))?.abs())
}
(None, _, Input::Enter) => self.inside = Some(hdr(s, "DONUTID", 0.5)),
(Some(_), None, Input::Text(t)) => {
self.outside = Some(number(&t).ok_or_else(|| crate::EngineError::Other("Requires a distance.".into()))?.abs())
}
(Some(_), None, Input::Enter) => self.outside = Some(hdr(s, "DONUTOD", 1.0)),
(Some(a), Some(b), Input::Point(p)) => {
s.add_entity(donut(p, a, b))?;
let d = s.doc_mut()?;
d.header.set_f64("DONUTID", a);
d.header.set_f64("DONUTOD", b);
}
(Some(_), Some(_), Input::Enter) => return Ok(Step::Done),
_ => {}
}
Ok(Step::Continue)
}
fn preview(&self, _s: &Session, c: Vec2) -> Vec<EntityKind> {
match (self.inside, self.outside) {
(Some(a), Some(b)) => vec![donut(c, a, b)],
_ => Vec::new(),
}
}
}
+137
View File
@@ -0,0 +1,137 @@
use super::{
Accept, EntityKind, Input, Interactive, Prompt, Result, Session, Step, Value, Vec2, added, bad, bool_or, lwpoly, number, point_req, points_param,
polygon_from_edge, polygon_typed_radius_point, polygon_vertices, size_param,
};
pub(super) fn run_polygon(s: &mut Session, p: &Value) -> Result<Value> {
// A `sides` that is not a whole number (e.g. -3) is refused, not replaced by the default 4.
let n = match p.get("sides") {
None | Some(Value::Null) => 4,
Some(v) => v.as_u64().and_then(|n| usize::try_from(n).ok()).unwrap_or(0),
};
if !(3..=1024).contains(&n) {
return Err(bad("polygon", "sides must be 3..1024"));
}
let vs = if let Some(e) = points_param(p, "edge") {
let (Some(a), Some(b)) = (e.first(), e.get(1)) else { return Err(bad("polygon", "edge needs 2 points")) };
if a.near(*b, 1e-12) {
return Err(bad("polygon", "edge endpoints must differ"));
}
polygon_from_edge(*a, *b, n)
} else {
let c = point_req("polygon", p, "center")?;
let r = size_param("polygon", p, "radius", false)?.ok_or_else(|| bad("polygon", "`radius` (number) is required"))?;
let inscribed = bool_or(p, "inscribed", true);
// Without an angle, the bottom edge is horizontal, as with a typed radius.
let rp = match p.get("angle").and_then(Value::as_f64).filter(|a| a.is_finite()) {
Some(a) => Vec2::polar(c, r, a.to_radians()),
None => polygon_typed_radius_point(c, n, r, inscribed),
};
polygon_vertices(c, n, rp, inscribed)
};
added(s.add_entity(lwpoly(vs, true))?)
}
#[derive(Default)]
pub(super) struct PolygonM {
sides: Option<usize>,
center: Option<Vec2>,
edge: bool,
edge_pts: Vec<Vec2>,
inscribed: Option<bool>,
}
impl Interactive for PolygonM {
fn name(&self) -> &'static str {
"POLYGON"
}
fn prompt(&self, s: &Session) -> Prompt {
let def = s.doc().map(|d| d.header.i64("POLYSIDES", 4)).unwrap_or(4);
match (self.sides, self.edge, self.center, self.inscribed) {
(None, _, _, _) => Prompt::new("Enter number of sides", Accept::NUMBER).default(def.to_string()),
(Some(_), true, _, _) => Prompt::new(
if self.edge_pts.is_empty() { "Specify first endpoint of edge" } else { "Specify second endpoint of edge" },
Accept::POINT,
)
.base_opt(self.edge_pts.first().copied()),
(Some(_), false, None, _) => Prompt::new("Specify center of polygon", Accept::POINT).kw(&["Edge"]),
(Some(_), false, Some(_), None) => Prompt::new("Enter an option", Accept::TEXT)
.kw(&["Inscribed in circle", "Circumscribed about circle"])
.default(if s.last_used.polygon_circumscribed { "C" } else { "I" }),
(Some(_), false, Some(c), Some(_)) => Prompt::new("Specify radius of circle", Accept::POINT_OR_NUMBER).base(c).distance(),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
if self.sides.is_none() {
let n = match &i {
Input::Text(t) => t
.trim()
.parse::<usize>()
.ok()
.filter(|n| (3..=1024).contains(n))
.ok_or_else(|| crate::EngineError::Other("Requires an integer between 3 and 1024.".into()))?,
Input::Enter => s.doc()?.header.i64("POLYSIDES", 4).clamp(3, 1024) as usize,
_ => return Ok(Step::Continue),
};
self.sides = Some(n);
s.doc_mut()?.header.set_i64("POLYSIDES", n as i64);
return Ok(Step::Continue);
}
let n = self.sides.unwrap_or(4);
if self.edge {
if let Input::Point(p) = i {
if self.edge_pts.first().is_some_and(|a| a.near(p, 1e-12)) {
return Err(crate::EngineError::Other("Edge endpoints must differ.".into()));
}
self.edge_pts.push(p);
if self.edge_pts.len() == 2 {
s.add_entity(lwpoly(polygon_from_edge(self.edge_pts[0], self.edge_pts[1], n), true))?;
return Ok(Step::Done);
}
}
return Ok(Step::Continue);
}
match (self.center, self.inscribed, i) {
(None, _, Input::Keyword(k)) if k == "Edge" => self.edge = true,
(None, _, Input::Point(p)) => self.center = Some(p),
(Some(_), None, inp @ (Input::Keyword(_) | Input::Enter | Input::Text(_))) => {
// Enter takes the option used last time (the prompt's default).
let ins = match inp {
Input::Keyword(k) => k.starts_with('I'),
Input::Text(t) => !t.trim().to_ascii_lowercase().starts_with('c'),
_ => !s.last_used.polygon_circumscribed,
};
self.inscribed = Some(ins);
s.last_used.polygon_circumscribed = !ins;
}
(Some(c), Some(ins), Input::Point(p)) => {
if c.near(p, 1e-12) {
return Err(crate::EngineError::Other("Value must be positive and nonzero.".into()));
}
s.add_entity(lwpoly(polygon_vertices(c, n, p, ins), true))?;
return Ok(Step::Done);
}
(Some(c), Some(ins), Input::Text(t)) => {
let r = number(&t).ok_or_else(|| crate::EngineError::Other("Requires numeric distance or point.".into()))?;
if !r.is_finite() || r <= 0.0 {
return Err(crate::EngineError::Other("Value must be positive and nonzero.".into()));
}
let rp = polygon_typed_radius_point(c, n, r, ins);
s.add_entity(lwpoly(polygon_vertices(c, n, rp, ins), true))?;
return Ok(Step::Done);
}
_ => {}
}
Ok(Step::Continue)
}
fn preview(&self, _s: &Session, c: Vec2) -> Vec<EntityKind> {
let n = self.sides.unwrap_or(4);
if self.edge {
return self.edge_pts.first().map(|a| vec![lwpoly(polygon_from_edge(*a, c, n), true)]).unwrap_or_default();
}
match (self.center, self.inscribed) {
(Some(ctr), Some(ins)) => vec![lwpoly(polygon_vertices(ctr, n, c, ins), true)],
_ => Vec::new(),
}
}
}
+173
View File
@@ -0,0 +1,173 @@
use super::{
Accept, EntityKind, Input, Interactive, Prompt, Result, Session, Spline, Step, Value, Vec2, added, bad, bool_or, distinct_points, find_command,
points_param,
};
pub(super) fn run_spline(s: &mut Session, p: &Value) -> Result<Value> {
if p.get("object").is_some() {
return crate::cmd::spline_opts::run_object(s, p);
}
let sp = if let Some(f) = points_param(p, "fit") {
if distinct_points(&f).len() < 2 {
return Err(bad("spline", "need 2+ different fit points"));
}
Spline::fit_with(&f, bool_or(p, "closed", false), crate::cmd::spline_opts::json_options(s, p)?)
} else if let Some(c) = points_param(p, "control") {
if c.len() < 2 || c.len() > crate::cmd::curves::MAX_GEN {
return Err(bad("spline", "need 2+ control points"));
}
// Degree 1..=10 as at the Degree prompt, `spline.cv` and DXF input (tessellation grows
// with its square).
Spline::from_control(c, p.get("degree").and_then(Value::as_u64).unwrap_or(3).clamp(1, 10) as usize)
} else {
return Err(bad("spline", "`fit` or `control` points are required"));
};
added(s.add_entity(EntityKind::Spline(sp))?)
}
#[derive(Default)]
pub(super) struct SplineM {
pts: Vec<Vec2>,
asking_method: bool,
/// `Method` > `CV` hands over to the control-vertex machine (`spline.cv`).
delegate: Option<Box<dyn Interactive>>,
/// Knots, end tangents and fit tolerance chosen so far.
opts: cadcraft_geom::FitOptions,
/// An option asking a further question (Knots, Tangency, toLerance, Object).
ask: Option<crate::cmd::spline_opts::SplineAsk>,
}
impl SplineM {
fn make(&self, closed: bool) -> Spline {
Spline::fit_with(&self.pts, closed, self.opts)
}
}
impl Interactive for SplineM {
fn name(&self) -> &'static str {
"SPLINE"
}
fn begin(&mut self, s: &mut Session) -> Result<Step> {
self.opts.knots = crate::cmd::spline_opts::current_knots(s);
s.echo(format!("Current settings: Method=Fit Knots={}", self.opts.knots.name()));
Ok(Step::Continue)
}
fn prompt(&self, s: &Session) -> Prompt {
if let Some(d) = &self.delegate {
return d.prompt(s);
}
if let Some(a) = &self.ask {
return a.prompt(&self.opts, &self.pts);
}
if self.asking_method {
return Prompt::new("Enter spline creation method", crate::cmd::curves::KW).kw(&["Fit", "CV"]).default("Fit");
}
match self.pts.len() {
0 => Prompt::new("Specify first point", Accept::POINT).kw(&["Method", "Knots", "Object"]),
1 => Prompt::new("Enter next point", Accept::POINT).kw(&["start Tangency", "toLerance"]).base_opt(self.pts.last().copied()),
2 => Prompt::new("Enter next point", Accept::POINT).kw(&["end Tangency", "toLerance", "Undo"]).base_opt(self.pts.last().copied()),
_ => {
Prompt::new("Enter next point", Accept::POINT).kw(&["end Tangency", "toLerance", "Undo", "Close"]).base_opt(self.pts.last().copied())
}
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
if let Some(d) = &mut self.delegate {
return d.input(s, i);
}
if let Some(a) = &mut self.ask {
use crate::cmd::spline_opts::Outcome;
return match a.input(s, i, &mut self.opts, &self.pts)? {
Outcome::Stay => Ok(Step::Continue),
Outcome::Back => {
self.ask = None;
Ok(Step::Continue)
}
Outcome::Finish => {
self.ask = None;
if self.pts.len() >= 2 {
s.add_entity(EntityKind::Spline(self.make(false)))?;
}
Ok(Step::Done)
}
Outcome::Done => Ok(Step::Done),
};
}
if self.asking_method {
match i {
Input::Keyword(k) if k == "CV" => {
let Some(factory) = find_command("spline.cv").and_then(|c| c.interactive) else {
return Err(crate::EngineError::Other("CV: not available yet".into()));
};
let mut m = factory(s)?;
self.asking_method = false;
let step = m.begin(s)?;
self.delegate = Some(m);
return Ok(step);
}
Input::Keyword(_) | Input::Enter => self.asking_method = false,
Input::Text(_) => return Err(crate::EngineError::Other("Invalid option keyword.".into())),
_ => {}
}
return Ok(Step::Continue);
}
match i {
Input::Point(p) => {
self.pts.push(p);
Ok(Step::Continue)
}
Input::Keyword(k) if k == "Undo" => {
self.pts.pop();
Ok(Step::Continue)
}
Input::Keyword(k) if k == "Close" => {
// A smooth closed (periodic) curve, not a repeat of the first point.
s.add_entity(EntityKind::Spline(self.make(true)))?;
Ok(Step::Done)
}
Input::Keyword(k) if k == "Method" => {
self.asking_method = true;
Ok(Step::Continue)
}
Input::Keyword(k) => {
// Knots, Object, start/end Tangency, toLerance ask their own question.
self.ask = Some(
crate::cmd::spline_opts::SplineAsk::from_keyword(&k)
.ok_or_else(|| crate::EngineError::Other("Invalid option keyword.".into()))?,
);
Ok(Step::Continue)
}
Input::Enter => {
if self.pts.len() >= 2 {
s.add_entity(EntityKind::Spline(self.make(false)))?;
}
Ok(Step::Done)
}
Input::Text(_) => Err(crate::EngineError::Other("Point or option keyword required.".into())),
_ => Ok(Step::Continue),
}
}
fn preview(&self, s: &Session, c: Vec2) -> Vec<EntityKind> {
if let Some(d) = &self.delegate {
return d.preview(s, c);
}
if self.pts.is_empty() {
return Vec::new();
}
let mut opts = self.opts;
let mut pts = self.pts.clone();
match self.ask {
// Rubber-band the tangent being picked.
Some(crate::cmd::spline_opts::SplineAsk::StartTangent) => {
opts.start_tangent = self.pts.first().map(|f| c - *f).filter(|d| d.len() > 1e-12)
}
Some(crate::cmd::spline_opts::SplineAsk::EndTangent) => opts.end_tangent = self.pts.last().map(|l| c - *l).filter(|d| d.len() > 1e-12),
Some(_) => {}
None => pts.push(c),
}
if pts.len() < 2 {
return Vec::new();
}
vec![EntityKind::Spline(Spline::fit_with(&pts, false, opts))]
}
}
+277
View File
@@ -0,0 +1,277 @@
use super::{
Accept, EntityKind, HAlign, Input, Interactive, MText, PolyVertex, Prompt, Result, Session, Step, Text, VAlign, Value, Vec2, added, bad, bool_or,
f64_or, lwpoly, number, point_req, str_param, v3,
};
/// Justification code → (halign, valign).
pub(crate) fn justify(j: &str) -> Option<(HAlign, VAlign)> {
Some(match j.to_ascii_uppercase().as_str() {
"L" | "LEFT" => (HAlign::Left, VAlign::Baseline),
"C" | "CENTER" => (HAlign::Center, VAlign::Baseline),
"R" | "RIGHT" => (HAlign::Right, VAlign::Baseline),
"A" | "ALIGN" => (HAlign::Aligned, VAlign::Baseline),
"M" | "MIDDLE" => (HAlign::Middle, VAlign::Baseline),
"F" | "FIT" => (HAlign::Fit, VAlign::Baseline),
"TL" => (HAlign::Left, VAlign::Top),
"TC" => (HAlign::Center, VAlign::Top),
"TR" => (HAlign::Right, VAlign::Top),
"ML" => (HAlign::Left, VAlign::Middle),
"MC" => (HAlign::Center, VAlign::Middle),
"MR" => (HAlign::Right, VAlign::Middle),
"BL" => (HAlign::Left, VAlign::Bottom),
"BC" => (HAlign::Center, VAlign::Bottom),
"BR" => (HAlign::Right, VAlign::Bottom),
_ => return None,
})
}
pub(crate) fn make_text(s: &Session, at: Vec2, value: &str, height: f64, rotation: f64, j: (HAlign, VAlign)) -> EntityKind {
let style = s.doc().map(|d| d.header.str("TEXTSTYLE", "Standard")).unwrap_or_else(|_| "Standard".into());
let (h, v) = j;
let left = h == HAlign::Left && v == VAlign::Baseline;
EntityKind::Text(Text {
insert: v3(at),
align_pt: if left { None } else { Some(v3(at)) },
height,
value: value.into(),
rotation,
width_factor: 1.0,
oblique: 0.0,
style,
halign: h,
valign: v,
})
}
pub(super) fn run_text(s: &mut Session, p: &Value) -> Result<Value> {
let at = point_req("text", p, "at")?;
let text = str_param(p, "text").ok_or_else(|| bad("text", "`text` is required"))?;
let height = f64_or(p, "height", s.doc()?.header.f64("TEXTSIZE", 0.2));
if height <= 0.0 {
return Err(bad("text", "height must be positive"));
}
let j = justify(str_param(p, "justify").unwrap_or("L")).ok_or_else(|| bad("text", "unknown justification"))?;
let k = make_text(s, at, text, height, f64_or(p, "rotation", 0.0).to_radians(), j);
added(s.add_entity(k)?)
}
pub(super) fn run_mtext(s: &mut Session, p: &Value) -> Result<Value> {
let at = point_req("mtext", p, "at")?;
let text = str_param(p, "text").ok_or_else(|| bad("mtext", "`text` is required"))?;
let height = f64_or(p, "height", s.doc()?.header.f64("TEXTSIZE", 0.2));
if height <= 0.0 {
return Err(bad("mtext", "height must be positive"));
}
let style = match str_param(p, "style") {
Some(n) => s.doc()?.text_style(n).map(|t| t.name.clone()).ok_or_else(|| bad("mtext", format!("no text style \"{n}\"")))?,
None => s.doc()?.header.str("TEXTSTYLE", "Standard"),
};
let attach = match str_param(p, "justify") {
Some(j) => crate::cmd::mtext_opts::attach_code(j).ok_or_else(|| bad("mtext", "`justify` must be TL, TC, TR, ML, MC, MR, BL, BC or BR"))?,
None => p.get("attach").and_then(Value::as_u64).unwrap_or(1).clamp(1, 9) as u8,
};
let width = f64_or(p, "width", 0.0).max(0.0);
let k = EntityKind::MText(MText {
insert: v3(at),
height,
width,
attach,
rotation: f64_or(p, "rotation", 0.0).to_radians(),
style,
contents: crate::cmd::mtext_opts::json_paragraphs(p, text.replace('\n', "\\P"), height)?,
line_spacing: f64_or(p, "lineSpacing", 1.0),
line_spacing_exact: bool_or(p, "lineSpacingExact", false),
columns: crate::cmd::mtext_opts::json_columns(p, height, width)?,
});
added(s.add_entity(k)?)
}
pub(super) struct TextM {
at: Option<Vec2>,
height: Option<f64>,
rotation: Option<f64>,
justify: (HAlign, VAlign),
asking_justify: bool,
line: usize,
/// Run by a LISP routine (`command`): one line of text ends the command, as AutoLISP's
/// `command` runs TEXT; typed and in scripts each line asks for the next.
single_line: bool,
}
impl TextM {
pub(super) fn new(s: &Session) -> Self {
TextM {
at: None,
height: None,
rotation: None,
justify: (HAlign::Left, VAlign::Baseline),
asking_justify: false,
line: 0,
single_line: s.lisp_running(),
}
}
}
impl Interactive for TextM {
fn name(&self) -> &'static str {
"TEXT"
}
fn prompt(&self, s: &Session) -> Prompt {
let th = s.doc().map(|d| d.header.f64("TEXTSIZE", 0.2)).unwrap_or(0.2);
if self.asking_justify {
return Prompt::new("Enter an option", Accept::TEXT)
.kw(&["Left", "Center", "Right", "Align", "Middle", "Fit", "TL", "TC", "TR", "ML", "MC", "MR", "BL", "BC", "BR"]);
}
match (self.at, self.height, self.rotation) {
(None, _, _) => Prompt::new("Specify start point of text", Accept::POINT).kw(&["Justify", "Style"]),
(Some(a), None, _) => Prompt::new("Specify height", Accept::POINT_OR_NUMBER).default(format!("{th:.4}")).base(a),
(Some(a), Some(_), None) => Prompt::new("Specify rotation angle of text", Accept::POINT_OR_NUMBER).default("0").base(a),
_ => Prompt::new("Enter text", Accept::TEXT),
}
}
fn begin(&mut self, s: &mut Session) -> Result<Step> {
let d = s.doc()?;
let style = d.header.str("TEXTSTYLE", "Standard");
let anno = if d.text_style(&style).is_some_and(|st| st.annotative) { "Yes" } else { "No" };
let msg =
format!("Current text style: \"{style}\" Text height: {:.4} Annotative: {anno} Justify: Left", d.header.f64("TEXTSIZE", 0.2));
s.echo(msg);
Ok(Step::Continue)
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
if self.asking_justify {
let code = match &i {
Input::Keyword(k) | Input::Text(k) => k.clone(),
_ => return Ok(Step::Continue),
};
self.justify = justify(&code).unwrap_or(self.justify);
self.asking_justify = false;
return Ok(Step::Continue);
}
let th = s.doc()?.header.f64("TEXTSIZE", 0.2);
match (self.at, self.height, self.rotation, i) {
(None, _, _, Input::Keyword(k)) if k == "Justify" => self.asking_justify = true,
(None, _, _, Input::Point(p)) => self.at = Some(p),
(Some(_), None, _, Input::Enter) => self.height = Some(th),
(Some(a), None, _, Input::Point(p)) => self.height = Some(a.dist(p).max(1e-9)),
(Some(_), None, _, Input::Text(t)) => {
let h = number(&t).filter(|h| *h > 0.0).ok_or_else(|| crate::EngineError::Other("Requires a positive height.".into()))?;
self.height = Some(h);
s.doc_mut()?.header.set_f64("TEXTSIZE", h);
}
(Some(_), Some(_), None, Input::Enter) => self.rotation = Some(0.0),
(Some(a), Some(_), None, Input::Point(p)) => self.rotation = Some(a.angle_to(p)),
(Some(_), Some(_), None, Input::Text(t)) => {
self.rotation = Some(s.angle_settings().direction(&t).ok_or_else(|| crate::EngineError::Other("Requires an angle.".into()))?)
}
(Some(a), Some(h), Some(r), Input::Text(t)) => {
let at = a + Vec2::from_angle(r - std::f64::consts::FRAC_PI_2) * (h * 5.0 / 3.0 * self.line as f64);
let k = make_text(s, at, &t, h, r, self.justify);
s.add_entity(k)?;
self.line += 1;
if self.single_line {
return Ok(Step::Done);
}
}
(Some(_), Some(_), Some(_), Input::Enter) => return Ok(Step::Done),
_ => {}
}
Ok(Step::Continue)
}
}
#[derive(Default)]
pub(super) struct MTextM {
first: Option<Vec2>,
second: Option<Vec2>,
/// Paragraphs typed so far: one per input line, an empty line ends the text (as on
/// AutoCAD's command line and in scripts).
lines: Vec<String>,
/// Height, justification, line spacing, rotation, style and width set by the options.
opts: crate::cmd::mtext_opts::MTextOpts,
/// An option asking a further question.
ask: Option<crate::cmd::mtext_opts::MTextAsk>,
}
impl MTextM {
/// The box is known: the opposite corner was picked or the Width option answered.
fn boxed(&self) -> bool {
self.second.is_some() || self.opts.width.is_some()
}
}
impl Interactive for MTextM {
fn name(&self) -> &'static str {
"MTEXT"
}
fn begin(&mut self, s: &mut Session) -> Result<Step> {
self.opts = crate::cmd::mtext_opts::MTextOpts::new(s);
let anno = s.doc()?.text_style(&self.opts.style).is_some_and(|st| st.annotative);
s.echo(format!(
"Current text style: \"{}\" Text height: {:.4} Annotative: {}",
self.opts.style,
self.opts.height,
if anno { "Yes" } else { "No" }
));
Ok(Step::Continue)
}
fn prompt(&self, _s: &Session) -> Prompt {
if let (Some(a), Some(q)) = (self.first, self.ask) {
return q.prompt(&self.opts, a);
}
match self.first {
None => Prompt::new("Specify first corner", Accept::POINT),
Some(a) if !self.boxed() => Prompt::new("Specify opposite corner", Accept::POINT)
.kw(&["Height", "Justify", "Line spacing", "Rotation", "Style", "Width", "Columns"])
.base(a),
_ if self.lines.is_empty() => Prompt::new("Enter text (use \\P for new paragraphs)", Accept::TEXT),
_ => Prompt::new("Enter next line of text (empty line to finish)", Accept::TEXT),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
if let (Some(a), Some(q)) = (self.first, self.ask) {
use crate::cmd::mtext_opts::Outcome;
self.ask = match q.input(s, i, &mut self.opts, a)? {
Outcome::Ask(next) => Some(next),
Outcome::Back | Outcome::Text => None,
};
return Ok(Step::Continue);
}
match (self.first, self.boxed(), i) {
(None, _, Input::Point(p)) => self.first = Some(p),
(Some(_), false, Input::Point(p)) => self.second = Some(p),
(Some(_), false, Input::Keyword(k)) => {
self.ask = Some(
crate::cmd::mtext_opts::MTextAsk::from_keyword(&k).ok_or_else(|| crate::EngineError::Other("Invalid option keyword.".into()))?,
);
}
(Some(_), true, Input::Text(t)) if !t.is_empty() => self.lines.push(t),
(Some(a), true, Input::Text(_) | Input::Enter) => {
if self.lines.is_empty() {
return Ok(Step::Done);
}
s.add_entity(EntityKind::MText(self.opts.mtext(a, self.second, self.lines.join("\\P"))))?;
return Ok(Step::Done);
}
(_, _, Input::Enter) => return Ok(Step::Done),
_ => {}
}
Ok(Step::Continue)
}
fn editing_mtext(&self) -> Option<MText> {
let a = self.first?;
(self.boxed() && self.ask.is_none()).then(|| self.opts.mtext(a, self.second, String::new()))
}
fn preview(&self, _s: &Session, c: Vec2) -> Vec<EntityKind> {
match (self.first, self.boxed(), self.ask) {
(Some(a), false, None) => {
// The box, turned by the rotation about the first corner.
let r = self.opts.rotation;
let l = (c - a).rotate(-r);
let corners = [Vec2::ZERO, Vec2::new(l.x, 0.0), l, Vec2::new(0.0, l.y)];
vec![lwpoly(corners.iter().map(|q| PolyVertex::new(a + q.rotate(r))).collect(), true)]
}
_ => Vec::new(),
}
}
}
File diff suppressed because it is too large Load Diff
+384
View File
@@ -0,0 +1,384 @@
use super::{
Accept, Arc, Circle, EntityKind, Input, Interactive, Prompt, Result, Session, Step, Value, Vec2, added, arc, bad, circle, curves, line,
norm_angle, number, other, point_param, point_req, point_value,
};
// =====================================================================================
// Generic point/value sequences (arc and circle variants)
// =====================================================================================
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum ArcV {
/// The ARC command itself: three points, with options switching to the other variants.
ThreeP,
/// Start, end, then center.
Sec,
Sce,
Sca,
Scl,
Sea,
Sed,
Ser,
Cse,
Csa,
Csl,
CircleCd,
Circle2p,
Circle3p,
}
/// What a step asks for.
#[derive(Clone, Copy, Debug)]
enum Ask {
Point,
/// Absolute angle from value `base` to the picked point, or a typed angle.
Angle(usize),
/// CCW angle at value `center` from the ray to value `from` to the ray to the picked point.
Sweep(usize, usize),
/// Distance from value `base`, or a typed distance (may be negative).
Dist(usize),
}
#[derive(Clone, Copy, Debug)]
enum Val {
P(Vec2),
N(f64),
}
impl Val {
fn p(self) -> Option<Vec2> {
if let Val::P(p) = self { Some(p) } else { None }
}
fn n(self) -> Option<f64> {
if let Val::N(n) = self { Some(n) } else { None }
}
}
impl ArcV {
fn name(self) -> &'static str {
match self {
ArcV::CircleCd | ArcV::Circle2p | ArcV::Circle3p => "CIRCLE",
_ => "ARC",
}
}
fn steps(self) -> &'static [(&'static str, Ask)] {
const S: &str = "Specify start point of arc";
const C: &str = "Specify center point of arc";
const E: &str = "Specify end point of arc";
const A: &str = "Specify included angle";
const L: &str = "Specify length of chord";
match self {
ArcV::ThreeP => &[(S, Ask::Point), ("Specify second point of arc", Ask::Point), (E, Ask::Point)],
ArcV::Sec => &[(S, Ask::Point), (E, Ask::Point), (C, Ask::Point)],
ArcV::Sce => &[(S, Ask::Point), (C, Ask::Point), (E, Ask::Point)],
ArcV::Sca => &[(S, Ask::Point), (C, Ask::Point), (A, Ask::Sweep(1, 0))],
ArcV::Scl => &[(S, Ask::Point), (C, Ask::Point), (L, Ask::Dist(0))],
ArcV::Sea => &[(S, Ask::Point), (E, Ask::Point), (A, Ask::Angle(0))],
ArcV::Sed => &[(S, Ask::Point), (E, Ask::Point), ("Specify tangent direction for the start point of arc", Ask::Angle(0))],
ArcV::Ser => &[(S, Ask::Point), (E, Ask::Point), ("Specify radius of arc", Ask::Dist(1))],
ArcV::Cse => &[(C, Ask::Point), (S, Ask::Point), ("Specify end point of arc (hold Ctrl to switch direction)", Ask::Point)],
ArcV::Csa => &[(C, Ask::Point), (S, Ask::Point), (A, Ask::Sweep(0, 1))],
ArcV::Csl => &[(C, Ask::Point), (S, Ask::Point), (L, Ask::Dist(1))],
ArcV::CircleCd => &[("Specify center point for circle", Ask::Point), ("Specify diameter of circle", Ask::Dist(0))],
ArcV::Circle2p => {
&[("Specify first end point of circle's diameter", Ask::Point), ("Specify second end point of circle's diameter", Ask::Point)]
}
ArcV::Circle3p => &[
("Specify first point on circle", Ask::Point),
("Specify second point on circle", Ask::Point),
("Specify third point on circle", Ask::Point),
],
}
}
fn build(self, v: &[Val], ctrl: bool) -> Option<EntityKind> {
let p = |i: usize| v.get(i).and_then(|x| x.p());
let n = |i: usize| v.get(i).and_then(|x| x.n());
let a = match self {
ArcV::ThreeP => Arc::from_3_points(p(0)?, p(1)?, p(2)?),
ArcV::Sec => Some(Arc::from_start_center_end(p(0)?, p(2)?, p(1)?)),
ArcV::Sce => Some(Arc::from_start_center_end(p(0)?, p(1)?, p(2)?)),
ArcV::Sca => curves::arc_sca(p(0)?, p(1)?, n(2)?),
ArcV::Scl => curves::arc_scl(p(0)?, p(1)?, n(2)?),
ArcV::Sea => curves::arc_sea(p(0)?, p(1)?, n(2)?),
ArcV::Sed => curves::arc_sed(p(0)?, p(1)?, Vec2::from_angle(n(2)?)),
ArcV::Ser => curves::arc_ser(p(0)?, p(1)?, n(2)?),
// Center, Start, End. Ctrl keeps the two endpoints on the circle and walks the
// other way around: swapping the start/end angles is the mirror arc through the
// same two endpoints. (Continue below flips differently, by negating the start
// tangent; both are "reverse", but only here is the chord fixed by the picks.)
ArcV::Cse => {
let a = Arc::from_start_center_end(p(1)?, p(0)?, p(2)?);
Some(if ctrl { Arc::new(a.center, a.radius, a.end, a.start) } else { a })
}
ArcV::Csa => curves::arc_sca(p(1)?, p(0)?, n(2)?),
ArcV::Csl => curves::arc_scl(p(1)?, p(0)?, n(2)?),
ArcV::CircleCd => {
let d = n(1)?.abs();
let c = p(0)?;
return (d > 1e-12 && d.is_finite()).then(|| circle(c, d / 2.0));
}
ArcV::Circle2p => {
let c = Circle::from_2_points(p(0)?, p(1)?);
return (c.radius > 1e-12).then(|| circle(c.center, c.radius));
}
ArcV::Circle3p => {
let c = Circle::from_3_points(p(0)?, p(1)?, p(2)?)?;
return Some(circle(c.center, c.radius));
}
}?;
(a.radius > 1e-12 && a.radius.is_finite() && a.center.is_finite()).then(|| arc(&a))
}
}
impl ArcV {
/// Options at step `i` and the variant each one switches to (the values so far carry over).
fn alts(self, i: usize) -> &'static [(&'static str, ArcV)] {
match (self, i) {
(ArcV::ThreeP, 0) => &[("Center", ArcV::Cse)],
(ArcV::ThreeP, 1) => &[("Center", ArcV::Sce), ("End", ArcV::Sec)],
(ArcV::Sce, 2) => &[("Angle", ArcV::Sca), ("chord Length", ArcV::Scl)],
(ArcV::Cse, 2) => &[("Angle", ArcV::Csa), ("chord Length", ArcV::Csl)],
(ArcV::Sec, 2) => &[("Angle", ArcV::Sea), ("Direction", ArcV::Sed), ("Radius", ArcV::Ser)],
_ => &[],
}
}
}
pub(super) fn seq(v: ArcV) -> Box<dyn Interactive> {
Box::new(SeqM { v, vals: Vec::new(), cont: None })
}
/// The full ARC prompt sequence (all options; Enter at the first prompt continues the last
/// line or arc).
pub(crate) fn arc_machine() -> Box<dyn Interactive> {
seq(ArcV::ThreeP)
}
struct SeqM {
v: ArcV,
vals: Vec<Val>,
/// ARC continuation: start point and tangent.
cont: Option<(Vec2, Vec2)>,
}
impl SeqM {
fn ask(&self) -> Option<(&'static str, Ask)> {
self.v.steps().get(self.vals.len()).copied()
}
fn value_from_point(&self, ask: Ask, q: Vec2) -> Option<Val> {
let pt = |i: usize| self.vals.get(i).and_then(|x| x.p());
Some(match ask {
Ask::Point => Val::P(q),
Ask::Angle(b) => Val::N(pt(b)?.angle_to(q)),
Ask::Sweep(c, f) => {
let c = pt(c)?;
Val::N(norm_angle(c.angle_to(q) - c.angle_to(pt(f)?)))
}
Ask::Dist(b) => Val::N(pt(b)?.dist(q)),
})
}
fn base(&self) -> Option<Vec2> {
match self.ask()?.1 {
Ask::Point => self.vals.last().and_then(|v| v.p()),
Ask::Angle(b) | Ask::Dist(b) => self.vals.get(b).and_then(|v| v.p()),
Ask::Sweep(c, _) => self.vals.get(c).and_then(|v| v.p()),
}
}
}
impl Interactive for SeqM {
fn name(&self) -> &'static str {
self.v.name()
}
fn prompt(&self, _s: &Session) -> Prompt {
if let Some((st, _)) = self.cont {
return Prompt::new("Specify end point of arc (hold Ctrl to switch direction)", Accept::POINT).base(st);
}
let kws: Vec<&str> = self.v.alts(self.vals.len()).iter().map(|a| a.0).collect();
let p = match self.ask() {
Some((m, Ask::Point)) => Prompt::new(m, Accept::POINT).base_opt(self.base()),
Some((m, _)) => Prompt::new(m, Accept::POINT_OR_NUMBER).base_opt(self.base()),
None => Prompt::new("", Accept::default()),
};
p.kw(&kws)
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
if let Some((st, dir)) = self.cont {
// Ctrl flips the arc: negating the start tangent makes `arc_sed` pick the mirror
// arc (same two points and center, opposite sweep). `ctrl_down` comes from the
// UI's per-frame write on `Session`; the `Input` enum itself stays free of
// modifiers so it remains UI-agnostic.
let dir = if s.ctrl_down { -dir } else { dir };
return match i {
Input::Point(e) => {
let a = curves::arc_sed(st, e, dir).ok_or_else(|| other("Invalid end point."))?;
s.add_entity(arc(&a))?;
Ok(Step::Done)
}
Input::Enter => Ok(Step::Cancel),
_ => Err(other("Point required.")),
};
}
if let Input::Keyword(k) = &i
&& let Some((_, v)) = self.v.alts(self.vals.len()).iter().find(|a| a.0 == k)
{
self.v = *v;
return Ok(Step::Continue);
}
if i == Input::Enter && self.v == ArcV::ThreeP && self.vals.is_empty() {
self.cont = curves::last_curve_end(s);
if self.cont.is_none() {
s.echo("No line or arc to continue.");
return Ok(Step::Cancel);
}
return Ok(Step::Continue);
}
let Some((_, ask)) = self.ask() else { return Ok(Step::Done) };
let val = match (&i, ask) {
(Input::Point(q), _) => self.value_from_point(ask, *q),
(Input::Text(t), Ask::Angle(_) | Ask::Sweep(..)) => {
// The tangent direction is a direction; included angles are sizes.
let au = s.angle_settings();
let a = if self.v == ArcV::Sed { au.direction(t) } else { au.amount(t) };
Some(Val::N(a.ok_or_else(|| other("Requires a valid angle."))?))
}
(Input::Text(t), Ask::Dist(_)) => Some(Val::N(number(t).ok_or_else(|| other("Requires numeric distance or a point."))?)),
(Input::Enter, _) => return Ok(Step::Cancel),
_ => return Err(other("Point or value required.")),
};
let Some(val) = val else { return Ok(Step::Continue) };
self.vals.push(val);
if self.vals.len() < self.v.steps().len() {
return Ok(Step::Continue);
}
match self.v.build(&self.vals, s.ctrl_down) {
Some(k) => {
if let EntityKind::Circle(c) = &k {
let r = c.radius;
s.add_entity(k)?;
s.doc_mut()?.header.set_f64("CIRCLERAD", r);
} else {
s.add_entity(k)?;
}
Ok(Step::Done)
}
None => {
self.vals.pop();
Err(other("Invalid arc or circle for these values."))
}
}
}
fn preview(&self, s: &Session, c: Vec2) -> Vec<EntityKind> {
if let Some((st, d)) = self.cont {
let d = if s.ctrl_down { -d } else { d };
return curves::arc_sed(st, c, d).map(|a| vec![arc(&a)]).unwrap_or_default();
}
let Some((_, ask)) = self.ask() else { return Vec::new() };
let Some(v) = self.value_from_point(ask, c) else { return Vec::new() };
let mut vals = self.vals.clone();
vals.push(v);
let mut out: Vec<EntityKind> = self.base().map(|b| vec![line(b, c)]).unwrap_or_default();
if vals.len() == self.v.steps().len() {
out.extend(self.v.build(&vals, s.ctrl_down));
}
out
}
}
fn direction_param(p: &Value) -> Option<f64> {
match p.get("direction") {
Some(v) if v.is_array() || v.is_object() => point_value(v).filter(|d| d.len() > 1e-12).map(Vec2::angle),
Some(v) => v.as_f64().filter(|x| x.is_finite()).map(f64::to_radians),
None => None,
}
}
fn arc_from_params(p: &Value) -> Result<EntityKind> {
let st = point_param(p, "start");
let c = point_param(p, "center");
let e = point_param(p, "end");
let ang = p.get("angle").and_then(Value::as_f64).filter(|v| v.is_finite()).map(f64::to_radians);
let len = p.get("length").and_then(Value::as_f64).filter(|v| v.is_finite());
let rad = p.get("radius").and_then(Value::as_f64).filter(|v| v.is_finite());
let dir = direction_param(p);
let a = match (st, c, e) {
(Some(st), Some(c), Some(e)) => Some(Arc::from_start_center_end(st, c, e)),
(Some(st), Some(c), None) => match (ang, len) {
(Some(a), _) => curves::arc_sca(st, c, a),
(None, Some(l)) => curves::arc_scl(st, c, l),
_ => return Err(bad("arc", "start+center needs `end`, `angle` or `length`")),
},
(Some(st), None, Some(e)) => match (ang, dir, rad) {
(Some(a), _, _) => curves::arc_sea(st, e, a),
(None, Some(d), _) => curves::arc_sed(st, e, Vec2::from_angle(d)),
(None, None, Some(r)) => curves::arc_ser(st, e, r),
_ => return Err(bad("arc", "start+end needs `angle`, `direction` or `radius`")),
},
_ => return Err(bad("arc", "give start with center and/or end (see params)")),
};
let a = a.filter(|a| a.radius > 1e-12 && a.radius.is_finite()).ok_or_else(|| bad("arc", "these values do not define an arc"))?;
Ok(arc(&a))
}
pub(super) fn run_arc_variant(s: &mut Session, p: &Value) -> Result<Value> {
let k = arc_from_params(p)?;
added(s.add_entity(k)?)
}
pub(super) fn run_arc_continue(s: &mut Session, p: &Value) -> Result<Value> {
let e = point_req("arc.continue", p, "end")?;
let (st, dir) = match (point_param(p, "start"), direction_param(p)) {
(Some(st), Some(d)) => (st, Vec2::from_angle(d)),
_ => curves::last_curve_end(s).ok_or_else(|| bad("arc.continue", "no line or arc to continue from"))?,
};
let a = curves::arc_sed(st, e, dir).ok_or_else(|| bad("arc.continue", "end point is on the tangent line"))?;
let h = s.add_entity(arc(&a))?;
// The next continuation starts where this arc was drawn to, not at its stored CCW end.
s.last_point = e;
added(h)
}
#[derive(Default)]
pub(super) struct ArcContinueM {
from: Option<(Vec2, Vec2)>,
}
impl Interactive for ArcContinueM {
fn name(&self) -> &'static str {
"ARC"
}
fn begin(&mut self, s: &mut Session) -> Result<Step> {
self.from = curves::last_curve_end(s);
if self.from.is_none() {
s.echo("No line or arc to continue.");
return Ok(Step::Cancel);
}
Ok(Step::Continue)
}
fn prompt(&self, _s: &Session) -> Prompt {
Prompt::new("Specify end point of arc (hold Ctrl to switch direction)", Accept::POINT).base_opt(self.from.map(|f| f.0))
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
let Some((st, dir)) = self.from else { return Ok(Step::Cancel) };
// Ctrl flips the direction, exactly as in `SeqM::input`.
let dir = if s.ctrl_down { -dir } else { dir };
match i {
Input::Point(e) => {
let a = curves::arc_sed(st, e, dir).ok_or_else(|| other("Invalid end point."))?;
s.add_entity(arc(&a))?;
Ok(Step::Done)
}
Input::Enter => Ok(Step::Cancel),
_ => Err(other("Point required.")),
}
}
fn preview(&self, s: &Session, c: Vec2) -> Vec<EntityKind> {
self.from
.and_then(|(st, dir)| {
let dir = if s.ctrl_down { -dir } else { dir };
curves::arc_sed(st, c, dir)
})
.map(|a| vec![arc(&a)])
.unwrap_or_default()
}
}
+3 -2
View File
@@ -9,10 +9,11 @@ use cadcraft_doc::{Drawing, EntityKind};
use cadcraft_geom::{PolyVertex, Vec2};
use serde_json::Value;
use super::super::machines::number;
use super::added;
use super::curves::MAX_GEN;
use super::machines::number;
use super::*;
use super::{CommandSpec, line, lwpoly, point_param, point_req, size_param, str_param};
use crate::cmd::modify2::as_lwpoly;
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
+126
View File
@@ -0,0 +1,126 @@
use super::{
Accept, EntityKind, Handle, Input, Interactive, Prompt, Result, Session, Step, Value, Vec2, added, bad, curves, json, line, other, targets,
};
// =====================================================================================
// CENTERMARK / CENTERLINE
// =====================================================================================
/// Add a line on the CENTER linetype (loading it from the standard library when missing).
fn add_center_line(s: &mut Session, a: Vec2, b: Vec2) -> Result<Handle> {
let d = s.doc_mut()?;
if d.linetype("CENTER").is_none()
&& let Some(lt) = cadcraft_doc::library::standard_linetypes_for(d).into_iter().find(|l| l.name == "CENTER")
{
d.linetypes.push(lt);
}
let h = s.add_entity(line(a, b))?;
s.doc_mut()?.modify_entity(h, |e| e.common.linetype = "CENTER".into())?;
Ok(h)
}
fn center_ext(s: &Session) -> f64 {
let d = s.doc().ok();
let metric = d.is_some_and(|d| d.header.i64("MEASUREMENT", 0) == 1);
d.map(|d| d.header.f64("CENTEREXE", if metric { 3.5 } else { 0.12 })).unwrap_or(0.12)
}
fn center_mark(s: &mut Session, h: Handle) -> Result<Vec<String>> {
let e = curves::entity(s, h)?;
let (c, r) = match &e.kind {
EntityKind::Circle(c) => (c.center.xy(), c.radius),
EntityKind::Arc(a) => (a.center.xy(), a.radius),
_ => return Err(other("Select a circle or arc.")),
};
let ext = r + center_ext(s);
let h1 = add_center_line(s, c - Vec2::X * ext, c + Vec2::X * ext)?;
let h2 = add_center_line(s, c - Vec2::Y * ext, c + Vec2::Y * ext)?;
Ok(vec![h1.hex(), h2.hex()])
}
fn center_line(s: &mut Session, h1: Handle, h2: Handle) -> Result<Handle> {
let l = |h: Handle| -> Result<(Vec2, Vec2)> {
match curves::entity(s, h)?.kind {
EntityKind::Line(l) => Ok((l.a.xy(), l.b.xy())),
_ => Err(other("Select a line.")),
}
};
let (a1, b1) = l(h1)?;
let (mut a2, mut b2) = l(h2)?;
if (b1 - a1).dot(b2 - a2) < 0.0 {
std::mem::swap(&mut a2, &mut b2);
}
let (st, en) = (a1.mid(a2), b1.mid(b2));
let dir = (en - st).normalized();
if dir == Vec2::ZERO {
return Err(other("The lines do not define a center line."));
}
let ext = center_ext(s);
add_center_line(s, st - dir * ext, en + dir * ext)
}
pub(super) fn run_centermark(s: &mut Session, p: &Value) -> Result<Value> {
let hs = targets(s, p)?;
if hs.is_empty() {
return Err(bad("centermark", "`handle` or `handles` is required"));
}
let mut out = Vec::new();
for h in hs {
out.extend(center_mark(s, h)?);
}
Ok(json!({ "handles": out }))
}
pub(super) fn run_centerline(s: &mut Session, p: &Value) -> Result<Value> {
let h1 = curves::handle_param(p, "h1").ok_or_else(|| bad("centerline", "`h1` is required"))?;
let h2 = curves::handle_param(p, "h2").ok_or_else(|| bad("centerline", "`h2` is required"))?;
added(center_line(s, h1, h2)?)
}
pub(super) struct CenterM {
pub(super) first: Option<Handle>,
pub(super) mark: bool,
}
impl Interactive for CenterM {
fn name(&self) -> &'static str {
if self.mark { "CENTERMARK" } else { "CENTERLINE" }
}
fn begin(&mut self, s: &mut Session) -> Result<Step> {
s.set_selection(Vec::new());
Ok(Step::Continue)
}
fn prompt(&self, _s: &Session) -> Prompt {
match (self.mark, self.first) {
(true, _) => Prompt::new("Select circle or arc to add center mark", Accept::POINT),
(false, None) => Prompt::new("Select first line", Accept::POINT),
(false, Some(_)) => Prompt::new("Select second line", Accept::POINT),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
match i {
Input::Point(p) => {
let h = curves::pick_at(s, p).ok_or_else(|| other("*Invalid selection*"))?;
if self.mark {
center_mark(s, h)?;
return Ok(Step::Continue);
}
match self.first {
None => {
if !matches!(curves::entity(s, h)?.kind, EntityKind::Line(_)) {
return Err(other("Select a line."));
}
self.first = Some(h);
Ok(Step::Continue)
}
Some(h1) => {
center_line(s, h1, h)?;
Ok(Step::Done)
}
}
}
Input::Enter => Ok(Step::Done),
_ => Ok(Step::Continue),
}
}
}
+366
View File
@@ -0,0 +1,366 @@
use super::{
Accept, EntityKind, Handle, Helix, HelixConstrain, Input, Interactive, MAX_GEN, Polyline3d, Prompt, Result, Session, Spline, Step, Value, Vec2,
Vec3, added, bad, bool_or, circle, curves, f64_or, f64_req, line, number, other, point_req, points_param, v3,
};
// =====================================================================================
// 3DPOLY
// =====================================================================================
fn point3_value(v: &Value) -> Option<Vec3> {
let a = v.as_array()?;
let x = a.first()?.as_f64()?;
let y = a.get(1)?.as_f64()?;
let z = a.get(2).and_then(Value::as_f64).unwrap_or(0.0);
let p = Vec3::new(x, y, z);
p.is_finite().then_some(p)
}
pub(super) fn run_3dpoly(s: &mut Session, p: &Value) -> Result<Value> {
let pts: Vec<Vec3> = p.get("points").and_then(Value::as_array).map(|a| a.iter().filter_map(point3_value).collect()).unwrap_or_default();
if pts.len() < 2 || pts.len() > MAX_GEN {
return Err(bad("3dpoly", "`points` needs 2 or more [x,y,z] points"));
}
added(s.add_entity(EntityKind::Polyline3d(Polyline3d { points: pts, closed: bool_or(p, "closed", false) }))?)
}
#[derive(Default)]
pub(super) struct Poly3dM {
pts: Vec<Vec2>,
handle: Option<Handle>,
}
impl Poly3dM {
fn sync(&mut self, s: &mut Session, closed: bool) -> Result<()> {
let k = EntityKind::Polyline3d(Polyline3d { points: self.pts.iter().map(|p| v3(*p)).collect(), closed });
match (self.handle, self.pts.len() >= 2) {
(Some(h), true) => s.doc_mut()?.modify_entity(h, |e| e.kind = k)?,
(None, true) => self.handle = Some(s.add_entity(k)?),
(Some(h), false) => {
s.doc_mut()?.remove_entity(h);
self.handle = None;
}
(None, false) => {}
}
Ok(())
}
}
impl Interactive for Poly3dM {
fn name(&self) -> &'static str {
"3DPOLY"
}
fn prompt(&self, _s: &Session) -> Prompt {
match self.pts.len() {
0 => Prompt::new("Specify start point of polyline", Accept::POINT),
1 | 2 => Prompt::new("Specify endpoint of line", Accept::POINT).kw(&["Undo"]).base_opt(self.pts.last().copied()),
_ => Prompt::new("Specify endpoint of line", Accept::POINT).kw(&["Close", "Undo"]).base_opt(self.pts.last().copied()),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
match i {
Input::Point(p) => {
if self.pts.last().is_none_or(|l| !l.near(p, 1e-12)) && self.pts.len() < MAX_GEN {
self.pts.push(p);
self.sync(s, false)?;
}
Ok(Step::Continue)
}
Input::Keyword(k) if k == "Undo" => {
self.pts.pop();
self.sync(s, false)?;
Ok(Step::Continue)
}
Input::Keyword(k) if k == "Close" => {
self.sync(s, true)?;
Ok(Step::Done)
}
Input::Enter => Ok(Step::Done),
_ => Ok(Step::Continue),
}
}
fn preview(&self, _s: &Session, c: Vec2) -> Vec<EntityKind> {
self.pts.last().map(|l| vec![line(*l, c)]).unwrap_or_default()
}
}
// =====================================================================================
// HELIX (3D polyline approximation)
// =====================================================================================
/// A helix entity (base centre `c`, radii, height, turns, twist, start angle).
fn helix_kind(c: Vec2, rb: f64, rt: f64, height: f64, turns: f64, ccw: bool, a0: f64) -> EntityKind {
let h = Helix { center: c.to3(0.0), base_radius: rb, top_radius: rt, height, turns, start_angle: a0, ccw, constrain: HelixConstrain::Height };
EntityKind::Helix(h.sanitized())
}
pub(super) fn run_helix(s: &mut Session, p: &Value) -> Result<Value> {
let c = point_req("helix", p, "center")?;
let rb = f64_req("helix", p, "baseRadius")?;
let rt = f64_or(p, "topRadius", rb);
let turns = f64_or(p, "turns", 3.0);
if !(rb > 0.0 && rt >= 0.0 && turns > 0.0 && turns <= 500.0) {
return Err(bad("helix", "radii must be positive and turns in (0, 500]"));
}
let height = f64_or(p, "height", 1.0);
if height.abs() > 1e12 || rb > 1e12 || rt > 1e12 {
return Err(bad("helix", "sizes are out of range"));
}
added(s.add_entity(helix_kind(c, rb, rt, height, turns, bool_or(p, "ccw", true), f64_or(p, "startAngle", 0.0).to_radians()))?)
}
#[derive(Default)]
pub(super) struct HelixM {
center: Option<Vec2>,
rb: Option<f64>,
rt: Option<f64>,
turns: Option<f64>,
cw: bool,
diameter: bool,
asking: u8, // 0 main, 1 turns, 2 twist, 3 turn height
start: Option<f64>,
}
impl HelixM {
fn turns(&self) -> f64 {
self.turns.unwrap_or(3.0)
}
fn create(&self, s: &mut Session, height: f64) -> Result<Step> {
let (Some(c), Some(rb)) = (self.center, self.rb) else { return Ok(Step::Cancel) };
let rt = self.rt.unwrap_or(rb);
if !height.is_finite() || height.abs() > 1e12 {
return Err(other("Requires a distance."));
}
s.add_entity(helix_kind(c, rb, rt, height, self.turns(), !self.cw, self.start.unwrap_or(0.0)))?;
Ok(Step::Done)
}
}
impl Interactive for HelixM {
fn name(&self) -> &'static str {
"HELIX"
}
fn begin(&mut self, s: &mut Session) -> Result<Step> {
s.echo(format!("Number of turns = {:.4} Twist=CCW", self.turns()));
Ok(Step::Continue)
}
fn prompt(&self, _s: &Session) -> Prompt {
match self.asking {
1 => return Prompt::new("Enter number of turns", Accept::NUMBER).default(format!("{:.4}", self.turns())),
2 => return Prompt::new("Enter twist direction of helix", curves::KW).kw(&["CW", "CCW"]).default("CCW"),
3 => return Prompt::new("Specify distance between turns", Accept::NUMBER),
_ => {}
}
match (self.center, self.rb, self.rt) {
(None, ..) => Prompt::new("Specify center point of base", Accept::POINT),
(Some(c), None, _) => {
Prompt::new(if self.diameter { "Specify base diameter" } else { "Specify base radius or" }, Accept::POINT_OR_NUMBER)
.kw(if self.diameter { &[] } else { &["Diameter"] })
.default("1.0000")
.base(c)
}
(Some(c), Some(rb), None) => {
Prompt::new(if self.diameter { "Specify top diameter" } else { "Specify top radius or" }, Accept::POINT_OR_NUMBER)
.kw(if self.diameter { &[] } else { &["Diameter"] })
.default(format!("{rb:.4}"))
.base(c)
}
(Some(c), Some(_), Some(_)) => Prompt::new("Specify helix height or", Accept::POINT_OR_NUMBER)
.kw(&["Axis endpoint", "Turns", "turn Height", "tWist"])
.default("1.0000")
.base(c),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
match self.asking {
1 => {
if let Input::Text(t) = &i {
let v = number(t).filter(|v| *v > 0.0 && *v <= 500.0).ok_or_else(|| other("Turns must be in (0, 500]."))?;
self.turns = Some(v);
}
self.asking = 0;
return Ok(Step::Continue);
}
2 => {
if let Input::Keyword(k) | Input::Text(k) = &i {
self.cw = k.eq_ignore_ascii_case("cw");
}
self.asking = 0;
return Ok(Step::Continue);
}
3 => {
self.asking = 0;
if let Input::Text(t) = &i {
let v = number(t).ok_or_else(|| other("Requires a distance."))?;
return self.create(s, v * self.turns());
}
return Ok(Step::Continue);
}
_ => {}
}
let num = |i: &Input, base: Option<Vec2>| -> Option<f64> {
match i {
Input::Point(p) => base.map(|b| b.dist(*p)),
Input::Text(t) => number(t),
_ => None,
}
};
match (self.center, self.rb, self.rt, &i) {
(None, _, _, Input::Point(p)) => {
self.center = Some(*p);
Ok(Step::Continue)
}
(Some(_), None | Some(_), _, Input::Keyword(k)) if k == "Diameter" => {
self.diameter = true;
Ok(Step::Continue)
}
(Some(c), None, _, _) => {
let v = match &i {
Input::Enter => Some(1.0),
_ => num(&i, Some(c)),
};
let v = v.filter(|v| *v > 0.0).ok_or_else(|| other("Requires a positive distance."))?;
if let Input::Point(p) = &i {
self.start = Some(c.angle_to(*p));
}
self.rb = Some(if self.diameter { v / 2.0 } else { v });
self.diameter = false;
Ok(Step::Continue)
}
(Some(c), Some(rb), None, _) => {
let v = match &i {
Input::Enter => Some(if self.diameter { rb * 2.0 } else { rb }),
_ => num(&i, Some(c)),
};
let v = v.filter(|v| *v >= 0.0).ok_or_else(|| other("Requires a distance."))?;
self.rt = Some(if self.diameter { v / 2.0 } else { v });
Ok(Step::Continue)
}
(Some(c), Some(_), Some(_), _) => match &i {
Input::Keyword(k) => {
self.asking = match k.as_str() {
"Turns" => 1,
"tWist" => 2,
"turn Height" => 3,
_ => 0,
};
Ok(Step::Continue)
}
Input::Enter => self.create(s, 1.0),
_ => {
let h = num(&i, Some(c)).ok_or_else(|| other("Requires a distance."))?;
self.create(s, h)
}
},
_ => Ok(Step::Continue),
}
}
fn preview(&self, _s: &Session, c: Vec2) -> Vec<EntityKind> {
match (self.center, self.rb, self.rt) {
(Some(ct), None, _) => vec![circle(ct, ct.dist(c))],
(Some(ct), Some(rb), None) => {
vec![helix_kind(ct, rb, ct.dist(c), 0.0, self.turns(), !self.cw, self.start.unwrap_or(0.0))]
}
_ => Vec::new(),
}
}
}
// =====================================================================================
// SPLINE by control vertices
// =====================================================================================
pub(super) fn run_spline_cv(s: &mut Session, p: &Value) -> Result<Value> {
let mut c = points_param(p, "control").ok_or_else(|| bad("spline.cv", "`control` points are required"))?;
if c.len() < 2 || c.len() > MAX_GEN {
return Err(bad("spline.cv", "need 2+ control points"));
}
let closed = bool_or(p, "closed", false);
if closed && let Some(f) = c.first().copied() {
c.push(f);
}
let deg = p.get("degree").and_then(Value::as_u64).unwrap_or(3).clamp(1, 10) as usize;
let mut sp = Spline::from_control(c, deg);
sp.closed = closed;
added(s.add_entity(EntityKind::Spline(sp))?)
}
#[derive(Default)]
pub(super) struct SplineCvM {
pts: Vec<Vec2>,
degree: Option<usize>,
asking_degree: bool,
}
impl Interactive for SplineCvM {
fn name(&self) -> &'static str {
"SPLINE"
}
fn begin(&mut self, s: &mut Session) -> Result<Step> {
s.echo(format!("Current settings: Method=CV Degree={}", self.degree.unwrap_or(3)));
Ok(Step::Continue)
}
fn prompt(&self, _s: &Session) -> Prompt {
if self.asking_degree {
return Prompt::new("Enter degree of spline", Accept::NUMBER).default(self.degree.unwrap_or(3).to_string());
}
match self.pts.len() {
0 => Prompt::new("Specify first point", Accept::POINT).kw(&["Method", "Degree", "Object"]),
1 => Prompt::new("Enter next point", Accept::POINT).base_opt(self.pts.last().copied()),
2 => Prompt::new("Enter next point", Accept::POINT).kw(&["Undo"]).base_opt(self.pts.last().copied()),
_ => Prompt::new("Enter next point", Accept::POINT).kw(&["Close", "Undo"]).base_opt(self.pts.last().copied()),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
if self.asking_degree {
if let Input::Text(t) = &i {
let d = number(t).filter(|d| (1.0..=10.0).contains(d)).ok_or_else(|| other("Degree must be 1..10."))?;
self.degree = Some(d as usize);
}
self.asking_degree = false;
return Ok(Step::Continue);
}
let deg = self.degree.unwrap_or(3);
match i {
Input::Point(p) => {
if self.pts.len() < MAX_GEN {
self.pts.push(p);
}
Ok(Step::Continue)
}
Input::Keyword(k) => {
match k.as_str() {
"Degree" => self.asking_degree = true,
"Undo" => {
self.pts.pop();
}
"Close" => {
let mut c = self.pts.clone();
c.extend(self.pts.first().copied());
let mut sp = Spline::from_control(c, deg);
sp.closed = true;
s.add_entity(EntityKind::Spline(sp))?;
return Ok(Step::Done);
}
_ => s.echo(format!("{k}: use SPLINE for fit points.")),
}
Ok(Step::Continue)
}
Input::Enter => {
if self.pts.len() >= 2 {
s.add_entity(EntityKind::Spline(Spline::from_control(self.pts.clone(), deg)))?;
}
Ok(Step::Done)
}
_ => Ok(Step::Continue),
}
}
fn preview(&self, _s: &Session, c: Vec2) -> Vec<EntityKind> {
if self.pts.is_empty() {
return Vec::new();
}
let mut pts = self.pts.clone();
pts.push(c);
vec![EntityKind::Spline(Spline::from_control(pts, self.degree.unwrap_or(3)))]
}
}
+184
View File
@@ -0,0 +1,184 @@
use super::{
Accept, Chain, EntityKind, Handle, Input, Interactive, MAX_GEN, Point, Prompt, Result, Session, Step, Value, Vec2, bad, bool_or, curves, f64_req,
json, number, other, point_param, str_param, targets, v3,
};
// =====================================================================================
// DIVIDE / MEASURE
// =====================================================================================
fn chain_of(s: &Session, h: Handle) -> Result<Chain> {
let e = curves::entity(s, h)?;
Chain::of(&e.kind).ok_or_else(|| other("Cannot divide/measure that object."))
}
fn place_markers(s: &mut Session, at: &[(Vec2, Vec2)], block: Option<&str>, align: bool) -> Result<Vec<String>> {
let mut out = Vec::new();
for (p, t) in at {
let h = match block {
Some(b) => crate::cmd::blocks::insert(s, b, *p, 1.0, if align { t.angle() } else { 0.0 }, &serde_json::Map::new())?,
None => s.add_entity(EntityKind::Point(Point { p: v3(*p), angle: 0.0 }))?,
};
out.push(h.hex());
}
Ok(out)
}
fn check_block(s: &Session, b: Option<&str>) -> Result<()> {
if let Some(b) = b
&& s.doc()?.block(b).is_none()
{
return Err(other(format!("Block \"{b}\" not found.")));
}
Ok(())
}
pub(crate) fn divide_points(c: &Chain, n: usize) -> Vec<(Vec2, Vec2)> {
let l = c.len();
let range = if c.closed { 0..n } else { 1..n };
range.filter_map(|i| c.at_length(l * i as f64 / n as f64)).collect()
}
pub(crate) fn measure_points(c: &Chain, step: f64) -> Vec<(Vec2, Vec2)> {
let l = c.len();
let mut out = Vec::new();
let mut k = 1usize;
while (k as f64) * step < l - 1e-9 && out.len() < MAX_GEN {
let d = k as f64 * step;
out.extend(c.at_length(d));
k += 1;
}
out
}
pub(super) fn run_divide(s: &mut Session, p: &Value) -> Result<Value> {
let h = targets(s, p)?.first().copied().ok_or_else(|| bad("divide", "`handle` is required"))?;
let n = p.get("segments").and_then(Value::as_u64).ok_or_else(|| bad("divide", "`segments` (2..32767) is required"))?;
if !(2..=32767).contains(&n) {
return Err(bad("divide", "segments must be 2..32767"));
}
let block = str_param(p, "block").map(str::to_string);
check_block(s, block.as_deref())?;
let c = chain_of(s, h)?;
let pts = divide_points(&c, n as usize);
let hs = place_markers(s, &pts, block.as_deref(), bool_or(p, "align", true))?;
Ok(json!({ "handles": hs }))
}
pub(super) fn run_measure(s: &mut Session, p: &Value) -> Result<Value> {
let h = targets(s, p)?.first().copied().ok_or_else(|| bad("measure", "`handle` is required"))?;
let step = f64_req("measure", p, "length")?;
let mut c = chain_of(s, h)?;
if step.is_nan() || step <= 0.0 || c.len() / step > MAX_GEN as f64 {
return Err(bad("measure", "length must be positive (and not tiny)"));
}
let block = str_param(p, "block").map(str::to_string);
check_block(s, block.as_deref())?;
if let Some(from) = point_param(p, "from")
&& !c.closed
&& let (Some(a), Some(b)) = (c.start(), c.end())
&& from.dist(b) < from.dist(a)
{
c = c.reversed();
}
let pts = measure_points(&c, step);
let hs = place_markers(s, &pts, block.as_deref(), bool_or(p, "align", true))?;
Ok(json!({ "handles": hs }))
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum DivPhase {
Object,
Value,
BlockName,
Align,
}
pub(super) struct DivideM {
measure: bool,
obj: Option<(Handle, Vec2)>,
phase: DivPhase,
block: Option<String>,
align: bool,
}
impl DivideM {
pub(super) fn new(measure: bool) -> Self {
DivideM { measure, obj: None, phase: DivPhase::Object, block: None, align: true }
}
}
impl Interactive for DivideM {
fn name(&self) -> &'static str {
if self.measure { "MEASURE" } else { "DIVIDE" }
}
fn prompt(&self, _s: &Session) -> Prompt {
match self.phase {
DivPhase::Object => Prompt::new(if self.measure { "Select object to measure" } else { "Select object to divide" }, Accept::POINT),
DivPhase::Value => {
let m = if self.measure { "Specify length of segment" } else { "Enter the number of segments" };
let p = Prompt::new(m, if self.measure { Accept::POINT_OR_NUMBER } else { Accept::NUMBER });
let p = if self.measure { p.base_opt(self.obj.map(|o| o.1)) } else { p };
if self.block.is_some() { p } else { p.kw(&["Block"]) }
}
DivPhase::BlockName => Prompt::new("Enter name of block to insert", Accept::TEXT),
DivPhase::Align => Prompt::new("Align block with object?", curves::KW).kw(&["Yes", "No"]).default("Y"),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
match (self.phase, i) {
(DivPhase::Object, Input::Point(p)) => {
let h = curves::pick_at(s, p).ok_or_else(|| other("*Invalid selection*"))?;
chain_of(s, h)?;
self.obj = Some((h, p));
self.phase = DivPhase::Value;
Ok(Step::Continue)
}
(DivPhase::Value, Input::Keyword(k)) if k == "Block" => {
self.phase = DivPhase::BlockName;
Ok(Step::Continue)
}
(DivPhase::BlockName, Input::Text(t) | Input::Keyword(t)) => {
let name = t.trim().to_string();
check_block(s, Some(&name))?;
self.block = Some(name);
self.phase = DivPhase::Align;
Ok(Step::Continue)
}
(DivPhase::Align, Input::Keyword(k) | Input::Text(k)) => {
self.align = !k.trim().to_ascii_lowercase().starts_with('n');
self.phase = DivPhase::Value;
Ok(Step::Continue)
}
(DivPhase::Align, Input::Enter) => {
self.phase = DivPhase::Value;
Ok(Step::Continue)
}
(DivPhase::Value, inp @ (Input::Text(_) | Input::Point(_))) => {
let Some((h, pick)) = self.obj else { return Ok(Step::Cancel) };
let v = match inp {
Input::Text(t) => number(&t).ok_or_else(|| other("Requires a number."))?,
Input::Point(q) => pick.dist(q),
_ => return Ok(Step::Continue),
};
let mut params = json!({ "handle": h.hex(), "align": self.align, "from": [pick.x, pick.y] });
if let Some(b) = &self.block {
params["block"] = json!(b);
}
if self.measure {
params["length"] = json!(v);
run_measure(s, &params)?;
} else {
if !(2.0..=32767.0).contains(&v) || v.fract() != 0.0 {
return Err(other("Requires an integer between 2 and 32767."));
}
params["segments"] = json!(v as u64);
run_divide(s, &params)?;
}
Ok(Step::Done)
}
(_, Input::Enter) => Ok(Step::Cancel),
_ => Err(other("Invalid input.")),
}
}
}
+60
View File
@@ -0,0 +1,60 @@
use super::{Ellipse, EntityKind, Line, Result, Session, TAU, Value, Vec2, added, bad, f64_req, point_param, point_req, v3};
// ---------------- ellipse entries ----------------
fn ellipse_from_axis(p1: Vec2, p2: Vec2, half: f64) -> Option<Ellipse> {
let c = p1.mid(p2);
let m = p2 - c;
let ml = m.len();
let half = half.abs();
if ml < 1e-12 || half < 1e-12 || !half.is_finite() {
return None;
}
Some(if half > ml { Ellipse::full(c, m.normalized().perp() * half, ml / half) } else { Ellipse::full(c, m, half / ml) })
}
fn axis_ellipse_params(cmd: &str, p: &Value) -> Result<Ellipse> {
let p1 = point_req(cmd, p, "p1")?;
let p2 = point_req(cmd, p, "p2")?;
let half = match point_param(p, "p3") {
Some(p3) => {
let ax = Line::new(p1, p2);
ax.project(p3).dist(p3)
}
None => f64_req(cmd, p, "distance")?,
};
ellipse_from_axis(p1, p2, half).ok_or_else(|| bad(cmd, "degenerate ellipse"))
}
fn ellipse_kind(e: &Ellipse) -> EntityKind {
EntityKind::Ellipse(cadcraft_doc::Ellipse { center: v3(e.center), major: v3(e.major), ratio: e.ratio, start: e.start, end: e.end })
}
pub(super) fn run_ellipse_axis(s: &mut Session, p: &Value) -> Result<Value> {
let e = axis_ellipse_params("ellipse.axis", p)?;
added(s.add_entity(ellipse_kind(&e))?)
}
pub(super) fn run_ellipse_arc(s: &mut Session, p: &Value) -> Result<Value> {
let mut e = if let Some(c) = point_param(p, "center") {
let m = point_req("ellipse.arc", p, "major")?;
let ratio = f64_req("ellipse.arc", p, "ratio")?;
if !(ratio > 0.0 && ratio <= 1.0) || m.len() < 1e-12 {
return Err(bad("ellipse.arc", "ratio must be in (0, 1] and major non-zero"));
}
Ellipse::full(c, m, ratio)
} else {
axis_ellipse_params("ellipse.arc", p)?
};
// Angles are measured from the major axis (like the prompts); convert to parameters.
let to_param = |deg: f64| {
let a = deg.to_radians();
let ge = Ellipse { start: 0.0, end: TAU, ..e };
ge.param_of(e.center + Vec2::from_angle(a + e.major.angle()))
};
let st = to_param(f64_req("ellipse.arc", p, "start")?);
let en = to_param(f64_req("ellipse.arc", p, "end")?);
e.start = st;
e.end = en;
added(s.add_entity(ellipse_kind(&e))?)
}
+4 -2
View File
@@ -9,9 +9,11 @@ use cadcraft_doc::{
use cadcraft_geom::{Line, Vec2, line_line_infinite};
use serde_json::{Value, json};
use super::super::machines::number;
use super::super::{distinct_points, ok};
use super::curves::{self, MAX_GEN};
use super::machines::number;
use super::*;
use super::{CommandSpec, bad, bool_or, f64_or, point_req, points_param, str_param};
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
pub fn specs() -> Vec<CommandSpec> {
+311
View File
@@ -0,0 +1,311 @@
use super::{
Accept, EntityKind, Input, Interactive, PolyVertex, Prompt, Result, Session, Step, Value, Vec2, added, bad, bool_or, curves, f64_or, lwpoly,
number, other, point_param, point_req, rect_with_corners, size_param,
};
// =====================================================================================
// RECTANG with Area / Dimensions / Rotation / Width / Elevation / Thickness
// =====================================================================================
fn rect_kind(first: Vec2, (w, h): (f64, f64), rot: f64, fillet: f64, chamfer: (f64, f64), width: f64, elevation: f64) -> EntityKind {
let local = rect_with_corners(Vec2::ZERO, Vec2::new(w, h), fillet, chamfer);
let vs = local.into_iter().map(|v| PolyVertex { p: first + v.p.rotate(rot), ..v }).collect();
let mut k = lwpoly(vs, true);
if let EntityKind::LwPolyline(pl) = &mut k {
pl.const_width = width.max(0.0);
pl.elevation = elevation;
}
k
}
/// The width and height of a rectangle from its `dimensions`, its `p2` corner, its `area` with
/// `length` or `breadth`, or its second corner `p2`, rotated by `rot` about `a`.
fn rect_size(p: &Value, a: Vec2, rot: f64, centered: bool) -> Result<(f64, f64)> {
Ok(if let Some(d) = p.get("dimensions").and_then(Value::as_array) {
let l = d.first().and_then(Value::as_f64).filter(|v| v.is_finite()).ok_or_else(|| bad("rectang", "dimensions: [length, width]"))?;
let wd = d.get(1).and_then(Value::as_f64).filter(|v| v.is_finite()).ok_or_else(|| bad("rectang", "dimensions: [length, width]"))?;
let (sx, sy) = match point_param(p, "p2") {
Some(q) => {
let loc = (q - a).rotate(-rot);
(if loc.x < 0.0 { -1.0 } else { 1.0 }, if loc.y < 0.0 { -1.0 } else { 1.0 })
}
None => (1.0, 1.0),
};
(l.abs() * sx, wd.abs() * sy)
} else if let Some(area) = p.get("area").and_then(Value::as_f64).filter(|v| v.is_finite()) {
if area <= 0.0 {
return Err(bad("rectang", "area must be positive"));
}
match (p.get("length").and_then(Value::as_f64), p.get("breadth").and_then(Value::as_f64)) {
(Some(l), _) if l.is_finite() && l > 0.0 => (l, area / l),
(_, Some(b)) if b.is_finite() && b > 0.0 => (area / b, b),
_ => return Err(bad("rectang", "area needs `length` or `breadth`")),
}
} else {
let b = point_req("rectang", p, "p2")?;
let loc = (b - a).rotate(-rot);
// In centre mode the picked point is one corner, mirrored across the centre.
if centered { (2.0 * loc.x, 2.0 * loc.y) } else { (loc.x, loc.y) }
})
}
/// Draws a rectangle from the JSON form: `p1` is a corner, or the centre when `center` is
/// true, and the size comes from `p2`, `dimensions` or `area` plus `length`/`breadth`.
pub(crate) fn run_rectang2(s: &mut Session, p: &Value) -> Result<Value> {
let a = point_req("rectang", p, "p1")?;
// Centre mode: `p1` is the rectangle's centre, so the rectangle spans it evenly.
let centered = bool_or(p, "center", false);
let rot = f64_or(p, "rotation", 0.0).to_radians();
let ch = p
.get("chamfer")
.and_then(Value::as_array)
.map(|c| (c.first().and_then(Value::as_f64).unwrap_or(0.0), c.get(1).and_then(Value::as_f64).unwrap_or(0.0)))
.map(|(x, y)| (if x.is_finite() { x } else { 0.0 }, if y.is_finite() { y } else { 0.0 }))
.unwrap_or((0.0, 0.0));
if ch.0 < 0.0 || ch.1 < 0.0 {
return Err(bad("rectang", "`chamfer` distances must be zero or positive"));
}
let fillet = size_param("rectang", p, "fillet", true)?.unwrap_or(0.0);
let width = size_param("rectang", p, "width", true)?.unwrap_or(0.0);
let (w, h) = rect_size(p, a, rot, centered)?;
if w.abs() < 1e-12 || h.abs() < 1e-12 || !w.is_finite() || !h.is_finite() {
return Err(bad("rectang", "rectangle has no area"));
}
let first = if centered { a - Vec2::new(w / 2.0, h / 2.0).rotate(rot) } else { a };
let k = rect_kind(first, (w, h), rot, fillet, ch, width, f64_or(p, "elevation", 0.0));
let hd = s.add_entity(k)?;
// A rectangle has its own thickness option and does not take THICKNESS.
let th = f64_or(p, "thickness", 0.0);
s.doc_mut()?.modify_entity(hd, |e| e.common.thickness = th)?;
added(hd)
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum RAsk {
Chamfer1,
Chamfer2,
Fillet,
Width,
Elevation,
Thickness,
Area,
AreaBasis,
AreaLength,
AreaWidth,
DimL,
DimW,
Rotation,
RotPick1,
RotPick2(Vec2),
}
#[derive(Default)]
pub(crate) struct RectM2 {
first: Option<Vec2>,
/// The first point is the rectangle's centre (the `Center` option), not a corner.
center: bool,
fillet: f64,
chamfer: (f64, f64),
width: f64,
elevation: f64,
thickness: f64,
rotation: f64,
dims: Option<(f64, f64)>,
area: f64,
asking: Option<RAsk>,
}
impl RectM2 {
/// Adds the rectangle of full size `w` × `h` with the collected options, drawing it from
/// the centre when centre mode is on, then applies the requested thickness.
fn make(&self, s: &mut Session, w: f64, h: f64) -> Result<Step> {
let Some(a) = self.first else { return Ok(Step::Cancel) };
if w.abs() < 1e-12 || h.abs() < 1e-12 {
return Err(other("The rectangle has no area."));
}
let first = self.origin(w, h).unwrap_or(a);
let hd = s.add_entity(rect_kind(first, (w, h), self.rotation, self.fillet, self.chamfer, self.width, self.elevation))?;
// A rectangle has its own thickness option and does not take THICKNESS.
let t = self.thickness;
s.doc_mut()?.modify_entity(hd, |e| e.common.thickness = t)?;
Ok(Step::Done)
}
/// The rectangle's local origin (the corner `rect_kind` draws from) for a size: the first
/// point itself as a corner, or the first point less half the size around the centre.
fn origin(&self, w: f64, h: f64) -> Option<Vec2> {
let a = self.first?;
Some(if self.center { a - Vec2::new(w / 2.0, h / 2.0).rotate(self.rotation) } else { a })
}
/// The rectangle's signed full size implied by the picked point `p`: explicit `dims` only
/// take their quadrant from `p`, otherwise `p` is a corner, doubled about the centre in
/// centre mode.
fn other_corner(&self, p: Vec2) -> Option<(f64, f64)> {
let a = self.first?;
let loc = (p - a).rotate(-self.rotation);
Some(match self.dims {
Some((l, w)) => (if loc.x < 0.0 { -l } else { l }, if loc.y < 0.0 { -w } else { w }),
// In centre mode the picked point is one corner, mirrored across the centre.
None if self.center => (2.0 * loc.x, 2.0 * loc.y),
None => (loc.x, loc.y),
})
}
}
impl Interactive for RectM2 {
fn name(&self) -> &'static str {
"RECTANG"
}
/// Asks for the pending option's value, or for the first and second points; in centre mode
/// the first prompt asks for the centre instead of a corner.
fn prompt(&self, _s: &Session) -> Prompt {
let num = |m: &str, d: f64| Prompt::new(m, Accept::NUMBER).default(format!("{d:.4}"));
if let Some(a) = self.asking {
return match a {
RAsk::Chamfer1 => num("Specify first chamfer distance for rectangles", self.chamfer.0),
RAsk::Chamfer2 => num("Specify second chamfer distance for rectangles", self.chamfer.1),
RAsk::Fillet => num("Specify fillet radius for rectangles", self.fillet),
RAsk::Width => num("Specify line width for rectangles", self.width),
RAsk::Elevation => num("Specify the elevation for rectangles", self.elevation),
RAsk::Thickness => num("Specify thickness for rectangles", self.thickness),
RAsk::Area => num("Enter area of rectangle in current units", if self.area > 0.0 { self.area } else { 100.0 }),
RAsk::AreaBasis => Prompt::new("Calculate rectangle dimensions based on", curves::KW).kw(&["Length", "Width"]).default("Length"),
RAsk::AreaLength => num("Enter rectangle length", 10.0),
RAsk::AreaWidth => num("Enter rectangle width", 10.0),
RAsk::DimL => num("Specify length for rectangles", self.dims.map(|d| d.0).unwrap_or(10.0)),
RAsk::DimW => num("Specify width for rectangles", self.dims.map(|d| d.1).unwrap_or(10.0)),
RAsk::Rotation => Prompt::new("Specify rotation angle or", Accept::POINT_OR_NUMBER)
.kw(&["Pick points"])
.default(format!("{:.0}", self.rotation.to_degrees()))
.base_opt(self.first),
RAsk::RotPick1 => Prompt::new("Specify first point", Accept::POINT),
RAsk::RotPick2(b) => Prompt::new("Specify second point", Accept::POINT).base(b),
};
}
match self.first {
None => {
let msg = if self.center { "Specify center point for rectangle" } else { "Specify first corner point" };
Prompt::new(msg, Accept::POINT).kw(&["Chamfer", "CEnter", "Elevation", "Fillet", "Thickness", "Width"])
}
Some(p) => Prompt::new("Specify other corner point", Accept::POINT).kw(&["Area", "Dimensions", "Rotation"]).corner(p),
}
}
/// Consumes one input: option keywords (`Center` only switches the first point's meaning
/// to the centre and asks nothing), option values, and the points that size the rectangle.
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
if let Some(a) = self.asking {
let val = |i: &Input, default: f64| -> Result<f64> {
match i {
Input::Text(t) => number(t).ok_or_else(|| other("Requires a number.")),
Input::Enter => Ok(default),
_ => Err(other("Requires a number.")),
}
};
self.asking = None;
match a {
RAsk::Chamfer1 => {
self.chamfer.0 = val(&i, self.chamfer.0)?.max(0.0);
self.chamfer.1 = self.chamfer.0;
self.asking = Some(RAsk::Chamfer2);
}
RAsk::Chamfer2 => self.chamfer.1 = val(&i, self.chamfer.1)?.max(0.0),
RAsk::Fillet => self.fillet = val(&i, self.fillet)?.max(0.0),
RAsk::Width => self.width = val(&i, self.width)?.max(0.0),
RAsk::Elevation => self.elevation = val(&i, self.elevation)?,
RAsk::Thickness => self.thickness = val(&i, self.thickness)?,
RAsk::Area => {
let v = val(&i, if self.area > 0.0 { self.area } else { 100.0 })?;
if v <= 0.0 {
self.asking = Some(RAsk::Area);
return Err(other("Value must be positive."));
}
self.area = v;
self.asking = Some(RAsk::AreaBasis);
}
RAsk::AreaBasis => {
let w = matches!(&i, Input::Keyword(k) | Input::Text(k) if k.to_ascii_lowercase().starts_with('w'));
self.asking = Some(if w { RAsk::AreaWidth } else { RAsk::AreaLength });
}
RAsk::AreaLength | RAsk::AreaWidth => {
let v = val(&i, 10.0)?;
if v <= 0.0 {
self.asking = Some(a);
return Err(other("Value must be positive."));
}
let (w, h) = if a == RAsk::AreaLength { (v, self.area / v) } else { (self.area / v, v) };
return self.make(s, w, h);
}
RAsk::DimL => {
let l = val(&i, self.dims.map(|d| d.0).unwrap_or(10.0))?.abs();
self.dims = Some((l, self.dims.map(|d| d.1).unwrap_or(10.0)));
self.asking = Some(RAsk::DimW);
}
RAsk::DimW => {
let w = val(&i, self.dims.map(|d| d.1).unwrap_or(10.0))?.abs();
self.dims = Some((self.dims.map(|d| d.0).unwrap_or(10.0), w));
}
RAsk::Rotation => match i {
Input::Keyword(_) => self.asking = Some(RAsk::RotPick1),
Input::Point(p) => self.rotation = self.first.map(|f| f.angle_to(p)).unwrap_or(0.0),
Input::Text(t) => self.rotation = s.angle_settings().direction(&t).ok_or_else(|| other("Requires a valid angle."))?,
_ => {}
},
RAsk::RotPick1 => {
if let Input::Point(p) = i {
self.asking = Some(RAsk::RotPick2(p));
}
}
RAsk::RotPick2(b) => {
if let Input::Point(p) = i {
self.rotation = b.angle_to(p);
}
}
}
return Ok(Step::Continue);
}
match i {
Input::Keyword(k) => {
// `Center` only switches the first point's meaning; it asks nothing.
if k == "CEnter" {
self.center = true;
return Ok(Step::Continue);
}
self.asking = Some(match k.as_str() {
"Chamfer" => RAsk::Chamfer1,
"Elevation" => RAsk::Elevation,
"Fillet" => RAsk::Fillet,
"Thickness" => RAsk::Thickness,
"Width" => RAsk::Width,
"Area" => RAsk::Area,
"Dimensions" => RAsk::DimL,
_ => RAsk::Rotation,
});
Ok(Step::Continue)
}
Input::Point(p) => match self.first {
None => {
self.first = Some(p);
Ok(Step::Continue)
}
Some(_) => {
let (w, h) = self.other_corner(p).ok_or_else(|| other("No first corner."))?;
self.make(s, w, h)
}
},
Input::Enter => Ok(Step::Cancel),
_ => Ok(Step::Continue),
}
}
/// The rubber-band rectangle for the cursor at `c`, mapped through the same size-and-origin
/// rules as the committed entity so centre mode previews where it will land.
fn preview(&self, _s: &Session, c: Vec2) -> Vec<EntityKind> {
if self.asking.is_some() {
return Vec::new();
}
match self.other_corner(c).and_then(|(w, h)| self.origin(w, h).map(|a| (a, w, h))) {
Some((a, w, h)) if w.abs() > 1e-12 && h.abs() > 1e-12 => {
vec![rect_kind(a, (w, h), self.rotation, self.fillet, self.chamfer, self.width, self.elevation)]
}
_ => Vec::new(),
}
}
}
+276
View File
@@ -0,0 +1,276 @@
use super::{
Accept, Chain, Circle, Ellipse, EntityKind, Handle, Input, Interactive, Line, MAX_GEN, PolyVertex, Polyline, Prompt, Result, Session, Step,
Value, Vec2, added, bad, bool_or, curves, lwpoly, number, other, point_param, points_param, rect_vertices, shoelace, str_param,
};
// =====================================================================================
// REVCLOUD
// =====================================================================================
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum CloudKind {
Rect,
Poly,
Free,
}
fn default_arc_len(s: &Session) -> f64 {
let d = s.doc().ok();
let metric = d.is_some_and(|d| d.header.i64("MEASUREMENT", 0) == 1);
d.map(|d| d.header.f64("REVCLOUDARCLEN", if metric { 15.0 } else { 0.5 })).unwrap_or(0.5).max(1e-6)
}
/// Bulged closed polyline vertices for a revision cloud around `boundary`.
pub(crate) fn cloud_vertices(boundary: &[Vec2], arc_len: f64, per_edge: bool, reverse: bool) -> Option<Vec<PolyVertex>> {
let mut pts: Vec<Vec2> = Vec::new();
for p in boundary {
if p.is_finite() && pts.last().is_none_or(|l| !l.near(*p, 1e-9)) {
pts.push(*p);
}
}
while pts.len() > 1 && pts.first().zip(pts.last()).is_some_and(|(a, b)| a.near(*b, 1e-9)) {
pts.pop();
}
if pts.len() < 3 || shoelace(&pts).abs() < 1e-12 {
return None;
}
if shoelace(&pts) < 0.0 {
pts.reverse();
}
let n = pts.len();
let perim: f64 = (0..n).filter_map(|i| Some(pts.get(i)?.dist(*pts.get((i + 1) % n)?))).sum();
if !(arc_len.is_finite() && arc_len > 0.0) {
return None;
}
let arc_len = arc_len.max(perim / MAX_GEN as f64);
let mut out: Vec<Vec2> = Vec::new();
if per_edge {
for i in 0..n {
let (Some(a), Some(b)) = (pts.get(i).copied(), pts.get((i + 1) % n).copied()) else { continue };
let k = ((a.dist(b) / arc_len).round() as usize).clamp(1, MAX_GEN);
for j in 0..k {
out.push(a.lerp(b, j as f64 / k as f64));
}
}
} else {
let chain = Chain {
segs: (0..n).filter_map(|i| Some(cadcraft_geom::Segment::Line(Line::new(*pts.get(i)?, *pts.get((i + 1) % n)?)))).collect(),
closed: true,
};
let k = ((perim / arc_len).round() as usize).clamp(3, MAX_GEN);
for j in 0..k {
out.extend(chain.at_length(perim * j as f64 / k as f64).map(|x| x.0));
}
}
let b = if reverse { -0.5 } else { 0.5 };
Some(out.into_iter().map(|p| PolyVertex::with_bulge(p, b)).collect())
}
fn object_outline(s: &Session, h: Handle) -> Result<Vec<Vec2>> {
let e = curves::entity(s, h)?;
let tol = 1e-3;
let pts = match &e.kind {
EntityKind::LwPolyline(p) if p.closed => Polyline { vertices: p.vertices.clone(), closed: true }.tessellate(tol),
EntityKind::Circle(c) => {
let mut v = Vec::new();
Circle::new(c.center.xy(), c.radius).tessellate(c.radius * 1e-3, &mut v);
v
}
EntityKind::Ellipse(el) => {
let mut v = Vec::new();
Ellipse { center: el.center.xy(), major: el.major.xy(), ratio: el.ratio, start: el.start, end: el.end }
.tessellate(el.major.xy().len() * 1e-3, &mut v);
v
}
EntityKind::Spline(sp) if sp.closed => sp.tessellate(tol),
_ => return Err(other("Select a closed polyline, circle, ellipse or closed spline.")),
};
Ok(pts)
}
fn cloud_entity(vs: Vec<PolyVertex>, calligraphy: bool, arc_len: f64) -> EntityKind {
let vs = if calligraphy { vs.into_iter().map(|v| PolyVertex { start_width: 0.0, end_width: arc_len * 0.15, ..v }).collect() } else { vs };
lwpoly(vs, true)
}
pub(super) fn run_revcloud(s: &mut Session, p: &Value) -> Result<Value> {
let arc_len = p.get("arcLength").and_then(Value::as_f64).filter(|v| v.is_finite() && *v > 0.0).unwrap_or_else(|| default_arc_len(s));
let reverse = bool_or(p, "reverse", false);
let calligraphy = str_param(p, "style").is_some_and(|x| x.eq_ignore_ascii_case("calligraphy"));
let (pts, per_edge, src) = if let (Some(a), Some(b)) = (point_param(p, "p1"), point_param(p, "p2")) {
(rect_vertices(a, b).into_iter().map(|v| v.p).collect(), true, None)
} else if let Some(pts) = points_param(p, "points") {
(pts, true, None)
} else if let Some(pts) = points_param(p, "freehand") {
(pts, false, None)
} else if let Some(h) = curves::handle_param(p, "handle") {
(object_outline(s, h)?, false, Some(h))
} else {
return Err(bad("revcloud", "give {p1, p2}, {points}, {freehand} or {handle}"));
};
if pts.len() > MAX_GEN {
return Err(bad("revcloud", "too many points"));
}
let vs = cloud_vertices(&pts, arc_len, per_edge, reverse).ok_or_else(|| bad("revcloud", "the boundary needs an area"))?;
let h = s.add_entity(cloud_entity(vs, calligraphy, arc_len))?;
if let Some(src) = src {
let common = curves::entity(s, src)?.common;
let d = s.doc_mut()?;
d.modify_entity(h, |e| e.common = common)?;
d.remove_entity(src);
}
added(h)
}
pub(super) struct RevcloudM {
kind: CloudKind,
pts: Vec<Vec2>,
arc_len: f64,
asking_arc: bool,
object: bool,
calligraphy: bool,
asking_style: bool,
}
impl RevcloudM {
pub(super) fn new(s: &Session, kind: CloudKind) -> Self {
RevcloudM { kind, pts: Vec::new(), arc_len: default_arc_len(s), asking_arc: false, object: false, calligraphy: false, asking_style: false }
}
fn boundary(&self, extra: Option<Vec2>) -> Vec<Vec2> {
let mut pts = self.pts.clone();
pts.extend(extra);
if self.kind == CloudKind::Rect
&& let (Some(a), Some(b)) = (pts.first().copied(), pts.get(1).copied())
{
return rect_vertices(a, b).into_iter().map(|v| v.p).collect();
}
pts
}
fn finish(&mut self, s: &mut Session) -> Result<Step> {
let b = self.boundary(None);
match cloud_vertices(&b, self.arc_len, self.kind != CloudKind::Free, false) {
Some(vs) => {
s.add_entity(cloud_entity(vs, self.calligraphy, self.arc_len))?;
s.echo("Revision cloud finished.");
Ok(Step::Done)
}
None => Err(other("The revision cloud needs at least three points enclosing an area.")),
}
}
}
impl Interactive for RevcloudM {
fn name(&self) -> &'static str {
"REVCLOUD"
}
fn begin(&mut self, s: &mut Session) -> Result<Step> {
let t = match self.kind {
CloudKind::Rect => "Rectangular",
CloudKind::Poly => "Polygonal",
CloudKind::Free => "Freehand",
};
s.echo(format!("Minimum arc length: {0:.4} Maximum arc length: {0:.4} Style: Normal Type: {t}", self.arc_len));
Ok(Step::Continue)
}
fn prompt(&self, _s: &Session) -> Prompt {
if self.asking_arc {
return Prompt::new("Specify minimum length of arc", Accept::NUMBER).default(format!("{:.4}", self.arc_len));
}
if self.asking_style {
return Prompt::new("Select arc style", curves::KW).kw(&["Normal", "Calligraphy"]).default("Normal");
}
if self.object {
return Prompt::new("Select object", Accept::POINT);
}
let first_kw: &[&str] = &["Arc length", "Object", "Rectangular", "Polygonal", "Freehand", "Style"];
match (self.kind, self.pts.len()) {
(CloudKind::Rect, 0) => Prompt::new("Specify first corner point", Accept::POINT).kw(first_kw).default("Object"),
(CloudKind::Rect, _) => {
Prompt { corner: true, ..Prompt::new("Specify opposite corner", Accept::POINT).base_opt(self.pts.first().copied()) }
}
(CloudKind::Poly, 0) => Prompt::new("Specify start point", Accept::POINT).kw(first_kw).default("Object"),
(CloudKind::Poly, 1) => Prompt::new("Specify next point", Accept::POINT).base_opt(self.pts.last().copied()),
(CloudKind::Poly, _) => Prompt::new("Specify next point", Accept::POINT).kw(&["Undo"]).base_opt(self.pts.last().copied()),
(CloudKind::Free, 0) => Prompt::new("Specify first point", Accept::POINT).kw(first_kw).default("Object"),
(CloudKind::Free, _) => Prompt::new("Guide crosshairs along cloud path", Accept::POINT).base_opt(self.pts.last().copied()),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
if self.asking_arc {
if let Input::Text(t) = &i {
let v = number(t).filter(|v| *v > 0.0).ok_or_else(|| other("Requires a positive value."))?;
self.arc_len = v;
s.doc_mut()?.header.set_f64("REVCLOUDARCLEN", v);
}
self.asking_arc = false;
return Ok(Step::Continue);
}
if self.asking_style {
if let Input::Keyword(k) | Input::Text(k) = &i {
self.calligraphy = k.to_ascii_lowercase().starts_with('c');
}
self.asking_style = false;
return Ok(Step::Continue);
}
if self.object {
let Input::Point(p) = i else { return if i == Input::Enter { Ok(Step::Cancel) } else { Ok(Step::Continue) } };
let h = curves::pick_at(s, p).ok_or_else(|| other("*Invalid selection*"))?;
let pts = object_outline(s, h)?;
let vs = cloud_vertices(&pts, self.arc_len, false, false).ok_or_else(|| other("The object has no area."))?;
let nh = s.add_entity(cloud_entity(vs, self.calligraphy, self.arc_len))?;
let common = curves::entity(s, h)?.common;
let d = s.doc_mut()?;
d.modify_entity(nh, |e| e.common = common)?;
d.remove_entity(h);
s.echo("Revision cloud finished.");
return Ok(Step::Done);
}
match i {
Input::Keyword(k) => {
match k.as_str() {
"Arc length" => self.asking_arc = true,
"Object" => self.object = true,
"Rectangular" => self.kind = CloudKind::Rect,
"Polygonal" => self.kind = CloudKind::Poly,
"Freehand" => self.kind = CloudKind::Free,
"Style" => self.asking_style = true,
"Undo" => {
self.pts.pop();
}
_ => {}
}
Ok(Step::Continue)
}
Input::Point(p) => {
self.pts.push(p);
if self.kind == CloudKind::Rect && self.pts.len() == 2 {
return self.finish(s);
}
if self.pts.len() > MAX_GEN {
return self.finish(s);
}
Ok(Step::Continue)
}
Input::Enter if self.pts.is_empty() => {
self.object = true;
Ok(Step::Continue)
}
Input::Enter => self.finish(s),
_ => Ok(Step::Continue),
}
}
fn preview(&self, _s: &Session, c: Vec2) -> Vec<EntityKind> {
if self.pts.is_empty() || self.object {
return Vec::new();
}
let b = self.boundary(Some(c));
match cloud_vertices(&b, self.arc_len, self.kind != CloudKind::Free, false) {
Some(vs) if vs.len() < 2000 => vec![lwpoly(vs, true)],
_ => {
let mut pts = self.pts.clone();
pts.push(c);
vec![lwpoly(pts.into_iter().map(PolyVertex::new).collect(), false)]
}
}
}
}
+140
View File
@@ -0,0 +1,140 @@
use super::{
Accept, Circle, Handle, Input, Interactive, Prompt, Result, Session, Step, TanObj, Value, Vec2, added, bad, circle, curves, f64_req, number,
other, point_param, point_req,
};
pub(super) fn run_circle_variant(s: &mut Session, p: &Value) -> Result<Value> {
let c = if let (Some(c), Some(d)) = (point_param(p, "center"), p.get("diameter").and_then(Value::as_f64)) {
Circle::new(c, d / 2.0)
} else if let (Some(a), Some(b)) = (point_param(p, "p1"), point_param(p, "p2")) {
match point_param(p, "p3") {
Some(c3) => Circle::from_3_points(a, b, c3).ok_or_else(|| bad("circle", "points are collinear"))?,
None => Circle::from_2_points(a, b),
}
} else {
return Err(bad("circle", "give {center, diameter}, {p1, p2} or {p1, p2, p3}"));
};
if !(c.radius > 1e-12 && c.radius.is_finite()) {
return Err(bad("circle", "radius must be positive"));
}
let h = s.add_entity(circle(c.center, c.radius))?;
s.doc_mut()?.header.set_f64("CIRCLERAD", c.radius);
added(h)
}
// ---------------- tangent circles ----------------
fn tan_obj(s: &Session, h: Handle, pick: Vec2) -> Result<TanObj> {
let e = curves::entity(s, h)?;
TanObj::from_kind(&e.kind, pick).ok_or_else(|| other("Select a line, circle, arc or polyline segment."))
}
fn pick_pair(cmd: &str, p: &Value, hk: &str, pk: &str) -> Result<(Handle, Vec2)> {
let h = curves::handle_param(p, hk).ok_or_else(|| bad(cmd, format!("`{hk}` is required")))?;
let q = point_req(cmd, p, pk)?;
Ok((h, q))
}
pub(super) fn run_circle_ttr(s: &mut Session, p: &Value) -> Result<Value> {
let (h1, p1) = pick_pair("circle.ttr", p, "h1", "p1")?;
let (h2, p2) = pick_pair("circle.ttr", p, "h2", "p2")?;
let r = f64_req("circle.ttr", p, "radius")?;
let c = curves::ttr(tan_obj(s, h1, p1)?, p1, tan_obj(s, h2, p2)?, p2, r).ok_or_else(|| other("Circle does not exist."))?;
let h = s.add_entity(circle(c.center, c.radius))?;
s.doc_mut()?.header.set_f64("CIRCLERAD", c.radius);
added(h)
}
pub(super) fn run_circle_ttt(s: &mut Session, p: &Value) -> Result<Value> {
let (h1, p1) = pick_pair("circle.ttt", p, "h1", "p1")?;
let (h2, p2) = pick_pair("circle.ttt", p, "h2", "p2")?;
let (h3, p3) = pick_pair("circle.ttt", p, "h3", "p3")?;
let objs = [(tan_obj(s, h1, p1)?, p1), (tan_obj(s, h2, p2)?, p2), (tan_obj(s, h3, p3)?, p3)];
let c = curves::ttt(objs).ok_or_else(|| other("Circle does not exist."))?;
added(s.add_entity(circle(c.center, c.radius))?)
}
/// TTR (two picks + radius) or TTT (three picks). Also used by CIRCLE's `Ttr` option.
pub(crate) struct TanCircleM {
radius_mode: bool,
picks: Vec<(TanObj, Vec2)>,
}
impl TanCircleM {
pub(crate) fn new(radius_mode: bool) -> Self {
TanCircleM { radius_mode, picks: Vec::new() }
}
}
impl Interactive for TanCircleM {
fn name(&self) -> &'static str {
"CIRCLE"
}
fn prompt(&self, s: &Session) -> Prompt {
let n = self.picks.len();
if self.radius_mode {
match n {
0 => Prompt::new("Specify point on object for first tangent of circle", Accept::POINT),
1 => Prompt::new("Specify point on object for second tangent of circle", Accept::POINT),
_ => {
let r = s.doc().map(|d| d.header.f64("CIRCLERAD", 0.0)).unwrap_or(0.0);
let p = Prompt::new("Specify radius of circle", Accept::POINT_OR_NUMBER).base_opt(self.picks.last().map(|x| x.1)).distance();
if r > 0.0 { p.default(format!("{r:.4}")) } else { p }
}
}
} else {
let m = ["Specify first point on circle: _tan to", "Specify second point on circle: _tan to", "Specify third point on circle: _tan to"];
Prompt::new(m.get(n).copied().unwrap_or(""), Accept::POINT)
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
let need = if self.radius_mode { 2 } else { 3 };
if self.picks.len() < need {
return match i {
Input::Point(p) => {
let h = curves::pick_at(s, p).ok_or_else(|| other("*Invalid selection*"))?;
let o = tan_obj(s, h, p)?;
self.picks.push((o, p));
if !self.radius_mode && self.picks.len() == 3 {
let (Some(a), Some(b), Some(c)) = (self.picks.first(), self.picks.get(1), self.picks.get(2)) else {
return Ok(Step::Cancel);
};
let c = curves::ttt([*a, *b, *c]).ok_or_else(|| {
self.picks.clear();
other("Circle does not exist.")
})?;
s.add_entity(circle(c.center, c.radius))?;
return Ok(Step::Done);
}
Ok(Step::Continue)
}
Input::Enter => Ok(Step::Cancel),
_ => Err(other("Select an object.")),
};
}
let r = match i {
Input::Point(p) => self.picks.last().map(|x| x.1.dist(p)).unwrap_or(0.0),
Input::Text(t) => number(&t).ok_or_else(|| other("Requires numeric distance or second point."))?,
Input::Enter => s.doc()?.header.f64("CIRCLERAD", 0.0),
_ => return Ok(Step::Continue),
};
if r.is_nan() || r <= 0.0 {
return Err(other("Value must be positive and nonzero."));
}
let ((o1, p1), (o2, p2)) = match (self.picks.first(), self.picks.get(1)) {
(Some(a), Some(b)) => (*a, *b),
_ => return Ok(Step::Cancel),
};
match curves::ttr(o1, p1, o2, p2, r) {
Some(c) => {
s.add_entity(circle(c.center, c.radius))?;
s.doc_mut()?.header.set_f64("CIRCLERAD", r);
Ok(Step::Done)
}
None => {
s.echo("Circle does not exist.");
Ok(Step::Done)
}
}
}
}
+140
View File
@@ -0,0 +1,140 @@
use super::{
Accept, EntityKind, Handle, Input, Interactive, MAX_GEN, PolyVertex, Prompt, Result, Session, Step, Value, Vec2, Wipeout, added, bad, bool_or,
curves, json, lwpoly, other, points_param, shoelace, str_param,
};
// =====================================================================================
// WIPEOUT
// =====================================================================================
fn set_frames(s: &mut Session, mode: &str) -> Result<i64> {
let v = match mode.trim().to_ascii_lowercase().as_str() {
"on" | "1" => 1,
"off" | "0" => 0,
"display" | "d" | "2" | "display but not plot" => 2,
_ => return Err(other("Frames mode must be ON, OFF or Display.")),
};
s.doc_mut()?.header.set_i64("WIPEOUTFRAME", v);
Ok(v)
}
fn wipeout_from_polyline(s: &mut Session, h: Handle, erase: bool) -> Result<Handle> {
let e = curves::entity(s, h)?;
let EntityKind::LwPolyline(p) = &e.kind else { return Err(other("Select a closed polyline.")) };
if !p.closed || p.vertices.len() < 3 {
return Err(other("The polyline must be closed with at least three vertices."));
}
if p.vertices.iter().any(|v| v.bulge.abs() > 1e-12) {
return Err(other("Polylines with arc segments cannot be used for wipeouts."));
}
let nh = s.add_entity(EntityKind::Wipeout(Wipeout { boundary: p.vertices.iter().map(|v| v.p).collect() }))?;
if erase {
s.doc_mut()?.remove_entity(h);
}
Ok(nh)
}
pub(super) fn run_wipeout(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(f) = str_param(p, "frames") {
let v = set_frames(s, f)?;
return Ok(json!({ "WIPEOUTFRAME": v }));
}
if let Some(h) = curves::handle_param(p, "handle") {
return added(wipeout_from_polyline(s, h, bool_or(p, "erase", false))?);
}
let pts = points_param(p, "points").ok_or_else(|| bad("wipeout", "`points` (3 or more) or `handle` is required"))?;
if pts.len() < 3 || pts.len() > MAX_GEN || shoelace(&pts).abs() < 1e-12 {
return Err(bad("wipeout", "need 3+ points enclosing an area"));
}
added(s.add_entity(EntityKind::Wipeout(Wipeout { boundary: pts }))?)
}
#[derive(Default)]
pub(super) struct WipeoutM {
pts: Vec<Vec2>,
phase: u8, // 0 points, 1 select polyline, 2 erase?, 3 frames
poly: Option<Handle>,
}
impl Interactive for WipeoutM {
fn name(&self) -> &'static str {
"WIPEOUT"
}
fn prompt(&self, _s: &Session) -> Prompt {
match (self.phase, self.pts.len()) {
(1, _) => Prompt::new("Select a closed polyline", Accept::POINT),
(2, _) => Prompt::new("Erase polyline?", curves::KW).kw(&["Yes", "No"]).default("No"),
(3, _) => Prompt::new("Enter mode", curves::KW).kw(&["ON", "OFF", "Display but not plot"]).default("ON"),
(_, 0) => Prompt::new("Specify first point", Accept::POINT).kw(&["Frames", "Polyline"]).default("Polyline"),
(_, 1) => Prompt::new("Specify next point", Accept::POINT).base_opt(self.pts.last().copied()),
(_, _) => Prompt::new("Specify next point", Accept::POINT).kw(&["Undo"]).base_opt(self.pts.last().copied()),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
match (self.phase, i) {
(1, Input::Point(p)) => {
let h = curves::pick_at(s, p).ok_or_else(|| other("*Invalid selection*"))?;
let e = curves::entity(s, h)?;
if !matches!(&e.kind, EntityKind::LwPolyline(pl) if pl.closed) {
return Err(other("Select a closed polyline."));
}
self.poly = Some(h);
self.phase = 2;
Ok(Step::Continue)
}
(2, inp @ (Input::Keyword(_) | Input::Text(_) | Input::Enter)) => {
let erase = matches!(&inp, Input::Keyword(k) | Input::Text(k) if k.to_ascii_lowercase().starts_with('y'));
let h = self.poly.ok_or_else(|| other("No polyline."))?;
wipeout_from_polyline(s, h, erase)?;
Ok(Step::Done)
}
(3, inp @ (Input::Keyword(_) | Input::Text(_) | Input::Enter)) => {
let m = match &inp {
Input::Keyword(k) | Input::Text(k) => k.clone(),
_ => "on".into(),
};
let m = if m.to_ascii_lowercase().starts_with('d') { "display".to_string() } else { m };
set_frames(s, &m)?;
Ok(Step::Done)
}
(0, Input::Keyword(k)) => {
match k.as_str() {
"Frames" => self.phase = 3,
"Polyline" => self.phase = 1,
"Undo" => {
self.pts.pop();
}
_ => {}
}
Ok(Step::Continue)
}
(0, Input::Point(p)) => {
if self.pts.len() < MAX_GEN {
self.pts.push(p);
}
Ok(Step::Continue)
}
(0, Input::Enter) if self.pts.is_empty() => {
self.phase = 1;
Ok(Step::Continue)
}
(0, Input::Enter) => {
if self.pts.len() < 3 || shoelace(&self.pts).abs() < 1e-12 {
return Err(other("A wipeout needs three or more points enclosing an area."));
}
s.add_entity(EntityKind::Wipeout(Wipeout { boundary: self.pts.clone() }))?;
Ok(Step::Done)
}
(_, Input::Enter) => Ok(Step::Cancel),
_ => Ok(Step::Continue),
}
}
fn preview(&self, _s: &Session, c: Vec2) -> Vec<EntityKind> {
if self.phase != 0 || self.pts.is_empty() {
return Vec::new();
}
let mut pts = self.pts.clone();
pts.push(c);
vec![lwpoly(pts.into_iter().map(PolyVertex::new).collect(), true)]
}
}
+263
View File
@@ -0,0 +1,263 @@
use super::{
Accept, EntityKind, Handle, Input, Interactive, Polyline, Prompt, RayLine, Result, Session, Step, Value, Vec2, added, bad, bool_or, curves,
f64_req, number, other, point_param, point_req, v3,
};
// =====================================================================================
// XLINE with Hor/Ver/Ang/Bisect/Offset
// =====================================================================================
fn add_xline(s: &mut Session, base: Vec2, dir: Vec2) -> Result<Handle> {
let d = dir.normalized();
if d == Vec2::ZERO || !base.is_finite() {
return Err(other("Invalid direction."));
}
s.add_entity(EntityKind::XLine(RayLine { base: v3(base), dir: v3(d) }))
}
/// A line-like object for XLINE Offset/Reference: (base, direction). For a polyline, the
/// segment nearest `pick` (the first without one), which must be straight.
fn linear_of(s: &Session, h: Handle, pick: Option<Vec2>) -> Result<(Vec2, Vec2)> {
let e = curves::entity(s, h)?;
match &e.kind {
EntityKind::Line(l) => Ok((l.a.xy(), (l.b.xy() - l.a.xy()).normalized())),
EntityKind::XLine(r) | EntityKind::Ray(r) => Ok((r.base.xy(), r.dir.xy().normalized())),
EntityKind::LwPolyline(p) => {
let segs = Polyline { vertices: p.vertices.clone(), closed: p.closed }.segments();
let seg = match pick {
Some(q) => segs.iter().min_by(|a, b| a.closest(q).dist(q).total_cmp(&b.closest(q).dist(q))),
None => segs.first(),
};
match seg {
Some(cadcraft_geom::Segment::Line(l)) => Ok((l.a, l.dir())),
_ => Err(other("Select a line object.")),
}
}
_ => Err(other("Select a line object.")),
}
}
fn bisect_dir(v: Vec2, a: Vec2, b: Vec2) -> Option<Vec2> {
let u1 = (a - v).normalized();
let u2 = (b - v).normalized();
if u1 == Vec2::ZERO || u2 == Vec2::ZERO {
return None;
}
let s = u1 + u2;
if s.len() < 1e-12 { Some(u1.perp()) } else { Some(s.normalized()) }
}
fn offset_xline(base: Vec2, dir: Vec2, dist: f64, side: Vec2) -> (Vec2, Vec2) {
let n = dir.perp();
let sgn = if n.dot(side - base) >= 0.0 { 1.0 } else { -1.0 };
(base + n * (dist * sgn), dir)
}
pub(crate) fn run_xline2(s: &mut Session, p: &Value) -> Result<Value> {
if let (Some(v), Some(a), Some(b)) = (point_param(p, "vertex"), point_param(p, "start"), point_param(p, "end")) {
let d = bisect_dir(v, a, b).ok_or_else(|| bad("xline", "degenerate angle"))?;
return added(add_xline(s, v, d)?);
}
if let Some(h) = curves::handle_param(p, "handle") {
let (base, dir) = linear_of(s, h, None)?;
let (nb, nd) = if let Some(t) = point_param(p, "through") {
(t, dir)
} else {
let dist = f64_req("xline", p, "distance")?;
let side = point_req("xline", p, "side")?;
offset_xline(base, dir, dist, side)
};
return added(add_xline(s, nb, nd)?);
}
let b = point_param(p, "base").or_else(|| point_param(p, "through")).ok_or_else(|| bad("xline", "`base` is required"))?;
let dir = if bool_or(p, "hor", false) {
Vec2::X
} else if bool_or(p, "ver", false) {
Vec2::Y
} else if let (Some(t), Some(_)) = (point_param(p, "through"), point_param(p, "base")) {
t - b
} else {
Vec2::from_angle(f64_req("xline", p, "angle")?.to_radians())
};
if dir.len() < 1e-12 {
return Err(bad("xline", "through point equals base"));
}
added(add_xline(s, b, dir)?)
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
enum XMode {
#[default]
Two,
Fixed(f64),
AngAsk,
RefSelect,
RefAngle(f64),
Bisect,
OffDist,
OffSelect,
OffSide,
}
#[derive(Default)]
pub(crate) struct XlineM2 {
mode: XMode,
base: Option<Vec2>,
bis: Vec<Vec2>,
offset: Option<f64>, // None = Through
line: Option<(Vec2, Vec2)>,
}
impl Interactive for XlineM2 {
fn name(&self) -> &'static str {
"XLINE"
}
fn prompt(&self, s: &Session) -> Prompt {
match self.mode {
XMode::Two => match self.base {
None => Prompt::new("Specify a point", Accept::POINT).kw(&["Hor", "Ver", "Ang", "Bisect", "Offset"]),
Some(b) => Prompt::new("Specify through point", Accept::POINT).base(b),
},
XMode::Fixed(_) => Prompt::new("Specify through point", Accept::POINT),
XMode::AngAsk => Prompt::new("Enter angle of xline (0) or", Accept::NUMBER).kw(&["Reference"]),
XMode::RefSelect => Prompt::new("Select a line object", Accept::POINT),
XMode::RefAngle(_) => Prompt::new("Enter angle of xline", Accept::NUMBER).default("0"),
XMode::Bisect => match self.bis.len() {
0 => Prompt::new("Specify angle vertex point", Accept::POINT),
1 => Prompt::new("Specify angle start point", Accept::POINT).base_opt(self.bis.first().copied()),
_ => Prompt::new("Specify angle end point", Accept::POINT).base_opt(self.bis.first().copied()),
},
XMode::OffDist => {
let d = s.doc().map(|d| d.header.f64("OFFSETDIST", 1.0)).unwrap_or(1.0);
Prompt::new("Specify offset distance or", Accept::NUMBER).kw(&["Through"]).default(if d < 0.0 {
"Through".into()
} else {
format!("{d:.4}")
})
}
XMode::OffSelect => Prompt::new("Select a line object", Accept::POINT),
XMode::OffSide => Prompt::new(if self.offset.is_some() { "Specify side to offset" } else { "Specify through point" }, Accept::POINT),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
match (self.mode, i) {
(_, Input::Enter) if matches!(self.mode, XMode::OffDist) => {
let d = s.doc()?.header.f64("OFFSETDIST", 1.0);
self.offset = (d >= 0.0).then_some(d);
self.mode = XMode::OffSelect;
Ok(Step::Continue)
}
(_, Input::Enter) => Ok(Step::Done),
(XMode::Two, Input::Keyword(k)) => {
self.mode = match k.as_str() {
"Hor" => XMode::Fixed(0.0),
"Ver" => XMode::Fixed(std::f64::consts::FRAC_PI_2),
"Ang" => XMode::AngAsk,
"Bisect" => XMode::Bisect,
_ => XMode::OffDist,
};
Ok(Step::Continue)
}
(XMode::Two, Input::Point(p)) => {
match self.base {
None => self.base = Some(p),
Some(b) => {
if !b.near(p, 1e-12) {
add_xline(s, b, p - b)?;
}
}
}
Ok(Step::Continue)
}
(XMode::Fixed(a), Input::Point(p)) => {
add_xline(s, p, Vec2::from_angle(a))?;
Ok(Step::Continue)
}
(XMode::AngAsk, Input::Keyword(_)) => {
self.mode = XMode::RefSelect;
Ok(Step::Continue)
}
(XMode::AngAsk, Input::Text(t)) => {
let a = s.angle_settings().direction(&t).ok_or_else(|| other("Requires a valid angle."))?;
self.mode = XMode::Fixed(a);
Ok(Step::Continue)
}
(XMode::RefSelect, Input::Point(p)) => {
let h = curves::pick_at(s, p).ok_or_else(|| other("*Invalid selection*"))?;
let (_, d) = linear_of(s, h, Some(p))?;
self.mode = XMode::RefAngle(d.angle());
Ok(Step::Continue)
}
(XMode::RefAngle(base), Input::Text(t)) => {
let a = s.angle_settings().rotation(&t).ok_or_else(|| other("Requires a valid angle."))?;
self.mode = XMode::Fixed(base + a);
Ok(Step::Continue)
}
(XMode::Bisect, Input::Point(p)) => {
if self.bis.len() < 2 {
self.bis.push(p);
} else if let (Some(v), Some(a)) = (self.bis.first().copied(), self.bis.get(1).copied()) {
let d = bisect_dir(v, a, p).ok_or_else(|| other("Invalid angle."))?;
add_xline(s, v, d)?;
}
Ok(Step::Continue)
}
(XMode::OffDist, Input::Keyword(_)) => {
self.offset = None;
s.doc_mut()?.header.set_f64("OFFSETDIST", -1.0);
self.mode = XMode::OffSelect;
Ok(Step::Continue)
}
(XMode::OffDist, Input::Text(t)) => {
let d = number(&t).filter(|d| *d >= 0.0).ok_or_else(|| other("Requires a non-negative distance."))?;
self.offset = Some(d);
s.doc_mut()?.header.set_f64("OFFSETDIST", d);
self.mode = XMode::OffSelect;
Ok(Step::Continue)
}
(XMode::OffSelect, Input::Point(p)) => {
let h = curves::pick_at(s, p).ok_or_else(|| other("*Invalid selection*"))?;
self.line = Some(linear_of(s, h, Some(p))?);
self.mode = XMode::OffSide;
Ok(Step::Continue)
}
(XMode::OffSide, Input::Point(p)) => {
let (b, d) = self.line.ok_or_else(|| other("No line selected."))?;
let (nb, nd) = match self.offset {
Some(dist) => offset_xline(b, d, dist, p),
None => (p, d),
};
add_xline(s, nb, nd)?;
self.mode = XMode::OffSelect;
Ok(Step::Continue)
}
_ => Err(other("Invalid input.")),
}
}
fn preview(&self, _s: &Session, c: Vec2) -> Vec<EntityKind> {
let xl = |b: Vec2, d: Vec2| {
let d = d.normalized();
(d != Vec2::ZERO).then(|| EntityKind::XLine(RayLine { base: v3(b), dir: v3(d) }))
};
match self.mode {
XMode::Two => self.base.and_then(|b| xl(b, c - b)).into_iter().collect(),
XMode::Fixed(a) => xl(c, Vec2::from_angle(a)).into_iter().collect(),
XMode::Bisect => match self.bis.as_slice() {
[v, a] => bisect_dir(*v, *a, c).and_then(|d| xl(*v, d)).into_iter().collect(),
_ => Vec::new(),
},
XMode::OffSide => self
.line
.and_then(|(b, d)| {
let (nb, nd) = match self.offset {
Some(dist) => offset_xline(b, d, dist, c),
None => (c, d),
};
xl(nb, nd)
})
.into_iter()
.collect(),
_ => Vec::new(),
}
}
}
+2 -1
View File
@@ -8,7 +8,8 @@ use cadcraft_doc::{EntityKind, Handle};
use serde_json::{Value, json};
use super::machines::{SelOutcome, SelectPhase, SelectRun};
use super::*;
use super::{CommandSpec, EngineError, bad, ok, targets};
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
pub fn specs() -> Vec<CommandSpec> {
+2 -1
View File
@@ -5,7 +5,8 @@ use cadcraft_geom::{Bounds2, Mat3, Vec2};
use serde_json::{Value, json};
use super::machines::{SelOutcome, SelectPhase, SelectRun};
use super::*;
use super::{CommandSpec, EngineError, bad, bool_or, clipboard, f64_or, has_selection, point_param, point_req, points_param, str_param, targets};
use crate::undoctl::Control;
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
+5 -1
View File
@@ -18,7 +18,8 @@ use cadcraft_render::raster::{RasterOptions, View};
use serde_json::{Value, json};
use super::file::{base64_decode, base64_encode, io};
use super::*;
use super::{CommandSpec, bad, point_value, str_param};
use crate::{EngineError, Result, Session};
/// Formats EXPORT writes (the io layer picks the writer from the extension).
@@ -557,7 +558,10 @@ mod tests {
use cadcraft_geom::Vec3;
use super::super::file::{IoHooks, set_io};
use super::super::resolve_alias;
use super::*;
use serde_json::json;
static DELIVERED: std::sync::Mutex<Vec<(String, String, usize)>> = std::sync::Mutex::new(Vec::new());

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