110 Commits
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 fc4e98315a Release 0.7.0
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 09:34:27 -04:00
Brandon ThomasandClaude Opus 5.5 e707ab4d7c cargo fmt after the #903 merge
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 06:58:50 -04:00
8cd0d46d58 Zoom and pan regenerate in the background instead of freezing the window (#903)
* Zoom and pan regenerate in the background instead of freezing the window

A rebuild that only the view asks for (a new 2x zoom band, a pan out of the
built area) now runs on a worker thread while the canvas keeps drawing the
previous display list, scaled by the view uniform. The worker builds the list,
converts it to vertex data and creates the GPU buffers, so the frame that swaps
it in only swaps buffers. One build runs at a time; a result from an older
drawing revision is dropped. Edits, the first build and the web build (no
threads) still rebuild synchronously.

ZOOM no longer calls touch(): it changes the view, not the drawing, and the
bumped revision made every typed zoom a full blocking rebuild (wheel zoom
never did). REGEN, REGENALL and layout switches still touch.

ui.screenshot waits (up to 6 s) for a background rebuild so agents capture
the new view; ui.inspect perf reports "building".

On a hatched 12k-entity DWG (~340k primitives) the longest UI stall per zoom
went from 2-4.5 s to 26-64 ms.

Also: cargo fmt of a comment in engine/src/prompt.rs that was failing the fmt
gate on main.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Cancel a background rebuild when the view moves on

A view that needs a different display list now cancels the build in flight
and starts one for itself, instead of waiting for the outdated build to land
and only then starting the next. A long scroll through many zoom bands builds
only for where it stops: the list lands about one build after the last step
(2.0-2.3 s on a hatched 12k-entity DWG) instead of up to two, and an outdated
build no longer keeps a core busy.

cadcraft_render::Options gains `cancel` (an Arc<AtomicBool>); a set flag stops
the build at the next entity (block contents included) and the hatch sweep at
its next pattern line, returning a partial list the caller discards. The
canvas's Job sets its flag when dropped, so replacing or dropping a job
cancels it; a cancelled worker sends nothing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* cargo fmt: crates/doc/src/dynblock.rs (fails the fmt gate on main)

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 06:58:16 -04:00
1e075f76c5 Arabic (العربية) UI translation: language switching + font fallback (#798)
* Show Thai file names and text in the interface with an installed Thai font

A drawing whose file name is Thai showed boxes in its file tab and in the
window title: the interface loaded one Latin system font (plus a CJK font for
the Chinese interface), and neither has Thai.

install_fonts now also adds an installed Thai font (Thonburi, Leelawadee UI,
Noto Sans Thai, Loma...) after the Latin one, in any interface language, since
file names and drawing text are Thai whatever the interface is in. No font is
bundled; without one installed nothing changes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Add Korean interface localization and Hangul font support

* Add Arabic (العربية) interface translation

A full ar.tsv catalog for the CAD interface — every menu label, tool,
palette, dialog and status message in the repo's three-column TSV
format, written from the plain meaning of the English with the
Arabic AutoCAD vocabulary its users know (أمر، إزاحة، تدوير، قص،
استدارة، طبقة…), with the six CLDR plural forms for the counts.
Registers ar in LANGUAGES with plural_arabic and the font chain
(Noto Sans Arabic first on Linux, Segoe UI/Tahoma on Windows, Geeza
Pro on macOS) so the UI families paint Arabic.

The shell itself still lays out left-to-right: egui 0.36 has no
global direction switch and no bidi, so mirroring is left for a
later pass; this PR is the catalog, the registry entry and the
fonts.

* i18n: start the Swedish (sv) interface catalog

Header only, not registered yet: the catalog is filled and the language registered in this
branch, translated from the meaning of CADCraft's English strings and reviewed by a native
Swedish speaker.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* i18n: add Swedish (sv) interface translation

A complete 1,235-entry Swedish catalog: every key of the Spanish catalog, Run Script... and the
four menu headings (Visa, Rita, Måttsättning, Ändra). Written from the meaning of CADCraft's
English strings; no other product's localisation and no other catalog was used. AI-assisted; the
terminology and the running app were reviewed by a native speaker. Command-line keywords, system
variables and osnap tokens stay English.

- Preference::Swedish (sv); sv-SE, sv-FI and sv_SE.UTF-8 select it; one|other plurals (the
  English rule).
- sv in REPORTED; tests for locales, saved preference and plurals, menu headings, a live switch,
  dialogs and the Swedish letters in the bundled fonts. No gaps are reported for Swedish.
- ATTRIBUTION, README, docs/localization.md, docs/localization-parity.md,
  docs/control-protocol.md, the options doc comment and a contributors/people.toml entry for
  @holmbom.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Chanthawat Kiriyadee <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: huk082 <[email protected]>
Co-authored-by: hasan-sdaif <[email protected]>
Co-authored-by: Erik Holmbom <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 06:54:40 -04:00
7fc29b056e i18n: add Swedish (sv) interface translation (#882)
* Show Thai file names and text in the interface with an installed Thai font

A drawing whose file name is Thai showed boxes in its file tab and in the
window title: the interface loaded one Latin system font (plus a CJK font for
the Chinese interface), and neither has Thai.

install_fonts now also adds an installed Thai font (Thonburi, Leelawadee UI,
Noto Sans Thai, Loma...) after the Latin one, in any interface language, since
file names and drawing text are Thai whatever the interface is in. No font is
bundled; without one installed nothing changes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Add Korean interface localization and Hangul font support

* i18n: start the Swedish (sv) interface catalog

Header only, not registered yet: the catalog is filled and the language registered in this
branch, translated from the meaning of CADCraft's English strings and reviewed by a native
Swedish speaker.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* i18n: add Swedish (sv) interface translation

A complete 1,235-entry Swedish catalog: every key of the Spanish catalog, Run Script... and the
four menu headings (Visa, Rita, Måttsättning, Ändra). Written from the meaning of CADCraft's
English strings; no other product's localisation and no other catalog was used. AI-assisted; the
terminology and the running app were reviewed by a native speaker. Command-line keywords, system
variables and osnap tokens stay English.

- Preference::Swedish (sv); sv-SE, sv-FI and sv_SE.UTF-8 select it; one|other plurals (the
  English rule).
- sv in REPORTED; tests for locales, saved preference and plurals, menu headings, a live switch,
  dialogs and the Swedish letters in the bundled fonts. No gaps are reported for Swedish.
- ATTRIBUTION, README, docs/localization.md, docs/localization-parity.md,
  docs/control-protocol.md, the options doc comment and a contributors/people.toml entry for
  @holmbom.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Chanthawat Kiriyadee <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: huk082 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 06:53:31 -04:00
aed2ae9615 Add Korean interface localization and Hangul font support (#857)
* Show Thai file names and text in the interface with an installed Thai font

A drawing whose file name is Thai showed boxes in its file tab and in the
window title: the interface loaded one Latin system font (plus a CJK font for
the Chinese interface), and neither has Thai.

install_fonts now also adds an installed Thai font (Thonburi, Leelawadee UI,
Noto Sans Thai, Loma...) after the Latin one, in any interface language, since
file names and drawing text are Thai whatever the interface is in. No font is
bundled; without one installed nothing changes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Add Korean interface localization and Hangul font support

---------

Co-authored-by: Chanthawat Kiriyadee <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 06:52:26 -04:00
033ee15e01 Fix double-clicking layout viewports creating persistent selection rectangle (#830)
Double-clicking a viewport to enter MSPACE or sheet to enter PSPACE triggers a primary click on the first click that starts an idle selection window (pending_window). When the second click triggers dbl_primary, the handler executed an early return, leaving pending_window open and rendering an unwanted crossing/window rectangle across the screen. Reset pending_window and drag_window on double clicks and suppress primary click dispatch.

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 06:16:24 -04:00
Brandon ThomasandClaude Opus 5.5 4a9dd31a58 Context menu test: a block reference offers both the Block Editor and REFEDIT (#818, #824)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 06:13:49 -04:00
26179f7428 Draw object transparency on screen, in exports and in plots (#764)
* Draw object transparency on screen, in exports and in plots

Transparency was read and saved (entity group 440, layer xdata, ByLayer and
ByBlock) but never drawn. Display lists now carry each object's effective
transparency (its own, its layer's, or the block reference's), and:

- the canvas (GPU and CPU paths), ui.render and PNG/SVG exports blend it;
  TRANSPARENCYDISPLAY turns it off on screen and is now on by default, as in
  AutoCAD (initial value 1);
- plots draw it only when "Plot transparency" is on: a page setup option
  (PAGESETUP `plotTransparency`, a checkbox in the Page Setup Manager and the
  Plot dialog), overridable per plot, with PLOTTRANSPARENCYOVERRIDE (0 never,
  1 the setting, 2 always). PDF gets /ExtGState alpha, PNG plots blend, SVG
  uses opacity. Plots without it are byte-identical to before.

The public DXF Reference has no plot layout flag for plot transparency, so
the option is kept in the session and in JSON, not in DXF/DWG.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Transparency fixes: attributes, PDF overlaps, drag previews

- Attributes draw with their own transparency (their definition's, layer 0
  meaning the reference's layer, ByBlock the reference's) instead of the
  block reference's.
- PDF plots stroke overlapping transparent objects one by one, so they build
  up where they cross as in PNG plots; opaque output is unchanged.
- Drag and rubber-band previews follow TRANSPARENCYDISPLAY.
- Effective transparency is worked out only when it is drawn, reusing the
  layer the property lookup already found.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Transparency: one PDF stroke per transparent primitive; opaque rebuilt previews

- PDF plots never batch two transparent primitives into one path. Block and
  viewport contents share their reference's handle, so the per-object flush
  missed them; each primitive now blends on its own, as in PNG plots and on
  screen. Opaque output is unchanged.
- Previews the session rebuilds with the current properties (rubber bands,
  copies of objects) are drawn opaque again: they carry CLAYER's
  transparency, not the objects'. The GPU drag of the objects themselves
  keeps following TRANSPARENCYDISPLAY.
- Tests for attributes on a transparent reference layer, attributes without a
  definition, and the drag cache across the TRANSPARENCYDISPLAY toggle.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Selection like AutoCAD: colour highlight and the Options Selection tab

Selecting or hovering an object outlined each filled primitive (TrueType
glyphs, dimension and leader arrowheads, solid hatches, wide polylines)
by the rectangle around its triangles, and dashed everything else. A
selected dimension's text became a row of dashed boxes, one per glyph,
and its closed arrowheads small rectangles.

Selected objects are now redrawn in the selection colour: lines solid,
fills filled half see-through (so the lines and text on a highlighted
hatch stay visible), and the whole object reads as selected, text and
arrowheads included. A wipeout is always outlined, since filling it would
hide the drawing under it.

The Options dialog's Selection tab is laid out as AutoCAD's, with the
settings CADCraft can act on, each one AutoCAD's variable, kept in the
user profile:

- Selection modes: noun/verb selection (PICKFIRST), Shift to add
  (PICKADD), Object grouping (PICKSTYLE bit 1, now saved in the profile).
- Selection effect color (selectionColor, an interface preference; null
  is the theme's).
- Grips: Show grips (GRIPS) and the object selection limit for display of
  grips (GRIPOBJLIMIT, 100; 0 no limit): grips are neither drawn nor
  picked beyond it.
- Selection preview (SELECTIONPREVIEW): the object under the cursor is
  highlighted when a command is active (2) and when none is (1).
- Visual Effect Settings: the selection area effect (SELECTIONAREA,
  WINDOWAREACOLOR 150, CROSSINGAREACOLOR 100, SELECTIONAREAOPACITY 25)
  and the Selection Preview Filter (PREVIEWFILTER 3: 1 objects on locked
  layers, 2 xrefs, 4 tables, 8 multiline text, 16 hatches, 32 groups).

As in AutoCAD these are typed, not on the dialog: HIGHLIGHT (0 shows only
the grips of selected objects; not saved, so every session starts
highlighted), SELECTIONEFFECT (1 the selection colour, 0 dashed lines with
fills outlined along their real boundary) and PREVIEWEFFECT (the preview
dashed 0, thicker 1 or both 2).

Left for later, as CADCraft lacks what they control: lasso, grip tips and
menus, group grips, grips within blocks, associative hatch selection,
command and property preview, PICKDRAG, PICKAUTO and PROPOBJLIMIT.

DisplayList::fill_outlines returns the boundary of the region a filled
primitive covers as closed polylines (edges only one triangle has; the
slab filler's horizontal cuts, which need not line up between trapezoids,
are split at every x on their line and kept where an odd number cover
them), falling back to the bounding box over 600,000 vertices. The GPU
and CPU highlight paths and the drawing comparison overlay share the same
looks.

The new labels have Ukrainian rows (the required catalog); other
languages fall back to English.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: EP995LAB <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Cody Vogt <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 06:12:48 -04:00
261d46adcf Selection like AutoCAD: colour highlight and the Options Selection tab (#808)
Selecting or hovering an object outlined each filled primitive (TrueType
glyphs, dimension and leader arrowheads, solid hatches, wide polylines)
by the rectangle around its triangles, and dashed everything else. A
selected dimension's text became a row of dashed boxes, one per glyph,
and its closed arrowheads small rectangles.

Selected objects are now redrawn in the selection colour: lines solid,
fills filled half see-through (so the lines and text on a highlighted
hatch stay visible), and the whole object reads as selected, text and
arrowheads included. A wipeout is always outlined, since filling it would
hide the drawing under it.

The Options dialog's Selection tab is laid out as AutoCAD's, with the
settings CADCraft can act on, each one AutoCAD's variable, kept in the
user profile:

- Selection modes: noun/verb selection (PICKFIRST), Shift to add
  (PICKADD), Object grouping (PICKSTYLE bit 1, now saved in the profile).
- Selection effect color (selectionColor, an interface preference; null
  is the theme's).
- Grips: Show grips (GRIPS) and the object selection limit for display of
  grips (GRIPOBJLIMIT, 100; 0 no limit): grips are neither drawn nor
  picked beyond it.
- Selection preview (SELECTIONPREVIEW): the object under the cursor is
  highlighted when a command is active (2) and when none is (1).
- Visual Effect Settings: the selection area effect (SELECTIONAREA,
  WINDOWAREACOLOR 150, CROSSINGAREACOLOR 100, SELECTIONAREAOPACITY 25)
  and the Selection Preview Filter (PREVIEWFILTER 3: 1 objects on locked
  layers, 2 xrefs, 4 tables, 8 multiline text, 16 hatches, 32 groups).

As in AutoCAD these are typed, not on the dialog: HIGHLIGHT (0 shows only
the grips of selected objects; not saved, so every session starts
highlighted), SELECTIONEFFECT (1 the selection colour, 0 dashed lines with
fills outlined along their real boundary) and PREVIEWEFFECT (the preview
dashed 0, thicker 1 or both 2).

Left for later, as CADCraft lacks what they control: lasso, grip tips and
menus, group grips, grips within blocks, associative hatch selection,
command and property preview, PICKDRAG, PICKAUTO and PROPOBJLIMIT.

DisplayList::fill_outlines returns the boundary of the region a filled
primitive covers as closed polylines (edges only one triangle has; the
slab filler's horizontal cuts, which need not line up between trapezoids,
are split at every x on their line and kept where an odd number cover
them), falling back to the bounding box over 600,000 vertices. The GPU
and CPU highlight paths and the drawing comparison overlay share the same
looks.

The new labels have Ukrainian rows (the required catalog); other
languages fall back to English.

Co-authored-by: Cody Vogt <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 06:09:39 -04:00
e74b276dfd In-place MTEXT editor with formatting toolbar and indent/tab ruler (#532) (#792)
The MTEXT text entry opens a text box on the canvas at the text's location and
width instead of a dialog: font, height, bold, italic, underline, overline,
colour, paragraph alignment, lists, tab and special characters, plus a ruler for
the paragraph's indents and tab stops. Text, per-character formats and paragraph
properties live in engine::mtext_edit (parse and write MTEXT codes, opaque codes
kept verbatim), tested headlessly.

Co-authored-by: Claude Sonnet 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 06:07:55 -04:00
d826450e03 Block Editor on the current drawing; a drawing with authoring elements offers it (stacked on #833) (#885)
* Block Editor: BEDIT, BSAVE, BSAVEAS and BCLOSE for static blocks

BEDIT opens a block definition in the drawing's own tab with its base point
at the origin, and the block's objects are edited with the ordinary commands.
BSAVE writes them back into the definition, so every reference in the drawing
updates (one undo step in the drawing); BSAVEAS saves under a new name;
BCLOSE goes back to the drawing, asking whether to save unsaved changes.

- The drawing waits untouched while the editor is open (blockedit.rs): its
  undo history, selection and views come back on BCLOSE. Objects left
  unchanged are saved back exactly, so no coordinate drifts.
- Files save the drawing outside the editor; autosave keeps the editor's
  unsaved changes in the block; the close/quit prompt's Save puts them into
  the block first.
- Right-click a selected block reference: Block Editor. In the editor the
  canvas is tinted, the base point is marked, and a bar offers Save Block,
  Save Block As and Close Block Editor (also in the right-click menu). The
  Block tool set's Block Editor button is now live. BLOCKEDITOR sysvar.
- Layout and paper-space commands are refused while the editor is open; a
  block can't be saved to contain a reference to itself.
- Dynamic blocks: their data is kept; the editor says it can't edit their
  parameters yet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Edit Block Definition dialog; BEDIT can start a new block

BEDIT typed or started from a menu or the Tool Sets with no block reference
selected opens the Edit Block Definition dialog: the drawing's blocks sorted
by name, a preview and the description. OK opens the Block Editor on the
block named; a name no block has yet starts a new block, which BSAVE creates
(`bedit {name, create: true}`). A selected block reference still opens at
once, and JSON calls never open the dialog.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Edit Block Definition: centred window, list box; gaps.md F3

The dialog opens centred on the window, the blocks are a full-width list
in a list box of fixed height, and gaps.md F3 mentions the dialog.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* BEDIT: find dynamic block data on the block's BLOCK_RECORD

A dynamic block's ACAD_ENHANCEDBLOCK dictionary hangs off its BLOCK_RECORD
table entry, not the BLOCK entity, so the "parameters are kept" note never
showed for AutoCAD files. Regression test with the data laid out as an
AutoCAD DXF has it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* BEDIT: a dynamic block reference opens its dynamic block

A dynamic block reference with values of its own shows an anonymous block
(`*U12`), so the right-click Block Editor (and BEDIT on a picked or selected
reference) refused it and asked for a name. The anonymous block's record
names its dynamic block in AcDbBlockRepBTag xdata; BEDIT now opens that, as
AutoCAD does. Regression test.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Block Editor on the current drawing; opening a drawing with authoring elements offers it

A dynamic block's own drawing (model space with authoring elements: its
block record's ACAD_ENHANCEDBLOCK) opened from the UI asks, as AutoCAD does,
"<file> contains authoring elements. Open in Block Editor?" Yes / No /
Cancel. Yes opens the Block Editor on <Current Drawing>: the drawing's own
objects, edited where they are; BSAVE keeps them, BCLOSE without saving puts
the drawing back. <Current Drawing> heads the Edit Block Definition list.
BSAVEAS is not available there.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Cody Vogt <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 06:02:48 -04:00
Brandon ThomasandClaude Opus 5.5 abe352564e docs: drop stale duplicate rows merges brought back (dynamic block data, foreign xdata, hatch)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 06:01:02 -04:00
Brandon ThomasandClaude Opus 5.5 d80920cc25 docs: drop stale F1, F2 and F13 rows a merge brought back
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 06:00:42 -04:00
43972c82e8 Edit Block Definition dialog; BEDIT can start a new block (stacked on #824) (#833)
* Block Editor: BEDIT, BSAVE, BSAVEAS and BCLOSE for static blocks

BEDIT opens a block definition in the drawing's own tab with its base point
at the origin, and the block's objects are edited with the ordinary commands.
BSAVE writes them back into the definition, so every reference in the drawing
updates (one undo step in the drawing); BSAVEAS saves under a new name;
BCLOSE goes back to the drawing, asking whether to save unsaved changes.

- The drawing waits untouched while the editor is open (blockedit.rs): its
  undo history, selection and views come back on BCLOSE. Objects left
  unchanged are saved back exactly, so no coordinate drifts.
- Files save the drawing outside the editor; autosave keeps the editor's
  unsaved changes in the block; the close/quit prompt's Save puts them into
  the block first.
- Right-click a selected block reference: Block Editor. In the editor the
  canvas is tinted, the base point is marked, and a bar offers Save Block,
  Save Block As and Close Block Editor (also in the right-click menu). The
  Block tool set's Block Editor button is now live. BLOCKEDITOR sysvar.
- Layout and paper-space commands are refused while the editor is open; a
  block can't be saved to contain a reference to itself.
- Dynamic blocks: their data is kept; the editor says it can't edit their
  parameters yet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Edit Block Definition dialog; BEDIT can start a new block

BEDIT typed or started from a menu or the Tool Sets with no block reference
selected opens the Edit Block Definition dialog: the drawing's blocks sorted
by name, a preview and the description. OK opens the Block Editor on the
block named; a name no block has yet starts a new block, which BSAVE creates
(`bedit {name, create: true}`). A selected block reference still opens at
once, and JSON calls never open the dialog.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Edit Block Definition: centred window, list box; gaps.md F3

The dialog opens centred on the window, the blocks are a full-width list
in a list box of fixed height, and gaps.md F3 mentions the dialog.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* BEDIT: find dynamic block data on the block's BLOCK_RECORD

A dynamic block's ACAD_ENHANCEDBLOCK dictionary hangs off its BLOCK_RECORD
table entry, not the BLOCK entity, so the "parameters are kept" note never
showed for AutoCAD files. Regression test with the data laid out as an
AutoCAD DXF has it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* BEDIT: a dynamic block reference opens its dynamic block

A dynamic block reference with values of its own shows an anonymous block
(`*U12`), so the right-click Block Editor (and BEDIT on a picked or selected
reference) refused it and asked for a name. The anonymous block's record
names its dynamic block in AcDbBlockRepBTag xdata; BEDIT now opens that, as
AutoCAD does. Regression test.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Cody Vogt <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 06:00:15 -04:00
767576ec9e Block Editor: BEDIT, BSAVE, BSAVEAS and BCLOSE for static blocks (#824)
* Block Editor: BEDIT, BSAVE, BSAVEAS and BCLOSE for static blocks

BEDIT opens a block definition in the drawing's own tab with its base point
at the origin, and the block's objects are edited with the ordinary commands.
BSAVE writes them back into the definition, so every reference in the drawing
updates (one undo step in the drawing); BSAVEAS saves under a new name;
BCLOSE goes back to the drawing, asking whether to save unsaved changes.

- The drawing waits untouched while the editor is open (blockedit.rs): its
  undo history, selection and views come back on BCLOSE. Objects left
  unchanged are saved back exactly, so no coordinate drifts.
- Files save the drawing outside the editor; autosave keeps the editor's
  unsaved changes in the block; the close/quit prompt's Save puts them into
  the block first.
- Right-click a selected block reference: Block Editor. In the editor the
  canvas is tinted, the base point is marked, and a bar offers Save Block,
  Save Block As and Close Block Editor (also in the right-click menu). The
  Block tool set's Block Editor button is now live. BLOCKEDITOR sysvar.
- Layout and paper-space commands are refused while the editor is open; a
  block can't be saved to contain a reference to itself.
- Dynamic blocks: their data is kept; the editor says it can't edit their
  parameters yet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* BEDIT: find dynamic block data on the block's BLOCK_RECORD

A dynamic block's ACAD_ENHANCEDBLOCK dictionary hangs off its BLOCK_RECORD
table entry, not the BLOCK entity, so the "parameters are kept" note never
showed for AutoCAD files. Regression test with the data laid out as an
AutoCAD DXF has it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* BEDIT: a dynamic block reference opens its dynamic block

A dynamic block reference with values of its own shows an anonymous block
(`*U12`), so the right-click Block Editor (and BEDIT on a picked or selected
reference) refused it and asked for a name. The anonymous block's record
names its dynamic block in AcDbBlockRepBTag xdata; BEDIT now opens that, as
AutoCAD does. Regression test.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Cody Vogt <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 05:58:12 -04:00
62fb5ae79a Attributes keep Verify, Preset and Lock position (stacked on #854) (#876)
* Multiline attributes: read, draw and save their text

A multiline attribute (DXF attribute type 2, or 4 for a definition) has its
text as embedded MTEXT after its own groups (`101 Embedded Object`); its own
group 1 is empty. CADCraft read only the own groups, so the attribute drew
nothing, and a save wrote it back empty: its text was lost.

- `Attrib::multiline` keeps the multiline text; `text.value` holds its plain
  text, which ATTEDIT and the other value editors change; an edited value
  replaces the contents (`Attrib::multiline_text`).
- DXF read: ATTRIB and ATTDEF split at the marker; the MTEXT parsing is
  shared with MTEXT entities (`mtext_from`).
- Drawn as multiline text; ZOOM Extents counts it.
- DXF write (2007 and later; earlier versions get one line): attribute type,
  alignment point and the embedded MTEXT, only what differs from unrotated
  text, as AutoCAD writes it.
- INSERT and ATTSYNC give references the definition's multiline text placed
  with them; MOVE/ROTATE/SCALE, Properties rotation and scale, annotative
  scaling and FLATTEN carry it.
- Tests: synthetic multiline ATTDEF and ATTRIB (read, drawn, saved, edited,
  pre-2007 save, damaged embedded groups); engine INSERT, MOVE, ATTEDIT and
  ATTSYNC.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Multiline attributes: read MTEXT direction from its own groups (#667, as #834 does)

#834 moves these lines' fix into the MTEXT reader; the same fix in the shared
mtext_from keeps the two compatible.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Attributes keep Verify, Preset and Lock position

ATTDEF/ATTRIB flags 4 (verify) and 8 (preset) and Lock position (group 280
after the tag, DXF 2007 and later) were dropped on save. Without Lock
position AutoCAD lets an attribute be dragged out of its reference, and its
dynamic block actions no longer carry it. A reference's attributes take Lock
position from their definitions, as AutoCAD saves them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Cody Vogt <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 05:56:05 -04:00
3199de4951 Multiline attributes: read, draw and save their text (they were lost on save) (#854)
* Multiline attributes: read, draw and save their text

A multiline attribute (DXF attribute type 2, or 4 for a definition) has its
text as embedded MTEXT after its own groups (`101 Embedded Object`); its own
group 1 is empty. CADCraft read only the own groups, so the attribute drew
nothing, and a save wrote it back empty: its text was lost.

- `Attrib::multiline` keeps the multiline text; `text.value` holds its plain
  text, which ATTEDIT and the other value editors change; an edited value
  replaces the contents (`Attrib::multiline_text`).
- DXF read: ATTRIB and ATTDEF split at the marker; the MTEXT parsing is
  shared with MTEXT entities (`mtext_from`).
- Drawn as multiline text; ZOOM Extents counts it.
- DXF write (2007 and later; earlier versions get one line): attribute type,
  alignment point and the embedded MTEXT, only what differs from unrotated
  text, as AutoCAD writes it.
- INSERT and ATTSYNC give references the definition's multiline text placed
  with them; MOVE/ROTATE/SCALE, Properties rotation and scale, annotative
  scaling and FLATTEN carry it.
- Tests: synthetic multiline ATTDEF and ATTRIB (read, drawn, saved, edited,
  pre-2007 save, damaged embedded groups); engine INSERT, MOVE, ATTEDIT and
  ATTSYNC.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Multiline attributes: read MTEXT direction from its own groups (#667, as #834 does)

#834 moves these lines' fix into the MTEXT reader; the same fix in the shared
mtext_from keeps the two compatible.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Cody Vogt <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 05:54:25 -04:00
48352f2baf Dynamic block grips: show and drag a dynamic reference's grips (stacked on #878) (#883)
* Dynamic blocks: read parameters and actions from AutoCAD files

A dynamic block's evaluation graph (kept as foreign data since #528) is read
into a typed model, `cadcraft_doc::dynblock`, without changing the kept
data: parameters (linear with value sets, point, visibility states,
lookup, base point; polar, XY, rotation, alignment and flip by geometry)
and actions (stretch with its frame and moved vertices, lookup tables;
move, scale, rotate, flip, array, polar stretch by kind), each with its
graph id and the parameters it follows.

- `blocks.dynamic {name}` returns the model as JSON (handles as hex);
  `blocks.list` marks dynamic blocks.
- Counts and values come from the file: capped by the groups present,
  non-finite numbers and missing members skipped.
- Tests: a synthetic DOOR block (visibility, linear list, stretch, lookup),
  its survival through a save, damaged data, and the engine commands.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Dynamic block references: their values and custom properties

A dynamic block reference's extension dictionary says which dynamic block it
shows (AcDbBlockRepresentation ▸ AcDbRepData) and the values it was given
(AppDataCache ▸ ACAD_ENHANCEDBLOCKDATA, one XRECORD per parameter graph id).
`dynblock::reference` reads them; a reference to the dynamic block itself
has the block's values.

- `dynblock::custom_properties`: the Properties palette's Custom section, in
  parameter order, only parameters shown in Properties: visibility state,
  lookup value worked out from the lookup tables (their "no match" name, e.g.
  Custom, when the inputs match no row), distances and point positions in
  drawing units (block units × the reference's scale), with the choices of
  each (states, lookup values, a distance's list).
- Properties: a dynamic block reference shows its dynamic block's name
  instead of its anonymous block's, and a read-only Custom section.
- `blocks.reference {handle}` returns the definition, values and properties.
- Tests: synthetic references (own values, an anonymous block scaled 2, a
  width no lookup row has), survival through a save, the engine command.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Dynamic block references: change the visibility state

Choosing a visibility state (or a lookup value whose tables set only
visibility states) in Properties ▸ Custom, or `blocks.reference.set
{handle, name, value}`, changes a dynamic block reference as AutoCAD does:

- The reference gets a new anonymous block: its current one with the
  state's objects shown and the parameter's other objects hidden (matched
  to the dynamic block's objects by their AcDbBlockRepETag element tags).
  The old anonymous block goes when nothing else uses it.
- AutoCAD's link data is written: the new block's record carries
  AcDbBlockRepBTag (the dynamic block's record), copied objects keep their
  element tags, and the reference's AcDbBlockRepresentation data records the
  value (an existing value record is updated; a new one copies another
  reference's record for the same parameter when there is one).
- An attribute whose definition the state hides is written hidden (60).
- Changes CADCraft can't make yet (distances, positions, lookups that set
  them) are refused with a message and change nothing; Properties shows
  only what can change as a dropdown (`CustomProperty::editable`).
- Tests: the synthetic DOOR references (own values and an anonymous block),
  survival through a save, refusals, and the engine command with undo.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Dynamic block references: distances, positions, stretch and move actions, lookup chaining

Linear distances (snapped to their value sets) and point positions can be
changed; the stretch and move actions that follow them run (listed vertices
or whole objects, multiplier and angle offset), parameters in an action's
selection are carried along, associative hatches refill from their
stretched outlines and the reference's attributes move with their
definitions. Lookup tables run both ways (a changed input gives its table's
lookup value, which sets the other tables' inputs). Connections are read in
either group order. Checked against AutoCAD's own references (*U10, *U12).

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Dynamic block references: record changes in the history as AutoCAD does; full value data for new references

A changed reference's ACAD_ENHANCEDBLOCKHISTORY says what was done to it: a
visibility state or lookup value by property name, a distance or position as
the drag of its grips (GRIPLOC, how far each grip is from where the dynamic
block has it). A reference that had no data gets a record for everything
other references of the same dynamic block have, with its own values.
Templates come only from references of the same dynamic block; anonymous
copies have no units; boundary reactors follow the copies.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Dynamic block history: record only the grips a change moves

AutoCAD's history holds the user's own drags; a grip that only moved with
another parameter (a stretch carrying it) gets no entry.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Dynamic block history: a distance by name, as typed in Properties

AutoCAD records a distance typed in Properties as the parameter's name and
value in block units (1071 id, 300 Distance2, 40 40.0), replacing drags of
its grips; positions stay grip drags.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* DWG: dynamic block lookup parameters keep their name and lookup action

AutoCAD writes a lookup parameter's subclass marker as
AcDbBlockLookUpParameter; the DWG bridge reads AcDbBlockLookupParameter and
matches it exactly, so the parameter's name (303) and lookup action (94)
were lost in DWG files and AutoCAD's lookups stopped working (a visibility
change no longer resized the block). The bridge DXF now uses its spelling.

Found by driving AutoCAD on CADCraft's output.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Dynamic blocks: carried parameter points are in a stretch frame where the block has them

AutoCAD evaluates actions from the dynamic block itself, so a parameter point
another action has moved out of a stretch frame still follows it. Testing the
current position instead changed a stretched label's width when its
visibility changed (Distance2 183.75 became 184.45; AutoCAD keeps 183.75).

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Dynamic block grips: show and drag a dynamic reference's grips as AutoCAD does

A selected dynamic block reference shows its grips: an arrow at each end of a
distance, a square at a position, a down-pointing triangle for a visibility
or lookup list, only those its visibility state lists. Dragging an arrow
changes the distance along the parameter (snapped to its values; a typed
number sets it), a square moves the position, a list grip offers its
choices; actions and lookups follow, with a live preview.

doc: dynblock::grips, drag_grip; grip objects by graph id.
engine: blocks.reference.grips, blocks.reference.grip, Session::start_dyn_grip.
ui: canvas dynamic grips (GRIPDYNCOLOR-like token), list popup.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Tests: read the shared DOOR fixture with LF line ends whatever the checkout gives

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Dynamic blocks: carried parameter points are in a stretch frame where the block has them

AutoCAD evaluates actions from the dynamic block itself, so a parameter point
another action has moved out of a stretch frame still follows it. Testing the
current position instead changed a stretched label's width when its
visibility changed (Distance2 183.75 became 184.45; AutoCAD keeps 183.75).

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Dynamic grips: Ukrainian labels for the new commands; copy the hot grip instead of cloning

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Cody Vogt <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 05:47:27 -04:00
0d138c2954 Dynamic block references: distances, positions, stretch and move actions, lookups both ways (stacked on #860) (#878)
* Dynamic blocks: read parameters and actions from AutoCAD files

A dynamic block's evaluation graph (kept as foreign data since #528) is read
into a typed model, `cadcraft_doc::dynblock`, without changing the kept
data: parameters (linear with value sets, point, visibility states,
lookup, base point; polar, XY, rotation, alignment and flip by geometry)
and actions (stretch with its frame and moved vertices, lookup tables;
move, scale, rotate, flip, array, polar stretch by kind), each with its
graph id and the parameters it follows.

- `blocks.dynamic {name}` returns the model as JSON (handles as hex);
  `blocks.list` marks dynamic blocks.
- Counts and values come from the file: capped by the groups present,
  non-finite numbers and missing members skipped.
- Tests: a synthetic DOOR block (visibility, linear list, stretch, lookup),
  its survival through a save, damaged data, and the engine commands.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Dynamic block references: their values and custom properties

A dynamic block reference's extension dictionary says which dynamic block it
shows (AcDbBlockRepresentation ▸ AcDbRepData) and the values it was given
(AppDataCache ▸ ACAD_ENHANCEDBLOCKDATA, one XRECORD per parameter graph id).
`dynblock::reference` reads them; a reference to the dynamic block itself
has the block's values.

- `dynblock::custom_properties`: the Properties palette's Custom section, in
  parameter order, only parameters shown in Properties: visibility state,
  lookup value worked out from the lookup tables (their "no match" name, e.g.
  Custom, when the inputs match no row), distances and point positions in
  drawing units (block units × the reference's scale), with the choices of
  each (states, lookup values, a distance's list).
- Properties: a dynamic block reference shows its dynamic block's name
  instead of its anonymous block's, and a read-only Custom section.
- `blocks.reference {handle}` returns the definition, values and properties.
- Tests: synthetic references (own values, an anonymous block scaled 2, a
  width no lookup row has), survival through a save, the engine command.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Dynamic block references: change the visibility state

Choosing a visibility state (or a lookup value whose tables set only
visibility states) in Properties ▸ Custom, or `blocks.reference.set
{handle, name, value}`, changes a dynamic block reference as AutoCAD does:

- The reference gets a new anonymous block: its current one with the
  state's objects shown and the parameter's other objects hidden (matched
  to the dynamic block's objects by their AcDbBlockRepETag element tags).
  The old anonymous block goes when nothing else uses it.
- AutoCAD's link data is written: the new block's record carries
  AcDbBlockRepBTag (the dynamic block's record), copied objects keep their
  element tags, and the reference's AcDbBlockRepresentation data records the
  value (an existing value record is updated; a new one copies another
  reference's record for the same parameter when there is one).
- An attribute whose definition the state hides is written hidden (60).
- Changes CADCraft can't make yet (distances, positions, lookups that set
  them) are refused with a message and change nothing; Properties shows
  only what can change as a dropdown (`CustomProperty::editable`).
- Tests: the synthetic DOOR references (own values and an anonymous block),
  survival through a save, refusals, and the engine command with undo.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Dynamic block references: distances, positions, stretch and move actions, lookup chaining

Linear distances (snapped to their value sets) and point positions can be
changed; the stretch and move actions that follow them run (listed vertices
or whole objects, multiplier and angle offset), parameters in an action's
selection are carried along, associative hatches refill from their
stretched outlines and the reference's attributes move with their
definitions. Lookup tables run both ways (a changed input gives its table's
lookup value, which sets the other tables' inputs). Connections are read in
either group order. Checked against AutoCAD's own references (*U10, *U12).

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Dynamic block references: record changes in the history as AutoCAD does; full value data for new references

A changed reference's ACAD_ENHANCEDBLOCKHISTORY says what was done to it: a
visibility state or lookup value by property name, a distance or position as
the drag of its grips (GRIPLOC, how far each grip is from where the dynamic
block has it). A reference that had no data gets a record for everything
other references of the same dynamic block have, with its own values.
Templates come only from references of the same dynamic block; anonymous
copies have no units; boundary reactors follow the copies.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Dynamic block history: record only the grips a change moves

AutoCAD's history holds the user's own drags; a grip that only moved with
another parameter (a stretch carrying it) gets no entry.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Dynamic block history: a distance by name, as typed in Properties

AutoCAD records a distance typed in Properties as the parameter's name and
value in block units (1071 id, 300 Distance2, 40 40.0), replacing drags of
its grips; positions stay grip drags.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* DWG: dynamic block lookup parameters keep their name and lookup action

AutoCAD writes a lookup parameter's subclass marker as
AcDbBlockLookUpParameter; the DWG bridge reads AcDbBlockLookupParameter and
matches it exactly, so the parameter's name (303) and lookup action (94)
were lost in DWG files and AutoCAD's lookups stopped working (a visibility
change no longer resized the block). The bridge DXF now uses its spelling.

Found by driving AutoCAD on CADCraft's output.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Dynamic blocks: carried parameter points are in a stretch frame where the block has them

AutoCAD evaluates actions from the dynamic block itself, so a parameter point
another action has moved out of a stretch frame still follows it. Testing the
current position instead changed a stretched label's width when its
visibility changed (Distance2 183.75 became 184.45; AutoCAD keeps 183.75).

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Cody Vogt <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 05:45:56 -04:00
f8521ab299 Dynamic block references: change the visibility state (stacked on #853) (#860)
* Dynamic blocks: read parameters and actions from AutoCAD files

A dynamic block's evaluation graph (kept as foreign data since #528) is read
into a typed model, `cadcraft_doc::dynblock`, without changing the kept
data: parameters (linear with value sets, point, visibility states,
lookup, base point; polar, XY, rotation, alignment and flip by geometry)
and actions (stretch with its frame and moved vertices, lookup tables;
move, scale, rotate, flip, array, polar stretch by kind), each with its
graph id and the parameters it follows.

- `blocks.dynamic {name}` returns the model as JSON (handles as hex);
  `blocks.list` marks dynamic blocks.
- Counts and values come from the file: capped by the groups present,
  non-finite numbers and missing members skipped.
- Tests: a synthetic DOOR block (visibility, linear list, stretch, lookup),
  its survival through a save, damaged data, and the engine commands.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Dynamic block references: their values and custom properties

A dynamic block reference's extension dictionary says which dynamic block it
shows (AcDbBlockRepresentation ▸ AcDbRepData) and the values it was given
(AppDataCache ▸ ACAD_ENHANCEDBLOCKDATA, one XRECORD per parameter graph id).
`dynblock::reference` reads them; a reference to the dynamic block itself
has the block's values.

- `dynblock::custom_properties`: the Properties palette's Custom section, in
  parameter order, only parameters shown in Properties: visibility state,
  lookup value worked out from the lookup tables (their "no match" name, e.g.
  Custom, when the inputs match no row), distances and point positions in
  drawing units (block units × the reference's scale), with the choices of
  each (states, lookup values, a distance's list).
- Properties: a dynamic block reference shows its dynamic block's name
  instead of its anonymous block's, and a read-only Custom section.
- `blocks.reference {handle}` returns the definition, values and properties.
- Tests: synthetic references (own values, an anonymous block scaled 2, a
  width no lookup row has), survival through a save, the engine command.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Dynamic block references: change the visibility state

Choosing a visibility state (or a lookup value whose tables set only
visibility states) in Properties ▸ Custom, or `blocks.reference.set
{handle, name, value}`, changes a dynamic block reference as AutoCAD does:

- The reference gets a new anonymous block: its current one with the
  state's objects shown and the parameter's other objects hidden (matched
  to the dynamic block's objects by their AcDbBlockRepETag element tags).
  The old anonymous block goes when nothing else uses it.
- AutoCAD's link data is written: the new block's record carries
  AcDbBlockRepBTag (the dynamic block's record), copied objects keep their
  element tags, and the reference's AcDbBlockRepresentation data records the
  value (an existing value record is updated; a new one copies another
  reference's record for the same parameter when there is one).
- An attribute whose definition the state hides is written hidden (60).
- Changes CADCraft can't make yet (distances, positions, lookups that set
  them) are refused with a message and change nothing; Properties shows
  only what can change as a dropdown (`CustomProperty::editable`).
- Tests: the synthetic DOOR references (own values and an anonymous block),
  survival through a save, refusals, and the engine command with undo.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* DWG: dynamic block lookup parameters keep their name and lookup action

AutoCAD writes a lookup parameter's subclass marker as
AcDbBlockLookUpParameter; the DWG bridge reads AcDbBlockLookupParameter and
matches it exactly, so the parameter's name (303) and lookup action (94)
were lost in DWG files and AutoCAD's lookups stopped working (a visibility
change no longer resized the block). The bridge DXF now uses its spelling.

Found by driving AutoCAD on CADCraft's output.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Cody Vogt <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 05:44:18 -04:00
73b8edac61 Dynamic block references: custom properties in Properties (stacked on #838) (#853)
* Dynamic blocks: read parameters and actions from AutoCAD files

A dynamic block's evaluation graph (kept as foreign data since #528) is read
into a typed model, `cadcraft_doc::dynblock`, without changing the kept
data: parameters (linear with value sets, point, visibility states,
lookup, base point; polar, XY, rotation, alignment and flip by geometry)
and actions (stretch with its frame and moved vertices, lookup tables;
move, scale, rotate, flip, array, polar stretch by kind), each with its
graph id and the parameters it follows.

- `blocks.dynamic {name}` returns the model as JSON (handles as hex);
  `blocks.list` marks dynamic blocks.
- Counts and values come from the file: capped by the groups present,
  non-finite numbers and missing members skipped.
- Tests: a synthetic DOOR block (visibility, linear list, stretch, lookup),
  its survival through a save, damaged data, and the engine commands.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Dynamic block references: their values and custom properties

A dynamic block reference's extension dictionary says which dynamic block it
shows (AcDbBlockRepresentation ▸ AcDbRepData) and the values it was given
(AppDataCache ▸ ACAD_ENHANCEDBLOCKDATA, one XRECORD per parameter graph id).
`dynblock::reference` reads them; a reference to the dynamic block itself
has the block's values.

- `dynblock::custom_properties`: the Properties palette's Custom section, in
  parameter order, only parameters shown in Properties: visibility state,
  lookup value worked out from the lookup tables (their "no match" name, e.g.
  Custom, when the inputs match no row), distances and point positions in
  drawing units (block units × the reference's scale), with the choices of
  each (states, lookup values, a distance's list).
- Properties: a dynamic block reference shows its dynamic block's name
  instead of its anonymous block's, and a read-only Custom section.
- `blocks.reference {handle}` returns the definition, values and properties.
- Tests: synthetic references (own values, an anonymous block scaled 2, a
  width no lookup row has), survival through a save, the engine command.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* DWG: dynamic block lookup parameters keep their name and lookup action

AutoCAD writes a lookup parameter's subclass marker as
AcDbBlockLookUpParameter; the DWG bridge reads AcDbBlockLookupParameter and
matches it exactly, so the parameter's name (303) and lookup action (94)
were lost in DWG files and AutoCAD's lookups stopped working (a visibility
change no longer resized the block). The bridge DXF now uses its spelling.

Found by driving AutoCAD on CADCraft's output.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Cody Vogt <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 05:42:37 -04:00
fbc7037a10 Dynamic blocks: read parameters and actions from AutoCAD files (#838)
* Dynamic blocks: read parameters and actions from AutoCAD files

A dynamic block's evaluation graph (kept as foreign data since #528) is read
into a typed model, `cadcraft_doc::dynblock`, without changing the kept
data: parameters (linear with value sets, point, visibility states,
lookup, base point; polar, XY, rotation, alignment and flip by geometry)
and actions (stretch with its frame and moved vertices, lookup tables;
move, scale, rotate, flip, array, polar stretch by kind), each with its
graph id and the parameters it follows.

- `blocks.dynamic {name}` returns the model as JSON (handles as hex);
  `blocks.list` marks dynamic blocks.
- Counts and values come from the file: capped by the groups present,
  non-finite numbers and missing members skipped.
- Tests: a synthetic DOOR block (visibility, linear list, stretch, lookup),
  its survival through a save, damaged data, and the engine commands.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* DWG: dynamic block lookup parameters keep their name and lookup action

AutoCAD writes a lookup parameter's subclass marker as
AcDbBlockLookUpParameter; the DWG bridge reads AcDbBlockLookupParameter and
matches it exactly, so the parameter's name (303) and lookup action (94)
were lost in DWG files and AutoCAD's lookups stopped working (a visibility
change no longer resized the block). The bridge DXF now uses its spelling.

Found by driving AutoCAD on CADCraft's output.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Dynamic blocks: a work budget on the record cursor, so counts promising missing groups can't make nested loops rescan a hostile record (with regression test)

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Cody Vogt <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 05:40:12 -04:00
e773d93164 HATCHEDIT: refuse an explicit bad scale or angle before editing anything (#724) (#777)
* HATCHEDIT: refuse an explicit bad scale or angle before editing anything (#724)

run_hatchedit read `scale` and `angle` leniently (a non-positive scale or a
non-numeric value was skipped), so such a request reported success, added an
undo step and cleared the redo history, applied its valid companion property
or recreated the boundary. HATCH already refuses a non-positive scale. The
numbers are now checked first with the same size_param rule; `angle` must be
a number; absent and null still mean "leave it".

* Docs: update the progress docs for this change

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 05:37:45 -04:00
a2b6c9ac4a Animation effects follow the operating system's setting; View ▸ Accessibility ▸ Animation Effects (#759)
With Windows set to show no animations (Settings ▸ Accessibility ▸ Visual
effects ▸ Animation effects), dialogs and menus still faded in and wheel
input glided. All of that motion is egui's default behaviour, and nothing
asked the operating system.

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

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

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

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 05:34:56 -04:00
8abc0909f3 MCP: render one area of the drawing, and read its texts (find_text) (#757)
A render of a large drawing (a set of sheets in model space) fits every
sheet in one image, too small to read. `render` takes `window`
[[x0,y0],[x1,y1]]: just that area, 1600 px wide with the window's shape
unless sized (the app's and the headless `ui.render` both; the display list
is built for that view only). `View::of_window` sizes it.

`find_text` (engine command `texts`, read-only) returns the readable text of
TEXT, MTEXT (formatting removed), block attributes and multileaders with
insertion point, height, rotation, style, layer and handle, filtered by
content, layer and a window. FIND selects and can replace; this only
reports, so an agent can find room names, sheet numbers and titles without
reading all the geometry.

On a 39,600-object permit set: 5,861 texts in 0.02 s; one 90 x 59.4 sheet
rendered at 2400 x 1584 in 0.5 s.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 05:34:34 -04:00
8166e4a809 AutoLISP: load and run .lsp routines (fixes #758) (#855)
* AutoLISP: load and run .lsp routines (#758)

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 05:33:57 -04:00
dc92f38772 DXF: count the records the reader can't place; info reports them, convert warns (part of #865) (#872)
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 05:31:58 -04:00
1c4d0a543f Open damaged DWGs partially and say what was left out (#800) (#859)
Bump acadrust to 0.6.4 and read DWG in its failsafe mode: an object
record the reader can't decode is skipped instead of failing the whole
file. The drawing's new read_notes (never saved) say how many records
were left out and of which types; OPEN echoes them, returns them as
`warnings`, and the app shows the first in the status bar. A file whose
objects stream can't be read at all is still an error.

acadrust 0.6.4 writes a merged table cell's anchor with 173=0 and the
span, so the DXF reader now takes a merge from the span alone. Unknown
records the DWG writer can't write back are dropped before writing.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 05:30:18 -04:00
Brandon ThomasandClaude Opus 5.5 ef9898d57f zh: drop two duplicate rows left by merging #870 and #884
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 05:29:13 -04:00
33fe98a6dc Update Chinese translations in zh.tsv (#884)
* Add Chinese language support to preferences

* Add Chinese language support to preferences

* Chinese: register it as a reported language and draw it with an installed CJK font

The catalog didn't compile (Self::SimplifiedChinese) and egui's bundled
fonts have no Chinese glyphs, so the interface would have shown boxes.
Fonts are never bundled: the interface now looks for an installed CJK
font (PingFang, Microsoft YaHei, Noto Sans CJK, WenQuanYi...) and adds it
after the Latin fonts when Chinese is shown. Without one the interface
stays in English and says why. ATTRIBUTION row; language lists updated.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Update Chinese translations in zh.tsv

* zh: drop a stray 外形 from the array limit message

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 05:24:22 -04:00
5630529464 Properties palette: numeric fields take feet and inches, show lengths in the drawing's units, and report what they can't read (fixes #849) (#870)
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 05:23:59 -04:00
3b16f2d516 Engine: run the post-command passes from one place (#856)
`execute` and the interactive `finish` each called the group, constraint and
associativity passes in sequence, with slightly different arguments. They now
both call `Session::after_command(before, uid, ok)`, which lists the passes
once, so a pass cannot be added on one path and missed on the other. The
arguments each path passed are unchanged: `execute` passes the document uid and
`undoable && ok`; `finish` passes no uid and `true`.

tests/post_command_passes.rs pins that MOVE and ERASE, run through `execute`
and typed, prune groups, purge/re-solve constraints and update an associative
hatch identically, in one undo step that undo fully reverses.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 05:23:39 -04:00
2f5bbbd68d doc: Space::tab_name / layout / is_paper replace repeated hand-written matches (#888)
* doc: Space::is_paper/layout/tab_name helpers replace hand-written matches

Twenty-seven call sites in 15 files across engine, io and ui-egui matched Space by hand to get
"is this a layout", "which layout" or "Model or the layout's name". Route them
through three small helpers on Space instead. No behaviour change.

This also makes adding a space kind a few-file change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* doc: keep Space::Model comparisons that no helper shortens; test the helpers

Only the sites that repeated the same match (tab name, layout name,
paper lineweights) go through the helpers; plain Model comparisons and
plot_window's match on (space, plotArea) stay as they were.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 05:21:19 -04:00
76b0ef8897 Select: pick a solid hatch inside its fill, islands left out, not its bounding rectangle (follow-up to #747) (#763)
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 05:20:39 -04:00
966356545d ACAD_TABLE from other writers: keep style text height, read cell text height, lay out cell text as MTEXT (#802) (#806)
Co-authored-by: Claude Sonnet 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 05:18:23 -04:00
4257674dfa Fix polyline offset arc bulge after joining moved endpoints (#817) (#825)
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 05:17:25 -04:00
64b7825d08 Property-based and hostile-input tests for geometry numerics (gaps.md S6) (#733)
* Property-based and hostile-input tests for geometry numerics (gaps.md S6)

A deterministic xorshift64* generator (no new dependency) drives a few
thousand cases per property in crates/geom/src/property_tests.rs:

- line/line: hits lie on both lines, parameters reproduce the point,
  swapping the operands gives the same point, finite results agree with
  the infinite parameters
- line/circle and circle/circle: hits lie on both curves, results are
  symmetric, properly crossing circles match the closed form; hostile
  inputs (NaN, infinities, subnormals, raw bit patterns, 1e300) never
  panic and the guarded functions return only finite hits or none
- segments: extending only adds hits
- closest points: fixed point of closest, perpendicular inside the
  segment, endpoints outside
- arcs: at(0)/at(1) are the endpoints at radii from 1e-6 to 1e6; the
  three-point arc keeps {start, end} = {p1, p3} and contains p2 (the
  CCW-only storage swaps the pair when p2 is on the clockwise side, as
  DXF stores arcs) and matches the circumradius closed form; collinear
  inputs are rejected
- bounds and vector identities
- known answers at 1e6-1e7 coordinates hold to 1e-9 relative

gaps.md S6 removed and geometry-parity.md's Tests section updated in
the same change.

* docs: drop the sentence the new numerics tests make stale

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 05:16:44 -04:00
d2a9159e51 PDFBOOK: plot sheet frames in model space into one PDF per row of frames (#896)
* PDFBOOK: plot sheet frames in model space into one PDF per row of frames

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>

* PDFBOOK: no Cmd+Shift+P shortcut (AutoCAD's Quick Properties toggle); simpler uk label

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: simanov <[email protected]>
Co-authored-by: Claude Sonnet 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 05:15:56 -04:00
678ff555ae MTEXT: paragraph indents and tab stops are in drawing units (#895)
* MTEXT: paragraph indents and tab stops are in drawing units

AutoCAD writes \pi, \pl, \pr and tab stops in drawing units, not multiples of the text
height. A file with \pi27.485 on 6-high text in a 120-wide box laid out with a 165-unit first
line indent: the first line vanished and wrapped words landed elsewhere. The JSON mtext
parameters indent, firstIndent, rightIndent and tabs follow (a list's default indent is now
3 text heights, in drawing units).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>

* MTEXT editor: list indent of three text heights in drawing units, matching the new \p units

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: simanov <[email protected]>
Co-authored-by: Claude Sonnet 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 05:13:16 -04:00
d0438577ed Fonts: use the TrueType typeface from a style's xdata and find families by first word (#894)
AutoCAD stores a TrueType text style's family (e.g. "GOST type A") in the style's ACAD xdata
and leaves the font file name empty. Styles without a file now take that family.

Family-name lookups also look in files whose name starts with the family's first word, so
"GOST type A" finds GOST.TTF (the stem shares only 4 characters with the family name).

Co-authored-by: simanov <[email protected]>
Co-authored-by: Claude Sonnet 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 05:12:33 -04:00
63b448bee9 Extents: count the visible attributes of a reference to a missing block (fixes #707) (#746)
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 05:12:00 -04:00
7b767ddab9 Plot: model-space PNG and SVG plots honour fit: false without a scale, at 1:1 like PDF (fixes #713) (#744)
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 05:10:52 -04:00
79efd97515 MTEXT: annotative scaling and SCALE on multileader text scale the text width and columns too (fixes #708, fixes #709) (#743)
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 05:10:24 -04:00
cddcbe7a2a Dimensions: a jogged radius with a NaN or infinite radius draws no lines instead of panicking (fixes #712) (#742)
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 05:09:44 -04:00
b209bb53e9 Geometry: param_at_length checks spline validity before clamping to the domain (fixes #704) (#741)
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 05:09:01 -04:00
5d39f8710c HATCHEDIT: choosing SOLID on a gradient hatch makes it a plain solid fill (fixes #721) (#740)
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 05:07:46 -04:00
6394c7c6f3 JOIN: an elliptical arc named twice is one object, so the joined result stays (fixes #725) (#739)
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 05:07:02 -04:00
2483993218 HATCH: a pattern name with no definition is refused instead of drawn as ANSI31 (fixes #722) (#736)
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 05:06:15 -04:00
cc57dff527 POLYGON: a typed radius of zero or less, or a radius point on the centre, is refused and asked again (fixes #720) (#735)
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 05:05:37 -04:00
724c18c6af Constraint point names: s2 names segment 2, as documented (fixes #710) (#734)
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 05:04:26 -04:00
8e0b867f6c BREAK, BREAKATPOINT and JOIN: leave objects on locked layers alone (fixes #727) (#730)
BREAK and BREAKATPOINT (break_entity) and line/arc/polyline JOIN
(modify::join) removed their source objects and added replacements
without checking the source layer's lock, so a locked line could be cut
or merged into a polyline. ERASE, MOVE, TRIM, FILLET, EXPLODE and the
ellipse/spline JOIN already guard against this.

Both now refuse with the same errors the sibling commands use:
"The object is on a locked layer." (BREAK, BREAKATPOINT, like FILLET)
and "An object is on a locked layer." (JOIN, like the ellipse/spline
join). JOIN refuses the whole request when any selected object is
locked, so a mixed-layer pair keeps its locked original in either
order. The guards sit in the shared functions, so JSON calls with
explicit handles or the selection, the interactive prompts and the
command line all reach them. The interactive commands report the
refusal on the command line.

Tests (crates/engine/tests/break_join_locked_layers.rs, red before the
fix): BREAK and BREAKATPOINT on a locked line by handle and by
selection; BREAK on a locked arc and circle; JOIN of locked lines and
of a locked/unlocked pair in both orders; interactive BREAK and JOIN.
Each checks the drawing is unchanged while locked, and that the same
edit works unlocked (BREAK also undoes and redoes).

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 05:03:16 -04:00
36f938e90a Performance: restrict object-snap intersections to cursor aperture (#701)
* Snap: pin finite and apparent intersection behavior

* Snap: skip finite intersections outside the aperture

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 05:02:28 -04:00
Brandon ThomasandClaude Opus 5.5 636b041a4e DXF writer: resolve the #827/#871 merge (W keeps both the layer handles and the hatch reactors)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 04:51:37 -04:00
Brandon ThomasandClaude Opus 5.5 bb7e1046be docs: gaps F1/F2 rows after the STRETCH and REFEDIT merges (one row each)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 04:47:56 -04:00
f807e65e30 STRETCH: polygon and fence selection (#892)
At "Select objects", CP and WP take a polygon (Enter closes it) whose
inside marks the vertices that move, as a crossing window does; F takes a
fence, and what it crosses moves whole, like objects picked one at a
time. BOX works too, Undo takes back the last point, and the polygon or
fence is drawn while its points are placed.

The JSON form takes `polygon` or `fence` in place of `window`; both are
capped at the selection modes' point limit and refuse non-finite points.

Co-authored-by: Chanthawat Kiriyadee <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 04:47:19 -04:00
69f2b9724a Save xref layer overrides with the host, in DXF and DWG (fixes #886) (#889)
A colour or freeze set in the host on an xref's layers (xref|layer) was lost on save:
the saved view dropped every xref-dependent symbol, layers included, and the DWG save goes
through the same DXF tags. The reader already keeps such layers when the xref loads.

The xref's layers are now written as xref-dependent LAYER records (DXF Reference, LAYER
group 70: 16 externally dependent, 32 resolved), as AutoCAD does with VISRETAIN = 1, with
the xref linetypes they use (same flags): without them a DWG falls back to Continuous.

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 04:46:45 -04:00
28b8160407 DXF: associative hatches in blocks keep their boundary; boundaries carry the hatch reactor (#871)
A hatch inside a block was written with no source objects (the writer looked
for them in model and paper space only), and no boundary object carried the
hatch as a persistent reactor. AutoCAD keeps the two linked both ways; without
it a stretched dynamic block in AutoCAD moved the outline but not the fill.

Co-authored-by: Cody Vogt <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 04:46:29 -04:00
5b037b8f92 DXF: write viewport frozen layers as group 341 layer handles too (#827)
A layout VIEWPORT's frozen layers were saved only as CADCraft's own
1001 CADCRAFT / 1000 VPFROZEN xdata. Other programs ignore that, so in
any third-party viewer every viewport showed every layer.

The writer now also emits the standard group 341: one soft pointer per
frozen layer to its LAYER table record handle, after 90/340 and before
the xdata. A frozen name with no LAYER record is skipped for 341 only
(the xdata still carries it). The reader keeps preferring the xdata; a
VIEWPORT without it (a file from another program) resolves its 341
handles through the LAYER records' group-5 handles, ignoring unknown
handles and keeping the 341 order.

Not covered: the DXF-to-DWG converter does not carry group 341 yet, so
a DWG keeps per-viewport freezing only through the CADCraft xdata.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 04:46:15 -04:00
5fd23f75fc Command line: a bare system variable name runs SETVAR on it (part of U10) (#799)
* Command line: a bare system variable name runs SETVAR on it (part of U10)

Typing ORTHOMODE, PICKADD and the like at the command line returned
"unknown command". Session::start now falls back to SETVAR with the name
already entered when the text is not a command but is a known variable;
dash, dot and underscore prefixes never mean a variable, and real
commands still win. Read-only variables print their value, and the
transparent form ('OSMODE) resumes the interrupted command.

Checked: fmt, clippy, test, assets, layers and wasm pass; also driven
through the control channel in the running app.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>

* Tests: every variable name, command collisions and hostile input for bare SETVAR

Covers each variable SETVAR lists: a name that is not a command starts SETVAR
(or prints a read-only value), a name that is also a command keeps the command,
and odd input (lone apostrophes, NUL, wide characters, a 1 MiB name) never
panics and leaves the session usable. Disabling the fix makes all three new
and old tests fail.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>

---------

Co-authored-by: Claude Sonnet 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 04:46:00 -04:00
a6200fa34f i18n(pt-BR): complete the Brazilian Portuguese catalog (#791)
Add the 238 rows that still fell back to English, taking the catalog to
1,231 rows: it now covers the reference catalog and every marked UI
literal (commands, menus, tool groups and tooltips, object snaps,
drafting prompts, dialogs and Preferences), so the i18n tests report no
remaining gaps for pt-BR.

The translations follow the terminology already used in the catalog
(cota, hachura, camada, pinça, janela gráfica, referência a objetos) and
preserve placeholders ({n}, {name}) and trailing ellipses exactly.

Also add a bundled-font glyph test for the catalog's accented letters,
mirroring the German and Ukrainian ones, and record the completion in
docs/localization-parity.md (status line and revision history) and
docs/localization.md.

Written from CADCraft's English strings; no proprietary localization
resources were copied.

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 04:45:43 -04:00
25e4900fcd Recent files: the Start page and File ▸ Open Recent list the drawings opened last (fixes #858) (#864)
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 04:44:44 -04:00
4efb01332e Canvas: every click places a point while drawing, however fast (fixes #815) (#835)
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 04:42:53 -04:00
mattlafyandClaude Opus 5.5 d134079f11 Xdata travels with its object: COPY, MIRROR, ARRAY and paste keep it (#890)
Other applications' xdata on an entity was kept beside the drawing,
keyed by handle, so a copy (new handle) lost it: a copied hyperlink
(PE_URL) had no link, third-party tags stayed on the original only.
In AutoCAD xdata is part of the object.

Move entity xdata onto the entity as Common::xdata (the same raw
groups); every copy path already clones Common. The DXF reader puts it
there (handle references still noted for remapping), the writer takes
it from the entity, PDF hyperlinks come from the entities. EXPLODE's
pieces of an object don't inherit its xdata. Table record and block
xdata stay in Foreign.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 04:38:34 -04:00
ZaxaerithandZaxaerith 591692a264 docs: clarify engine commands in control protocol (#845) (#873)
Co-authored-by: Zaxaerith <[email protected]>
2026-10-13 04:38:29 -04:00
Marco Arnulfo 50246683c3 DXF: read every ENTITIES section, not only the last (fixes #865) (#869) 2026-10-13 04:38:24 -04:00
Marco Arnulfo 2effc8bf3e Double-click on text, mtext or an attribute definition starts TEXTEDIT on it (part of #842) (#868) 2026-10-13 04:38:18 -04:00
Marco Arnulfo 5b0238fc26 Dynamic Input and grip fields show distances in the drawing's UNITS (fixes #841) (#867) 2026-10-13 04:38:13 -04:00
Marco Arnulfo 7b723b7bc0 Viewport scale list: add engineering scales for imperial drawings (fixes #851) (#866) 2026-10-13 04:38:08 -04:00
aldozarandClaude Opus 5.5 a10e43cb2a CJK text: big-font cells in SHX text, CJK interface fonts in every language (#861)
Drawing (#674): in stroke-font (SHX) text, East Asian wide characters
from a fallback font fill a square cell from the baseline to the text
height, as a big font's do, instead of being scaled by the fallback
font's Latin cap height (about 1.3-1.5x too large and too widely spaced,
so CJK labels overran their space). A line no longer ends with the
stroke font's letter gap after a fallback glyph.

Interface (#513): a CJK font is installed after the Latin one in every
interface language, not only Chinese, so Japanese or Chinese file,
layout and layer names stop showing as boxes. A Japanese font comes
first unless the interface is Chinese; a craft-fonts face embedded in
the build comes last, for systems (and the web build) without one.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 04:38:03 -04:00
Marco Arnulfo 7e38081b54 Control channel: control.methods lists the methods, ping answers (fixes #843) (#852)
A client could only learn the top-level methods from docs/control-protocol.md;
help, methods, ping and the like all came back as "unknown method". The app
and the headless backend now answer control.methods with each method they
serve ({method, params, description}) and ping with "pong", and an unknown
method's error points to control.methods. A test keeps the app's list equal
to the doc table and checks every listed method is dispatched.
2026-10-13 04:37:57 -04:00
Marco Arnulfo 3f709bed2e Desktop: a bare --control keeps the CADCRAFT_CONTROL_PORT port (fixes #844) (#848)
`--control` as the last argument read `args.next()` as None and reset the
port taken from CADCRAFT_CONTROL_PORT, without logging anything, so
`CADCRAFT_CONTROL_PORT=47321 cadcraft --control` started no control
channel. A bare `--control` now keeps the variable's port and logs a
warning either way; a port given on the command line still wins. The
precedence is documented in docs/control-protocol.md.
2026-10-13 04:37:52 -04:00
Ray caad180e98 fix(dxf): MTEXT direction ignores the embedded object's insertion point (#667) (#834)
An R2018+ MTEXT can carry an embedded object (group 101) in which 10 is the
X-axis direction and 11 the insertion point. AutoCAD writes one for MTEXT with
column settings, and so does the DWG bridge. The reader took the first group 11
anywhere in the entity as the direction, so such text turned towards its own
insertion point. Read 11/21 and 50 from the MTEXT's own groups only.
2026-10-13 04:37:47 -04:00
Valentin KleinandClaude Sonnet 5.5 80ec2dcc60 Ctrl+Z / U during LINE, PLINE... removes the last segment instead of the whole command (#750) (#831)
Co-authored-by: Claude Sonnet 5.5 <[email protected]>
2026-10-13 04:37:42 -04:00
46ab40d211 Keep the layout tabs inside the status bar (#821)
* Keep the layout tabs inside the status bar

With many layouts or long layout names the tabs ran under the coordinates and
the drafting toggles and off the window.

The tabs now stop before the right-hand controls, leaving room for the
coordinates: Model and "+" stay, then the run of layouts that fits, always
holding the current one. A name wider than 150 px is cut with "…" and shows in
full on hover. When some layouts don't fit, a button after the last tab lists
them all; the "Layout list" button at the left of the bar opens the same list
(it did nothing before).

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Status bar test: expect the other layout tabs only when the bar has room

At 900 pt the layout tabs that don't fit move into the layout list (d2fe40b), so
only the current tab is painted there. The test still required "Layout2" at
every size and failed on main.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Chanthawat Kiriyadee <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 04:37:37 -04:00
Valentin KleinandClaude Sonnet 5.5 9464945f82 REFEDIT, REFSET and REFCLOSE: edit block references in place (#818)
* REFEDIT, REFSET and REFCLOSE: edit block references in place (#813)

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>

* Move REFEDIT translation lines into the middle of the catalogs (avoids PR merge conflicts)

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>

---------

Co-authored-by: Claude Sonnet 5.5 <[email protected]>
2026-10-13 04:37:31 -04:00
Valentin KleinandClaude Sonnet 5.5 3dd678ff3d MLEADEREDIT, MLEADERALIGN, MLEADERCOLLECT (#816)
* MLEADEREDIT, MLEADERALIGN and MLEADERCOLLECT (#812)

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>

* Move MLEADER translation lines away from the end of the catalogs (avoids PR merge conflicts)

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>

---------

Co-authored-by: Claude Sonnet 5.5 <[email protected]>
2026-10-13 04:37:26 -04:00
Valentin KleinandClaude Sonnet 5.5 ea768ee92a docs: gaps.md FF14 lists what is left of SVG import (#814)
SVG import landed in #636 but its row still said it was missing.

Co-authored-by: Claude Sonnet 5.5 <[email protected]>
2026-10-13 04:37:21 -04:00
5ce44acfb2 Pick dimensions and multileaders anywhere on their text (#807)
Plain TEXT and MTEXT are picked anywhere inside their box, but a
dimension or multileader was only found near its lines: its hit geometry
is built without text. Hovering or clicking the words of a dimension found
nothing (on a survey plan, 2 of 34 letters of a dimension's text).

A point inside the box around the words, turned with the text, now picks
the object. A dimension with a block, as AutoCAD saves them, is drawn
from that block, so its text box comes from the TEXT and MTEXT in the
block, not from the text the dimension's own geometry would place (on the
same plan that was a different place, eight times smaller). A
multileader's text box comes from its MTEXT layout.

Co-authored-by: Cody Vogt <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 04:37:16 -04:00
Valentin KleinandClaude Sonnet 5.5 853b2915b6 Compare hatch pattern lines with a tolerance in normal_zoom_pattern_is_unchanged (#804) (#805)
The view-limited path differs from full detail by ~3e-16 on Windows (float noise from the rotated-frame round trip); the test now compares structure exactly and coordinates within 1e-9.

Co-authored-by: Claude Sonnet 5.5 <[email protected]>
2026-10-13 04:37:11 -04:00
Valentin KleinandClaude Sonnet 5.5 0b92b59fcd Open drawings on a worker thread with an Opening... indicator (#694) (#797)
Co-authored-by: Claude Sonnet 5.5 <[email protected]>
2026-10-13 04:37:06 -04:00
Valentin KleinandClaude Sonnet 5.5 f26588a357 PEDIT: pre-selected line or arc asks to turn it into a polyline (#745) (#796)
Co-authored-by: Claude Sonnet 5.5 <[email protected]>
2026-10-13 04:37:01 -04:00
Valentin KleinandClaude Sonnet 5.5 8073453d86 BOUNDARY: cap point list and aggregate output per call (#723) (#795)
* BOUNDARY: cap point list and aggregate output per call (#723)

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>

* BOUNDARY: a call over the output budget restores the drawing

The session does not roll back a command that returns an error, so the
polylines created before the cap was hit stayed in the drawing.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>

---------

Co-authored-by: Claude Sonnet 5.5 <[email protected]>
2026-10-13 04:36:56 -04:00
Jason Brashear 89c834155d Geometry: keep vector midpoints finite at coordinate extremes (#793) 2026-10-13 04:36:51 -04:00
Marco Arnulfo 14343212a9 Zoom Window: the opposite corner ignores ortho, so the window stays a rectangle (fixes #787) (#790) 2026-10-13 04:36:46 -04:00
bottelliemanuele-makerandClaude Fable 5.1 ecdb8f96b2 i18n: Italian interface catalog (it.tsv) (#789)
Adds Italian as an interface language: 1,227 rows written from the
English strings with Italian desktop-CAD vocabulary (Disegna,
Tratteggio, Quota, Direttrice, layer, spazio modello/carta). Two
`menu` entries keep the menu-bar headings apart from the command and
tool-group labels: View → Visualizza (object label: Vista) and
Modify → Edita (tool group: Modifica). Italian integer plurals follow
the English rule (one, other).

Registered in i18n::LANGUAGES and Preference (code `it`), reported in
the catalog tests, with a locale/preference/plural/menu/font test;
attributed in ATTRIBUTION.md; README, ROADMAP, docs/localization.md,
docs/localization-parity.md, docs/control-protocol.md and the Options
doc comment list the language.

Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-10-13 04:36:40 -04:00
4c63ba1470 LINE: resolve deferred TAN first point with typed @distance<angle> (#788)
* LINE: resolve deferred TAN first point with typed @distance<angle>

* LINE: collapse the nested if-let (clippy collapsible_if)

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 04:36:35 -04:00
c0335161c3 Geometry: spline arc length converges on tight bends, so LENGTHEN shortens to the target (#786)
* Geometry: spline arc length converges on tight bends, so LENGTHEN shortens to the target (fixes #705)

* Geometry: cap adaptive spline quadrature (depth 10, 1e-10 relative) so a hostile spline can't make LENGTHEN slow

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 04:36:28 -04:00
Marco Arnulfo 419d8848d6 MTEXT: Exactly line spacing keeps the pitch when a line has taller text (fixes #711) (#784) 2026-10-13 04:35:52 -04:00
aldozarandClaude Opus 5.5 c781169b95 Don't give entities made while reading handles the file still uses (#783)
Another program's file can name a $HANDSEED below handles it uses (a DWG
read through the bridge did: its seed was under the handles of its
dimension blocks). The faces a polyface mesh becomes were numbered from the
seed while reading, so they could take handles of records further on in
other lists (another block); within one list duplicates were already
renumbered. A DWG keeps one object per handle, so saving one lost the
duplicates: 15 faces of a block's mesh in a permit set.

Before entities are read, every handle a file uses is now reserved. A file
CADCraft wrote names its own next handle (#495), above which its rebuilt
records are numbered, so it is left to that and resaves stay identical.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 04:35:44 -04:00
aldozarandClaude Opus 5.5 f314d3a591 Write an unedited dimension's block as it was read (#782)
Saving regenerated every dimension's anonymous block, even dimensions read
from another program's file and never touched: the definition points went
and other programs then showed CADCraft's drawing of each dimension (a
dimension line split around its text came back whole, for instance).

A dimension still linked to the block it was read with hasn't changed
since: every edit drops the link, and the screen draws that block while it
is there. Its block is now written as read, under its name and handles;
edited and new dimensions are generated as before.

On a 1,013-dimension permit set saved DWG -> CADCraft -> DWG, every
dimension's block now matches the original (before: none).

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 04:35:38 -04:00
aldozarandClaude Opus 5.5 e9d8462828 Keep the ACIS geometry of 3D solids, regions and bodies on save (ACDSDATA) (#781)
From AutoCAD 2013 on, a 3DSOLID, REGION or BODY doesn't carry its ACIS
geometry: the binary SAB lives in the file's data store section (ACDSDATA),
one ACDSRECORD per entity bound by a 320 handle. CADCraft kept those
entities (as unknown entities, with their handles) but dropped the section,
so a drawing saved as DWG or DXF had empty solids, and saving reported them
as not saved.

- doc: Foreign::data_store keeps the section's groups as read;
  data_store_entities() lists the entities it has records for.
- io: the reader keeps the section; the writer puts it back for files of
  2013 and later (and in the DXF the DWG bridge converts, which acadrust
  reads into the DWG), without the records of entities no longer written.
  Nothing is written for older files or when no record's entity remains.
- engine: not_saved_as(d, version): from 2013 a solid, region or body with a
  data store record isn't reported; saves and document.bytes pass the version
  being written.

On a 39,600-object permit set with 625 3D solids and 2 regions, DWG ->
CADCraft -> DWG now keeps all 627 bodies with byte-identical SAB (before:
none), and the save no longer reports them.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 04:35:31 -04:00
Marco Arnulfo 6ab800e64c Engine: NEW and OPEN end a command left running in the previous drawing (fixes #715) (#780)
A command started in one drawing kept running after a programmatic NEW or OPEN made another
drawing current. Finishing it there recorded an undo step holding the first drawing, so undo in
the new drawing brought the other drawing back. Session::new_drawing and open_drawing now end a
running command before the new drawing becomes current, as CLOSE and document.switch already do
and as starting NEW from the command line did: its undo step lands in the drawing it started in.
With no command running, nothing changes (the previous drawing keeps its selection).
2026-10-13 04:35:26 -04:00
Marco Arnulfo 565cadaa51 Extents: cache block bounds per depth so a deep visit can't hide a shallow reference (fixes #706) (#778) 2026-10-13 04:35:20 -04:00
Marco Arnulfo bca698a588 SPLINE: cap programmatic control points like spline.cv (fixes #719) (#776) 2026-10-13 04:35:14 -04:00
Marco Arnulfo 5de2324973 Selection: rebuild the spatial index when a layer used inside blocks is hidden or shown (fixes #716) (#775) 2026-10-13 04:35:08 -04:00
Marco Arnulfo 266ee7e6ea Autosave: a write that failed is tried again without another change (fixes #717) (#774)
The planner marked a drawing as autosaved when it dispatched the write, and the background
worker only logged a failure. An unchanged drawing whose write failed was then never written
again, even once the folder became writable. The worker now reports whether the file was
written; on a failure (or a thread that could not be spawned) the drawing is no longer counted as
autosaved, so the next plan restarts its SAVETIME clock and tries again.
2026-10-13 04:35:02 -04:00
Marco Arnulfo 045212139a Geometry: fit splines with too many points use them as control points instead of a dense solve (fixes #702) (#773) 2026-10-13 04:34:56 -04:00
Marco Arnulfo 6772150bc4 Constraints: an arc solved with a negative radius is stored turned half a turn, so its points stay put (fixes #703) (#772) 2026-10-13 04:34:51 -04:00
NetterSilver b2ba585251 TRIM: a pick after a closed polyline's last cut keeps only the complementary run (#726) (#771)
* TRIM: a pick after a closed polyline's last cut keeps only the complementary run (#726)

With no cutting intersection past the pick, trim_polyline used the first cut
plus the perimeter as the end of the piece and then kept the run from the
first cut to the last cut plus a whole perimeter, so one extra circuit of the
outline stayed (a 4 x 4 square became an open polyline of length 24 instead of
8). The kept run now ends at the last cut before the pick.

* Docs: update the progress docs for this change
2026-10-13 04:34:46 -04:00
Books 7fcdc6e3bc Active viewport highlight: selection on the GPU, hover cached apart, kept inside a clipped outline (#770)
* Active viewport: redraw while panning and zooming inside it, without rebuilding the layout (#688)

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

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

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

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

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

Inside an active MSPACE viewport the selection highlight was drawn as
dashed egui shapes on the CPU every frame, about 8-13 ms per frame with
7,000 polylines selected, and its geometry was cached together with the
hover, so moving the cursor over objects tessellated the whole selection
again. The selection and the hover are now cached apart, and the
selection goes through the GPU highlight path model space already uses:
about 0.1 ms per frame. In a clipped viewport it is kept inside the
outline with the stencil mask its contents use; before, it was only cut
to the outline's bounding rectangle.
2026-10-13 04:34:40 -04:00
b19188919d DXF/DWG: write the Model layout's plot type as extents, not "Layout information" (#765)
The Model layout was written with the default page setup, whose "layout"
plot area is plot type 5, "Layout information" (DXF Reference, PLOTSETTINGS
group 74). That means nothing for model space: opened in AutoCAD 2027, a
drawing saved by CADCraft had a blank "What to plot", no preview and no scale
in the Plot dialog for Model. Model space now gets what PLOT does for it: the
drawing extents scaled to fit (74 = 1, 75 = 0). DWG saves go through the same
writer; the reader already ignores the Model layout's plot settings.

Co-authored-by: EP995LAB <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 04:34:35 -04:00
Books 488308a2de Hatch: a view covering a pattern hatch leaves its lines exactly as they are (#762)
With a view, each span of a pattern line was cut to the view's extent
intersected with the boundary's. Spans already lie within the boundary,
and the boundary extent comes from rotating the corners directly while a
span end comes from interpolating along an edge, so where a line passes
through a corner the two differ by a rounding error and the cut moved the
end (by 3e-16 on Windows, whose sin/cos round differently). The spans are
now cut to the view's extent only, so normal_zoom_pattern_is_unchanged
holds on every platform.
2026-10-13 04:34:30 -04:00
Marco Arnulfo f5d58913cc CIRCLE: a radius point on the centre is refused and asked again (follow-up to #735) (#761) 2026-10-13 04:34:25 -04:00
Marco Arnulfo 5ce1ea3dfc POLYGON: an Edge second endpoint on the first is refused and asked again (follow-up to #735) (#760) 2026-10-13 04:34:19 -04:00
aldozarandClaude Opus 5.5 483a9304ca cadcraft-cli mcp: --lazy and --launch for clients that start before the app (#756)
MCP clients start their servers when they start, usually before CADCraft is
open, and `--connect` refuses to start then. `--lazy` starts the bridge
anyway and reaches the app at the first tool call; until the app answers,
each call fails with how to open it. `--launch` (implies --lazy) opens
CADCraft on macOS with that control port at a call that finds nothing, and
waits up to 30 s. Neither falls back to a headless session (#103).

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 04:34:14 -04:00
5183f63bc5 Hatch and Gradient dialog (create and edit) (#753)
* HATCH/GRADIENT: points and objects in one call; GRADIENT name and centered

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* HATCHEDIT: gradient parameter (make or change a gradient fill)

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* HATCHPICK: pick hatch boundaries, an origin or hatches for the dialog

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Hatch dialog: state, defaults and the OK command call

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Hatch dialog: pick points, objects, origin or hatch and come back

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Hatch and Gradient dialog window (pattern swatches, gradient, origin, boundaries, options)

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* HATCH, GRADIENT and HATCHEDIT open the hatch dialog; double-click a hatch to edit; HPDLGMODE

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Hatch dialog: keep the window to its content (OK and Cancel stayed off screen); double-click test

A vertical separator between the two columns took the height available, so the window grew
every frame until its buttons left the screen. Found in the running app.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Docs: hatch dialogs in gaps, target-app parity and UI parity

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Hatch dialog review fixes

- Double-clicking a hatch at its centre opened the dialog and also started a grip edit.
- Edit mode no longer clamps an out-of-range scale or angle into a change nobody made.
- HPORIGINMODE outside 1-5 means the current origin, as HATCH reads it.
- Picks made in one space are dropped when the dialog comes back in another (layout tab).
- A gradient hatch's Pattern tab offers the current pattern (HPNAME), not SOLID.
- Ukrainian: "Default to boundary extents" said "outside the boundary".

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: lloyd <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 04:34:09 -04:00
cyfung1031 bcd6322ee9 Plotting: honor model-space center and X/Y offsets (#751)
* plot: add bounded model-space paper placement

* plot: expose center and paper offset parameters

* ui: add model-space Plot offset controls
2026-10-13 04:34:04 -04:00
Marco Arnulfo e71e17a8df View: refuse a view that maps to an infinite viewport view, keeping the viewport (fixes #714) (#749) 2026-10-13 04:33:59 -04:00
Marco Arnulfo 08cfbd5dd7 Select: pick a SOLID inside its fill, not its bounding rectangle (fixes #718) (#747) 2026-10-13 04:33:52 -04:00
241 changed files with 34610 additions and 1280 deletions

No files matched your search

+4
View File
@@ -64,3 +64,7 @@ Generated-in-code assets are original and have no file to list:
| `crates/ui-egui/src/i18n/ru.tsv` | @pechnikov2026-star | Original Russian translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
| `crates/ui-egui/src/i18n/fr.tsv` | @pechnikov2026-star | Original French translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
| `crates/ui-egui/src/i18n/zh.tsv` | @mayloglog | Original Simplified Chinese translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
| `crates/ui-egui/src/i18n/sv.tsv` | Erik Holmbom (@holmbom) with Claude Code (AI-assisted) | Original Swedish translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied; terminology and the running app reviewed by a native speaker |
| `crates/ui-egui/src/i18n/it.tsv` | Emanuele Bottelli (AI-assisted) | Original Italian translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
| `crates/ui-egui/src/i18n/ko.tsv` | @huk082 (AI-assisted) | Original Korean translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied; no font assets added |
| `crates/ui-egui/src/i18n/ar.tsv` | @hasan-sdaif | Original Arabic translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
Generated
+54 -22
View File
@@ -4,9 +4,9 @@ version = 4
[[package]]
name = "acadrust"
version = "0.6.3"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7df99ae4f5a6d0147907f9976fe1dd166d01e72ab1a93820b50242937c5797e6"
checksum = "94862189cab6e282ed0433a801a216cd1a712953813284a48fa81c67223e38f6"
dependencies = [
"anyhow",
"bitflags 2.13.2",
@@ -18,6 +18,7 @@ dependencies = [
"itoa",
"memmap2",
"nom",
"oem_cp",
"once_cell",
"rayon",
"ryu",
@@ -45,7 +46,7 @@ dependencies = [
"accesskit",
"accesskit_consumer 0.36.0",
"atspi-common",
"phf",
"phf 0.13.1",
"serde",
"zvariant",
]
@@ -546,7 +547,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "cadcraft"
version = "0.6.2"
version = "0.7.0"
dependencies = [
"cadcraft-engine",
"cadcraft-io",
@@ -556,9 +557,11 @@ dependencies = [
"hidapi",
"log",
"muda",
"objc2-app-kit 0.3.2",
"rfd",
"serde_json",
"sys-locale",
"windows",
"winit",
"winresource",
"zbus",
@@ -566,7 +569,7 @@ dependencies = [
[[package]]
name = "cadcraft-cli"
version = "0.6.2"
version = "0.7.0"
dependencies = [
"cadcraft-engine",
"cadcraft-io",
@@ -577,14 +580,14 @@ dependencies = [
[[package]]
name = "cadcraft-color"
version = "0.6.2"
version = "0.7.0"
dependencies = [
"serde",
]
[[package]]
name = "cadcraft-constraints"
version = "0.6.2"
version = "0.7.0"
dependencies = [
"cadcraft-doc",
"cadcraft-geom",
@@ -594,7 +597,7 @@ dependencies = [
[[package]]
name = "cadcraft-doc"
version = "0.6.2"
version = "0.7.0"
dependencies = [
"cadcraft-color",
"cadcraft-geom",
@@ -605,14 +608,14 @@ dependencies = [
[[package]]
name = "cadcraft-dwg"
version = "0.6.2"
version = "0.7.0"
dependencies = [
"acadrust",
]
[[package]]
name = "cadcraft-dxf"
version = "0.6.2"
version = "0.7.0"
dependencies = [
"encoding_rs",
"log",
@@ -621,7 +624,7 @@ dependencies = [
[[package]]
name = "cadcraft-engine"
version = "0.6.2"
version = "0.7.0"
dependencies = [
"cadcraft-color",
"cadcraft-constraints",
@@ -640,7 +643,7 @@ dependencies = [
[[package]]
name = "cadcraft-fonts"
version = "0.6.2"
version = "0.7.0"
dependencies = [
"cadcraft-geom",
"harfrust",
@@ -650,14 +653,14 @@ dependencies = [
[[package]]
name = "cadcraft-geom"
version = "0.6.2"
version = "0.7.0"
dependencies = [
"serde",
]
[[package]]
name = "cadcraft-io"
version = "0.6.2"
version = "0.7.0"
dependencies = [
"cadcraft-color",
"cadcraft-doc",
@@ -671,11 +674,12 @@ dependencies = [
"serde",
"serde_json",
"thiserror 2.0.21",
"tiny-skia",
]
[[package]]
name = "cadcraft-mcp"
version = "0.6.2"
version = "0.7.0"
dependencies = [
"cadcraft-engine",
"cadcraft-io",
@@ -686,7 +690,7 @@ dependencies = [
[[package]]
name = "cadcraft-render"
version = "0.6.2"
version = "0.7.0"
dependencies = [
"cadcraft-color",
"cadcraft-doc",
@@ -700,11 +704,12 @@ dependencies = [
[[package]]
name = "cadcraft-ui-egui"
version = "0.6.2"
version = "0.7.0"
dependencies = [
"cadcraft-color",
"cadcraft-doc",
"cadcraft-engine",
"cadcraft-fonts",
"cadcraft-geom",
"cadcraft-render",
"craft-ui",
@@ -721,7 +726,7 @@ dependencies = [
[[package]]
name = "cadcraft-web"
version = "0.6.2"
version = "0.7.0"
dependencies = [
"cadcraft-engine",
"cadcraft-io",
@@ -2707,6 +2712,15 @@ dependencies = [
"objc2-foundation 0.2.2",
]
[[package]]
name = "oem_cp"
version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c7959d3349f484aec36462f98226983578bf7d33f9b034c32967489c7000e1f"
dependencies = [
"phf 0.11.3",
]
[[package]]
name = "once_cell"
version = "1.21.4"
@@ -2800,6 +2814,15 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "phf"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
dependencies = [
"phf_shared 0.11.3",
]
[[package]]
name = "phf"
version = "0.13.1"
@@ -2807,7 +2830,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
dependencies = [
"phf_macros",
"phf_shared",
"phf_shared 0.13.1",
"serde",
]
@@ -2818,7 +2841,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
dependencies = [
"fastrand",
"phf_shared",
"phf_shared 0.13.1",
]
[[package]]
@@ -2828,12 +2851,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef"
dependencies = [
"phf_generator",
"phf_shared",
"phf_shared 0.13.1",
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "phf_shared"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
dependencies = [
"siphasher",
]
[[package]]
name = "phf_shared"
version = "0.13.1"
@@ -4772,7 +4804,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.6.2"
version = "0.7.0"
dependencies = [
"serde_json",
]
+1 -1
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
# default-members
[workspace.package]
version = "0.6.2"
version = "0.7.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
+18 -1
View File
@@ -111,7 +111,7 @@ numbers):
| Layouts & plotting | Paper-space layouts, viewports (scale, lock, per-viewport layer freeze), MSPACE/PSPACE through viewports, page setups, PLOT and EXPORTPDF |
| Parametric | Geometric and dimensional constraints, AUTOCONSTRAIN, PARAMETERS with expressions, conflict detection; constraints re-solve after every edit |
| Grips | Hot grips with Space to cycle Stretch, Move, Rotate, Scale and Mirror |
| Interface languages | English, Ukrainian (Українська), Brazilian Portuguese (Português (Brasil)), German (Deutsch), Spanish (Español), Czech (Čeština), Russian (Русский), French (Français) and Simplified Chinese (简体中文, with an installed CJK font), live switching and a remembered Auto/system-language choice; [details](docs/localization.md) |
| Interface languages | English, Ukrainian (Українська), Brazilian Portuguese (Português (Brasil)), German (Deutsch), Spanish (Español), Czech (Čeština), Russian (Русский), French (Français), Simplified Chinese (简体中文, with an installed CJK font), Italian (Italiano), Korean (한국어, with an installed Hangul font), Swedish (Svenska) and Arabic (العربية, with an installed Arabic font), live switching and a remembered Auto/system-language choice; [details](docs/localization.md) |
| Automation | MCP server, JSON control channel, `cadcraft-cli` (info, convert, run, commands, mcp) |
## Quick start
@@ -252,8 +252,25 @@ have accessible labels and visible keyboard focus. Property controls remain appl
## Accessibility
### System cursor
Over the drawing area CADCraft hides the system cursor and draws its own crosshair. Tools that follow the system cursor (Windows Magnifier set to follow or centre the mouse pointer, other screen magnifiers, screen recorders, remote desktops) lose track of it there. Turn on **View ▸ Accessibility ▸ Show System Cursor** to keep a small system crosshair visible at the centre of the drawn one, or start CADCraft with the environment variable `CADCRAFT_SYSTEM_CURSOR=1` (on Windows, set it once with `setx CADCRAFT_SYSTEM_CURSOR 1` and start CADCraft again). Agents can switch it with the control channel: `ui.set {"systemCursor": true}`.
### Animation effects
CADCraft follows the operating system's animation setting. When the system is set to show no animations, dialogs, menus and tooltips appear at once instead of fading in, collapsing sections and scroll bars change at once, and the mouse wheel scrolls lists a whole step at a time instead of gliding. Zooming the drawing with the wheel always moves a whole step; with Ctrl held (a touchpad pinch, on Windows) it glides to the new zoom only while animation effects show.
| System | Where the setting is |
|---|---|
| Windows | Settings ▸ Accessibility ▸ Visual effects ▸ Animation effects |
| macOS | System Settings ▸ Accessibility ▸ Reduce motion |
| Linux, BSD | the desktop's reduced-motion (or animations) setting, read through the XDG Desktop Portal |
| Web | the browser's `prefers-reduced-motion` |
A change to the system setting is picked up while CADCraft runs, within a few seconds. To choose for CADCraft alone, use **View ▸ Accessibility ▸ Animation Effects**: **Use System Setting** (the default), **On** or **Off**. Options ▸ Display has the same choice. Agents can set it with the control channel: `ui.set {"animations": "off"}` (`"system"`, `"on"` or `"off"`), and `ui.inspect` reports whether animations show (`animations`).
Drafting feedback is not an animation effect and stays as it is: rubber bands and previews, snap markers, the pause that acquires an object snap tracking point, the text cursor's blink, the spinner shown while a drawing opens, and pan and zoom, which follow the pointer and the wheel directly.
## Roadmap
See [ROADMAP.md](ROADMAP.md) for the stage, measured parity and the estimate of remaining work, and [docs/gaps.md](docs/gaps.md) for everything still missing.
+6 -2
View File
@@ -10,7 +10,7 @@ scriptable CLI, a web build, Linux and FreeBSD builds, and a free licence.
**Why pre-alpha:** four of AutoCAD's five core workflows fail the [alpha gate](docs/roadmap.md#alpha-gate).
Precise drafting lacks object snap tracking and has stubbed TRIM/EXTEND/OFFSET options; blocks
can't be edited (no block editor, REFEDIT or xrefs); plots ignore plot styles; and a DWG saved by
can't be edited in a block editor (REFEDIT edits them in place, xrefs not yet); plots ignore plot styles; and a DWG saved by
CADCraft drops everything it doesn't model and has never been checked in
AutoCAD. Dimensioning passes. Ready for real work (≈ 38%) is also under the ~40% alpha bar. Beta
is ≈ 600–930 h away.
@@ -98,7 +98,7 @@ Ukrainian and German. Detail: [localization-parity.md](docs/localization-parity.
| Korean | none | 0% |
| Vietnamese | none | 0% |
Other languages shipped: Ukrainian and German, both partial (989 catalog rows each; Ukrainian landed with #36; engine messages stay English).
Other languages shipped: Ukrainian and German (989 catalog rows each; Ukrainian landed with #36), Brazilian Portuguese, Spanish, Czech, Russian, French, Simplified Chinese and Italian (1,227 rows, 2026-10-12); engine messages stay English.
## Upcoming
@@ -118,6 +118,8 @@ Ranked; detail and the full beta checklist in [docs/roadmap.md](docs/roadmap.md)
| Date | Entry |
|---|---|
| 2026-10-12 | TRIM: a pick after a closed polyline's last cut keeps only the complementary run instead of an extra circuit of the outline (#726) |
| 2026-10-12 | HATCHEDIT refuses an explicit bad `scale` or `angle` before it edits anything, so a rejected request leaves the drawing, undo and redo as they were (#724) |
| 2026-10-10 | Merged main: Ukrainian interface (#36, first string catalog), Dynamic Input pointer boxes with Tab and relative entry (#60), press-drag selection windows (#63), WIPEOUT and hatch gradients kept through DXF/DWG save (#147, #162), SETVAR DIM* as style overrides (#129), UNITS dialog (#38), close-window save prompt (#47), DIMANGULAR on circles (#20), cargo fmt fixed on main. UI/UX 40% → 43% (dynamic input 15% → 45%), localization 3% → 5%; ready for real work ≈ 37% → 38% (weighted 37.2% → 37.7%), mainstream 27% → 28%, essentials 45% → 46%. Beta is now ~37 points away; hours unchanged within rounding. |
| 2026-10-10 | Added mainstream practitioner (≈ 27%) and essentials user (≈ 45%) numbers; ready for real work unchanged at ≈ 37%. User evidence: 2 praise comments, 0 "switched from AutoCAD" reports, 27 of 37 open issues on the core path. |
| 2026-10-10 | Stage re-normalized from alpha to **pre-alpha** under the craftrules core-workflow gate: drafting with tracking, blocks, plotting and lossless DWG save fail; dimensioning passes. Alpha is ≈ 125–190 h away. |
@@ -131,6 +133,8 @@ Ranked; detail and the full beta checklist in [docs/roadmap.md](docs/roadmap.md)
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | trivial | Progress log: closed-polyline TRIM (#726) |
| 2026-10-12 | trivial | Progress log: HATCHEDIT numeric validation (#724) |
| 2026-10-10 | minor | Merged main's changes (Ukrainian catalog, dynamic input pointer boxes, DXF fixes); UI/UX and localization numbers updated |
| 2026-10-10 | minor | Readiness-by-audience table with hours to ~95% for each number |
| 2026-10-10 | minor | Mainstream practitioner and essentials user headline numbers |
+47 -12
View File
@@ -5,7 +5,7 @@
//! cadcraft-cli convert IN.dxf OUT.(dxf|svg|png) [--window x0,y0,x1,y1 | --view extents|current]
//! cadcraft-cli run [FILE|--sample|--metric] [--script TEXT|--script-file F.scr] [--cmd 'id {json}']... [--save OUT] [--export OUT.png] [--window ... | --view ...]
//! cadcraft-cli commands [FILTER] command catalog (JSON)
//! cadcraft-cli mcp [--connect HOST:PORT] MCP server on stdio (headless or bridged to the app)
//! cadcraft-cli mcp [--connect HOST:PORT [--lazy] [--launch]] MCP server on stdio (headless or bridged to the app)
//! cadcraft-cli perf [N] timing table on a synthetic N-entity drawing
//! cadcraft-cli perf FILE canvas display-list build of a drawing, zoomed in step by step
//! cadcraft-cli sample (bracket|floorplan) OUT write a built-in sample drawing
@@ -60,7 +60,7 @@ const USAGE: &str = "usage:
cadcraft-cli convert IN.dxf OUT.(dxf|dwg|svg|png|pdf) [FRAMING]
cadcraft-cli run [FILE | --sample | --metric] [--script TEXT] [--script-file F.scr] [--cmd 'id {json}']... [--save OUT.dxf] [--export OUT.(png|svg|pdf)] [FRAMING]
cadcraft-cli commands [FILTER]
cadcraft-cli mcp [--connect HOST:PORT]
cadcraft-cli mcp [--connect HOST:PORT [--lazy] [--launch]]
cadcraft-cli perf [N | FILE]
cadcraft-cli sample (bracket|floorplan) OUT.(dxf|dwg|svg|png)
cadcraft-cli --version
@@ -172,6 +172,11 @@ fn install_io() {
cadcraft_engine::cmd::file::set_write_as(|d, name, o| {
cadcraft_io::write_with(d, name, &cadcraft_io::SaveOptions { version: o.version, binary: o.binary }).map_err(|e| e.to_string())
});
// AutoLISP's entget, entmake and entmod see entities as DXF groups.
cadcraft_engine::cmd::file::set_entity_codec(cadcraft_engine::cmd::file::EntityCodec {
groups: cadcraft_io::entity_groups,
entity: cadcraft_io::entity_from_groups,
});
}
fn open(s: &mut Session, path: &str) -> Result<(), String> {
@@ -267,10 +272,24 @@ fn convert(args: &[String]) -> Result<(), String> {
check_framed_output(framing, Some(o))?;
let mut s = Session::empty();
open(&mut s, i)?;
if let Some(m) = skipped_message(s.doc().map_err(|e| e.to_string())?, i) {
eprintln!("{m}");
}
let window = export_window(&s, framing, None)?;
export_framed(&s, o, window)
}
/// The warning for the records of `path` the reader could not place, or `None` when there are none.
fn skipped_message(d: &cadcraft_engine::doc::Drawing, path: &str) -> Option<String> {
let n = d.skipped_records.values().fold(0usize, |a, n| a.saturating_add(*n));
if n == 0 {
return None;
}
let list: Vec<String> = d.skipped_records.iter().map(|(section, n)| format!("{section}: {n}")).collect();
let records = if n == 1 { "record" } else { "records" };
Some(format!("{path}: {n} {records} could not be read and are left out ({})", list.join(", ")))
}
fn run(args: &[String]) -> Result<(), String> {
let mut s = Session::empty();
let mut it = args.iter();
@@ -524,37 +543,47 @@ fn perf(args: &[String]) -> Result<(), String> {
enum McpTarget {
/// An in-process drawing session (no `--connect`).
Headless,
/// The running app's control channel at `HOST:PORT`.
Connect(String),
/// The running app's control channel at `HOST:PORT`; `lazy`: reached at the first call;
/// `launch`: opened then if it isn't running.
Connect { addr: String, lazy: bool, launch: bool },
}
/// Parses the arguments after `mcp`. `--connect` must be followed by a `HOST:PORT`; a missing,
/// empty or flag-like value is an error, never a silent fall back to a headless session (#103).
fn parse_mcp_args(args: &[String]) -> Result<McpTarget, String> {
let mut target = McpTarget::Headless;
let mut addr: Option<String> = None;
let (mut lazy, mut launch) = (false, false);
let mut it = args.iter();
while let Some(a) = it.next() {
match a.as_str() {
"--connect" => {
let addr = it
let a = it
.next()
.map(|v| v.trim())
.filter(|v| !v.is_empty() && !v.starts_with('-'))
.ok_or("--connect needs HOST:PORT (the app's --control port, e.g. 127.0.0.1:7979)")?;
if target != McpTarget::Headless {
if addr.is_some() {
return Err("--connect given more than once".into());
}
target = McpTarget::Connect(addr.to_string());
addr = Some(a.to_string());
}
"--lazy" => lazy = true,
"--launch" => launch = true,
other => return Err(format!("mcp: unknown argument {other}\n{USAGE}")),
}
}
Ok(target)
match addr {
// `--launch` connects at the first call too (the app opens then).
Some(addr) => Ok(McpTarget::Connect { addr, lazy: lazy || launch, launch }),
None if lazy || launch => Err("--lazy and --launch need --connect HOST:PORT".into()),
None => Ok(McpTarget::Headless),
}
}
fn mcp(args: &[String]) -> Result<(), String> {
let backend: Box<dyn cadcraft_mcp::Backend> = match parse_mcp_args(args)? {
McpTarget::Connect(addr) => match cadcraft_mcp::Remote::connect(&addr) {
McpTarget::Connect { addr, lazy: true, launch } => Box::new(cadcraft_mcp::Remote::lazy(&addr, launch)),
McpTarget::Connect { addr, .. } => match cadcraft_mcp::Remote::connect(&addr) {
Ok(r) => Box::new(r),
Err(e) => return Err(format!("cadcraft-cli: cannot connect to {addr}: {e}")),
},
@@ -580,8 +609,14 @@ mod mcp_args_tests {
#[test]
fn mcp_connect_with_address_selects_remote() {
assert_eq!(parse_mcp_args(&args(&["--connect", "127.0.0.1:7979"])), Ok(McpTarget::Connect("127.0.0.1:7979".into())));
assert_eq!(parse_mcp_args(&args(&["--connect", "localhost:7979"])), Ok(McpTarget::Connect("localhost:7979".into())));
let to = |addr: &str, lazy, launch| Ok(McpTarget::Connect { addr: addr.into(), lazy, launch });
assert_eq!(parse_mcp_args(&args(&["--connect", "127.0.0.1:7979"])), to("127.0.0.1:7979", false, false));
assert_eq!(parse_mcp_args(&args(&["--connect", "localhost:7979"])), to("localhost:7979", false, false));
assert_eq!(parse_mcp_args(&args(&["--lazy", "--connect", "localhost:7979"])), to("localhost:7979", true, false));
assert_eq!(parse_mcp_args(&args(&["--connect", "localhost:7979", "--launch"])), to("localhost:7979", true, true));
// Without an app to reach they mean nothing: never a headless session.
assert!(parse_mcp_args(&args(&["--lazy"])).is_err());
assert!(parse_mcp_args(&args(&["--launch"])).is_err());
}
#[test]
@@ -0,0 +1,44 @@
//! `info` reports the records a DXF file lost on reading, by section, and `convert` warns about
//! them before writing the copy (#865). The exit code stays 0.
use std::process::{Command, Output};
fn cli(args: &[&str]) -> Output {
Command::new(env!("CARGO_BIN_EXE_cadcraft-cli")).args(args).output().unwrap()
}
/// A LINE after the last ENDSEC and a VERTEX with no POLYLINE before it.
const DAMAGED: &str = "0\nSECTION\n2\nENTITIES\n0\nLINE\n8\n0\n10\n0\n20\n0\n11\n5\n21\n5\n0\nVERTEX\n8\n0\n10\n1\n20\n1\n0\nENDSEC\n0\nLINE\n8\n0\n10\n9\n20\n9\n11\n8\n21\n8\n0\nEOF\n";
#[test]
fn info_counts_and_convert_warns_about_skipped_records() {
let dir = std::env::temp_dir().join(format!("cc-cli-skipped-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let damaged = dir.join("damaged.dxf");
std::fs::write(&damaged, DAMAGED).unwrap();
let damaged = damaged.to_str().unwrap();
let out = cli(&["info", damaged]);
assert!(out.status.success(), "{:?}: {}", out.status, String::from_utf8_lossy(&out.stderr));
let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
assert_eq!(v["skipped"], serde_json::json!({ "count": 2, "sections": { "(no section)": 1, "ENTITIES": 1 } }));
let copy = dir.join("copy.dxf");
let out = cli(&["convert", damaged, copy.to_str().unwrap()]);
assert!(out.status.success(), "{:?}: {}", out.status, String::from_utf8_lossy(&out.stderr));
let err = String::from_utf8_lossy(&out.stderr);
let warnings: Vec<&str> = err.lines().filter(|l| l.contains("could not be read")).collect();
assert_eq!(warnings.len(), 1, "{err}");
assert!(warnings[0].contains("2 records") && warnings[0].contains("ENTITIES: 1") && warnings[0].contains("(no section): 1"), "{err}");
// An intact file: a zero count, and no warning.
let sample = dir.join("sample.dxf");
assert!(cli(&["sample", "floorplan", sample.to_str().unwrap()]).status.success());
let out = cli(&["info", sample.to_str().unwrap()]);
let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
assert_eq!(v["skipped"], serde_json::json!({ "count": 0, "sections": {} }));
let out = cli(&["convert", sample.to_str().unwrap(), copy.to_str().unwrap()]);
assert!(out.status.success());
assert!(!String::from_utf8_lossy(&out.stderr).contains("could not be read"), "{}", String::from_utf8_lossy(&out.stderr));
let _ = std::fs::remove_dir_all(&dir);
}
+1 -1
View File
@@ -29,4 +29,4 @@ web-sys = { version = "0.3", features = ["HtmlInputElement", "FileList", "File"]
eframe = { workspace = true, features = ["persistence", "default_fonts", "wgpu", "accesskit"] }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Location", "Navigator", "Blob", "BlobPropertyBag", "Url", "MessageEvent", "UrlSearchParams"] }
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Location", "Navigator", "Blob", "BlobPropertyBag", "Url", "MessageEvent", "UrlSearchParams", "MediaQueryList"] }
+15 -5
View File
@@ -23,14 +23,16 @@ mod web {
const CANVAS_ID: &str = "cadcraft_canvas";
const LOADING_ID: &str = "cadcraft_loading";
/// The app, the files File > Open… and drag and drop read for it in the background, and the
/// page embedding it (`?host=parent`).
struct Shell(CadApp, crate::upload::Inbox, Option<(crate::host::Bridge, crate::host::DirtyReport)>);
/// The app, the files File > Open… and drag and drop read for it in the background, the
/// page embedding it (`?host=parent`), and the browser's `prefers-reduced-motion` (it stays
/// live, so View ▸ Accessibility ▸ Animation Effects ▸ Use System Setting follows a change).
struct Shell(CadApp, crate::upload::Inbox, Option<(crate::host::Bridge, crate::host::DirtyReport)>, Option<web_sys::MediaQueryList>);
impl eframe::App for Shell {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
self.1.take_dropped(ctx);
self.1.drain(&mut self.0);
self.0.system_animations = self.3.as_ref().map(|reduced| !reduced.matches());
self.0.logic(ctx);
if let Some((bridge, reported)) = &self.2 {
bridge.report_dirty(&self.0, reported);
@@ -80,6 +82,11 @@ mod web {
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,
});
// `?host=parent`: a same-origin page framing CADCraft (such as a Nextcloud app) opens
// drawings in it and stores what it saves. Otherwise there is no file system here: saved
// files reach the user as browser downloads.
@@ -124,7 +131,9 @@ mod web {
if !crate::wants_cpu(&query())
&& let Some(rs) = &cc.wgpu_render_state
{
app.set_wgpu(rs);
// eframe's web runner has no stencil buffer: clipped layout
// viewports stay in the layout's display list.
app.set_wgpu(rs, None);
}
if query().contains("sample") {
let _ = app.run("ui.sample", serde_json::json!({}));
@@ -142,7 +151,8 @@ mod web {
bridge.post_ready();
(bridge, crate::host::DirtyReport::default())
});
Ok(Box::new(Shell(app, inbox, host)))
let reduced_motion = web_sys::window().and_then(|w| w.match_media("(prefers-reduced-motion: reduce)").ok().flatten());
Ok(Box::new(Shell(app, inbox, host, reduced_motion)))
}),
)
.await;
+6
View File
@@ -35,10 +35,16 @@ sys-locale = "0.3"
[target.'cfg(target_os = "macos")'.dependencies]
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.
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSWorkspace", "NSAccessibility"] }
# Rust HID backends: no proprietary SDK or C HID library.
[target.'cfg(target_os = "windows")'.dependencies]
hidapi = { version = "2.6.7", default-features = false, features = ["windows-native"] }
# View ▸ Accessibility ▸ Animation Effects ▸ Use System Setting: Windows' Animation effects
# setting (system_animations.rs). Already in Cargo.lock through wgpu's DirectX 12 backend.
windows = { version = "0.62", features = ["UI_ViewManagement"] }
[target.'cfg(target_os = "linux")'.dependencies]
hidapi = { version = "2.6.7", default-features = false, features = ["linux-native-basic-udev"] }
+50 -3
View File
@@ -22,6 +22,7 @@ mod logging;
#[cfg(target_os = "macos")]
mod native_menu;
mod spacemouse;
mod system_animations;
mod workspace;
use cadcraft_engine::Session;
@@ -32,6 +33,8 @@ struct App {
spacemouse: spacemouse::SpaceMouse,
/// The desktop's light/dark choice where winit reports none (Linux, the BSDs).
desktop_theme: desktop_theme::DesktopTheme,
/// Whether the OS shows animations, for the System choice of View ▸ Accessibility ▸ Animation Effects.
system_animations: system_animations::SystemAnimations,
workspace: workspace::Workspace,
workspace_error: Option<String>,
#[cfg(target_os = "macos")]
@@ -72,6 +75,7 @@ impl eframe::App for App {
m.poll(&mut self.cad, ctx);
}
}
self.cad.system_animations = self.system_animations.current();
self.cad.logic(ctx);
self.spacemouse.poll(&mut self.cad, ctx);
self.persist_workspace(false);
@@ -115,6 +119,11 @@ pub fn install_io() {
cadcraft_engine::cmd::file::set_write_as(|d, name, o| {
cadcraft_io::write_with(d, name, &cadcraft_io::SaveOptions { version: o.version, binary: o.binary }).map_err(|e| e.to_string())
});
// AutoLISP's entget, entmake and entmod see entities as DXF groups.
cadcraft_engine::cmd::file::set_entity_codec(cadcraft_engine::cmd::file::EntityCodec {
groups: cadcraft_io::entity_groups,
entity: cadcraft_io::entity_from_groups,
});
}
fn services() -> Services {
@@ -175,6 +184,9 @@ fn config_dir_from(var: impl Fn(&str) -> Option<std::ffi::OsString>) -> Option<s
const APP_ID: &str = "ai.storyteller.cadcraft";
/// Stencil bits of the window's render pass (see `NativeOptions::stencil_buffer`).
const STENCIL_BITS: u8 = 8;
#[cfg(target_os = "macos")]
const APP_ICON_PNG: &[u8] = include_bytes!("../../../assets/app-icon/cadcraft-macos-512.png");
#[cfg(not(target_os = "macos"))]
@@ -200,6 +212,19 @@ fn control_port_from(source: &str, value: Option<String>) -> Option<u16> {
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
}
fn main() -> eframe::Result {
// First, so every start-up record is captured; see `logging`.
let logger = logging::install();
@@ -211,7 +236,7 @@ fn main() -> eframe::Result {
let mut args = std::env::args().skip(1);
while let Some(a) = args.next() {
match a.as_str() {
"--control" => control_port = control_port_from("--control", args.next()),
"--control" => control_port = control_flag(control_port, args.next()),
"--sample" => sample = true,
"--version" | "-V" => {
println!("{}", version_string());
@@ -248,6 +273,8 @@ fn main() -> eframe::Result {
.with_title_shown(false)
.with_app_id(APP_ID),
persist_window: false,
// A stencil buffer clips the contents of clipped layout viewports on the GPU.
stencil_buffer: STENCIL_BITS,
..Default::default()
};
if let Some(icon) = app_icon() {
@@ -304,13 +331,20 @@ fn main() -> eframe::Result {
// Autosave files (SAVEFILEPATH) default to `<settings dir>/autosave`.
if let Some(dir) = config_dir() {
app.enable_autosave(&dir.join("autosave"));
// Startup LISP (acad.lsp, acaddoc.lsp) is read from `<settings>/support`; the
// folder is made so it is there to put them in.
let support = dir.join("support");
if let Err(e) = std::fs::create_dir_all(&support) {
log::warn!("no LISP support folder {}: {e}", support.display());
}
app.session.set_lisp_support_dir(Some(support));
}
app.integrated_titlebar = cfg!(target_os = "macos");
if surface.present_mode == eframe::wgpu::PresentMode::AutoNoVsync {
app.frame_cap = Some(cadcraft_ui_egui::gpu::FrameCap::new(240));
}
if let Some(rs) = &cc.wgpu_render_state {
app.set_wgpu(rs);
app.set_wgpu(rs, eframe::egui_wgpu::depth_format_from_bits(0, STENCIL_BITS));
}
if let Some(port) = control_port {
let rx = control_server::start(port, cc.egui_ctx.clone());
@@ -327,8 +361,11 @@ fn main() -> eframe::Result {
}
let ctx = cc.egui_ctx.clone();
let desktop_theme = desktop_theme::DesktopTheme::start(move || ctx.request_repaint());
let ctx = cc.egui_ctx.clone();
let system_animations = system_animations::SystemAnimations::start(move || ctx.request_repaint());
Ok(Box::new(App {
desktop_theme,
system_animations,
cad: app,
spacemouse: spacemouse::SpaceMouse::start(cc.egui_ctx.clone()),
workspace,
@@ -342,7 +379,7 @@ fn main() -> eframe::Result {
#[cfg(test)]
mod tests {
use super::{config_dir_from, control_port_from, filters_for_save, follow_desktop};
use super::{config_dir_from, control_flag, control_port_from, filters_for_save, follow_desktop};
use std::ffi::OsString;
use std::path::PathBuf;
@@ -376,6 +413,16 @@ mod tests {
}
}
#[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")];
+23 -1
View File
@@ -16,6 +16,9 @@ pub struct NativeMenu {
predefined: Vec<(&'static str, PredefinedMenuItem)>,
language: &'static str,
last_refresh: f64,
/// The recent files File ▸ Open Recent lists now (it is rebuilt when they change).
recent: Vec<String>,
counter: usize,
}
/// Accelerator for "Cmd+Shift+S" (modifier-less shortcuts stay in the app so typing works).
@@ -63,7 +66,25 @@ impl NativeMenu {
submenus.push((title, sub));
}
menu.init_for_nsapp();
Self { _menu: menu, items, submenus, predefined, language, last_refresh: 0.0 }
Self { _menu: menu, items, submenus, predefined, language, last_refresh: 0.0, recent: app.ui.recent_files.clone(), counter }
}
/// Rebuild File ▸ Open Recent after a drawing was opened or the list was cleared: the native
/// menu is built once, and its items name the files by position.
fn refresh_recent(&mut self, app: &CadApp) {
if self.recent == app.ui.recent_files {
return;
}
self.recent = app.ui.recent_files.clone();
let Some(sub) = self.submenus.iter().find(|(label, _)| label == menus::OPEN_RECENT).map(|(_, s)| s.clone()) else { return };
for _ in sub.items() {
sub.remove_at(0);
}
let ids: Vec<String> = self.items.iter().filter(|(_, (cmd, _, _))| cmd.starts_with("ui.recent.")).map(|(id, _)| id.clone()).collect();
for id in ids {
self.items.remove(&id);
}
build(&sub, &menus::recent_entries(app), &mut self.items, &mut self.submenus, &mut self.counter, self.language);
}
pub fn poll(&mut self, app: &mut CadApp, ctx: &egui::Context) {
@@ -77,6 +98,7 @@ impl NativeMenu {
ctx.request_repaint();
}
}
self.refresh_recent(app);
let language = app.language();
if self.language != language {
self.language = language;
+182
View File
@@ -0,0 +1,182 @@
//! Whether the operating system shows animations, for View ▸ Accessibility ▸ Animation Effects ▸
//! Use System Setting.
//!
//! egui animates whatever the OS says (windows and menus fade in, wheel scrolling glides), and
//! winit reports no such setting. This asks the OS itself:
//!
//! - Windows: Settings ▸ Accessibility ▸ Visual effects ▸ Animation effects.
//! - macOS: System Settings ▸ Accessibility ▸ Reduce motion.
//! - Linux and the BSDs: the XDG Desktop Portal's Settings interface, as for the theme
//! (`desktop_theme.rs`): its `reduced-motion`, or GNOME's own `enable-animations`.
//!
//! The answer goes to `CadApp::system_animations` once a frame.
use std::sync::mpsc::{Receiver, TryRecvError, channel};
use std::time::{Duration, Instant};
/// How often the OS is asked again, so a change to its setting is followed while CADCraft runs.
const POLL: Duration = Duration::from_secs(3);
/// Watches the OS setting.
#[derive(Default)]
pub struct SystemAnimations {
/// The watcher thread's answers, where asking can block (the portal); `None` elsewhere, or
/// when no thread could be started.
changes: Option<Receiver<Option<bool>>>,
/// The OS's last answer.
current: Option<bool>,
/// When the OS was last asked from the UI thread, where asking is one quick call.
asked: Option<Instant>,
}
impl SystemAnimations {
/// Start watching. Where a thread does the asking, `wake` is called after each answer, so an
/// idle window repaints and picks it up.
pub fn start(wake: impl Fn() + Send + 'static) -> Self {
if !ASKS_THE_PORTAL {
return Self::default();
}
let (tx, rx) = channel();
let started = std::thread::Builder::new().name("cadcraft-system-animations".into()).spawn(move || {
let mut last = read();
if tx.send(last).is_err() {
return;
}
wake();
loop {
std::thread::sleep(POLL);
let now = read();
if now != last {
last = now;
if tx.send(now).is_err() {
return;
}
wake();
}
}
});
Self { changes: started.ok().map(|_| rx), ..Self::default() }
}
/// Whether the OS shows animations; `None` when it states nothing or there is nobody to ask.
/// Polled once a frame; an OS that stops answering keeps its last answer.
pub fn current(&mut self) -> Option<bool> {
if let Some(changes) = &self.changes {
loop {
match changes.try_recv() {
Ok(Some(animate)) => self.current = Some(animate),
Ok(None) => {}
Err(TryRecvError::Empty) | Err(TryRecvError::Disconnected) => break,
}
}
} else if !ASKS_THE_PORTAL && self.asked.is_none_or(|at| at.elapsed() >= POLL) {
self.asked = Some(Instant::now());
if let Some(animate) = read() {
self.current = Some(animate);
}
}
self.current
}
}
/// Where the answer comes over D-Bus, which can block: a thread asks. Elsewhere the UI thread asks.
const ASKS_THE_PORTAL: bool = cfg!(all(unix, not(target_os = "macos")));
/// Ask Windows once: `UISettings::AnimationsEnabled`, the setting behind
/// `SPI_GETCLIENTAREAANIMATION`.
#[cfg(windows)]
fn read() -> Option<bool> {
windows::UI::ViewManagement::UISettings::new().and_then(|settings| settings.AnimationsEnabled()).ok()
}
/// Ask macOS once. Called on the main thread (`SystemAnimations::current`).
#[cfg(target_os = "macos")]
fn read() -> Option<bool> {
Some(!objc2_app_kit::NSWorkspace::sharedWorkspace().accessibilityDisplayShouldReduceMotion())
}
/// Ask the portal once. D-Bus calls block, so this belongs off the UI thread: the watcher's
/// thread is its only caller.
#[cfg(all(unix, not(target_os = "macos")))]
fn read() -> Option<bool> {
const PORTAL: &str = "org.freedesktop.portal.Desktop";
const PATH: &str = "/org/freedesktop/portal/desktop";
const SETTINGS: &str = "org.freedesktop.portal.Settings";
// The standard key first (0 no preference, 1 reduced motion); then GNOME's own switch, which
// its portal answered before the standard key existed.
const KEYS: [(&str, &str); 2] = [("org.freedesktop.appearance", "reduced-motion"), ("org.gnome.desktop.interface", "enable-animations")];
let connection = zbus::blocking::Connection::session().ok()?;
for (namespace, key) in KEYS {
// `ReadOne` (Settings version 2) answers the value; older portals only have `Read`, which
// wraps it in one more variant. `parse` unwraps either.
let reply = connection
.call_method(Some(PORTAL), PATH, Some(SETTINGS), "ReadOne", &(namespace, key))
.or_else(|_| connection.call_method(Some(PORTAL), PATH, Some(SETTINGS), "Read", &(namespace, key)));
let Ok(reply) = reply else { continue };
let Ok((answer,)) = reply.body().deserialize::<(zbus::zvariant::OwnedValue,)>() else { continue };
if let Some(animate) = parse(&answer, 0) {
return Some(animate);
}
}
None
}
/// The portal's answer as "show animations": `reduced-motion` is a number (1 asks for less
/// motion; 0 and values from a later version state no preference), `enable-animations` a
/// boolean. `None` when it reads as neither. Variants are unwrapped a few levels deep.
#[cfg(all(unix, not(target_os = "macos")))]
fn parse(value: &zbus::zvariant::Value<'_>, depth: u8) -> Option<bool> {
use zbus::zvariant::Value;
match value {
Value::U32(reduced) => Some(*reduced != 1),
Value::Bool(enabled) => Some(*enabled),
Value::Value(inner) if depth < 4 => parse(inner, depth + 1),
_ => None,
}
}
#[cfg(not(any(unix, windows)))]
fn read() -> Option<bool> {
None
}
// macOS is asked on the main thread only, so there is nothing to test off it.
#[cfg(all(test, not(target_os = "macos")))]
mod tests {
use super::*;
/// Windows is asked on the first frame, then not again until the poll interval has passed.
#[test]
#[cfg(windows)]
fn the_os_is_asked_on_the_first_frame_and_its_answer_kept() {
let mut watch = SystemAnimations::start(|| {});
assert!(watch.changes.is_none(), "no thread: asking is one quick call");
let first = watch.current();
assert_eq!(first, read());
let asked = watch.asked;
assert!(asked.is_some());
assert_eq!(watch.current(), first);
assert_eq!(watch.asked, asked, "not asked again within the poll interval");
}
/// The standard key answers a number, GNOME's switch a boolean; either may come wrapped in
/// variants (the older `Read`).
#[test]
#[cfg(all(unix, not(target_os = "macos")))]
fn the_portal_answer_reads_as_show_animations_or_as_none() {
use zbus::zvariant::Value;
assert_eq!(parse(&Value::U32(1), 0), Some(false));
assert_eq!(parse(&Value::U32(0), 0), Some(true));
assert_eq!(parse(&Value::U32(7), 0), Some(true), "unknown values are no preference");
assert_eq!(parse(&Value::Bool(false), 0), Some(false));
assert_eq!(parse(&Value::Bool(true), 0), Some(true));
assert_eq!(parse(&Value::Value(Box::new(Value::U32(1))), 0), Some(false));
assert_eq!(parse(&Value::Str("off".into()), 0), None);
// A hostile answer nested without end is given up on.
let mut nested = Value::U32(1);
for _ in 0..16 {
nested = Value::Value(Box::new(nested));
}
assert_eq!(parse(&nested, 0), None);
}
}
+13
View File
@@ -48,3 +48,16 @@ sync_github_name = true
[people.Losminek]
sync_github_name = true
[people.GJoe2]
sync_github_name = true
[people.holmbom]
real_name = "Erik Holmbom"
display_name = "Erik Holmbom"
sync_github_name = false
[people.cyfung1031]
real_name = "Chester Fung"
display_name = "Chester Fung"
sync_github_name = true
+5 -2
View File
@@ -500,11 +500,14 @@ impl Model {
}
(EntityKind::Arc(a), Shape::Arc) => {
if let [cx, cy, r, s0, s1] = new {
// A negative radius puts every point opposite its angle: turning both
// ends half a turn keeps the same points with a positive radius.
let turn = if *r < 0.0 { std::f64::consts::PI } else { 0.0 };
a.center.x = *cx;
a.center.y = *cy;
a.radius = r.abs().max(1e-12);
a.start = cadcraft_geom::norm_angle(*s0);
a.end = cadcraft_geom::norm_angle(*s1);
a.start = cadcraft_geom::norm_angle(*s0 + turn);
a.end = cadcraft_geom::norm_angle(*s1 + turn);
}
}
(EntityKind::LwPolyline(p), Shape::Poly { n, .. }) if p.vertices.len() == n => {
+26
View File
@@ -463,3 +463,29 @@ fn set_parameter_rejects_out_of_domain_dimension() {
set_parameter(&mut d, "rad1", "2", &opts).unwrap();
assert!(all_satisfied(&d));
}
/// The solver may meet a coincidence with a negative arc radius; the stored arc must still put
/// the referenced point where the solver did (issue #703), through `add` and through `solve`.
#[test]
fn arc_solved_with_a_negative_radius_keeps_its_points() {
use std::f64::consts::FRAC_PI_2;
// (arc centre, radius, start, end, point): a semicircle with the point behind it, and the
// asymmetric case from the issue.
let cases = [((0.0, 0.0), 1.0, -FRAC_PI_2, FRAC_PI_2, (-5.0, 0.0)), ((6.694, -0.686), 0.62075, 3.4824, 1.1766, (-7.562, 4.946))];
for (c, r, a0, a1, p) in cases {
for through_solve in [false, true] {
let mut d = Drawing::default();
let a = arc(&mut d, c, r, a0, a1);
let q = add_e(&mut d, EntityKind::Point(DPoint { p: v(p.0, p.1), angle: 0.0 }));
let k = con(ConstraintKind::Coincident, &[s(a, Sub::Mid), w(q)]);
if through_solve {
d.constraints.push(Constraint { id: 1, ..k });
solve(&mut d, &SolveOptions::default()).unwrap();
} else {
ok(&mut d, k);
}
assert!(all_satisfied(&d), "{c:?} solve {through_solve}: {:?}", residuals(&d));
assert!(circ(&d, a).1 > 0.0);
}
}
}
+11
View File
@@ -159,6 +159,9 @@ pub fn anno_scaled(e: &Entity, f: f64) -> Entity {
EntityKind::MText(t) => {
t.height *= f;
t.width *= f;
if let Some(c) = &mut t.columns {
c.scale(f);
}
}
EntityKind::Dimension(dm) => {
dm.overrides.insert("scale".into(), serde_json::json!(f));
@@ -172,6 +175,9 @@ pub fn anno_scaled(e: &Entity, f: f64) -> Entity {
t.insert = land + (t.insert - land) * f;
t.height *= f;
t.width *= f;
if let Some(c) = &mut t.columns {
c.scale(f);
}
}
if let Some(ins) = &mut m.block {
ins.insert = land + (ins.insert - land) * f;
@@ -188,6 +194,11 @@ pub fn anno_scaled(e: &Entity, f: f64) -> Entity {
a.text.insert = o + (a.text.insert - o) * f;
a.text.align_pt = a.text.align_pt.map(|p| o + (p - o) * f);
a.text.height *= f;
if let Some(ml) = &mut a.multiline {
ml.mtext.insert = o + (ml.mtext.insert - o) * f;
ml.mtext.height *= f;
ml.mtext.width *= f;
}
}
}
_ => {}
+1 -1
View File
@@ -35,7 +35,7 @@ impl Sub {
"mid" | "midpoint" | "m" => Sub::Mid,
"center" | "centre" | "c" => Sub::Center,
_ => {
for (p, vertex) in [("vertex", true), ("segment", false), ("seg", false), ("v", true)] {
for (p, vertex) in [("vertex", true), ("segment", false), ("seg", false), ("s", false), ("v", true)] {
if let Some(n) = l.strip_prefix(p) {
let i: u32 = n.trim().parse().ok()?;
return Some(if vertex { Sub::Vertex(i) } else { Sub::Segment(i) });
+561
View File
@@ -0,0 +1,561 @@
//! Dynamic blocks made in another program, read from the data CADCraft keeps for them.
//!
//! A dynamic block's behaviour lives in objects CADCraft does not write itself and keeps as
//! [`Foreign`](crate::foreign::Foreign) data: the block's BLOCK_RECORD has an extension dictionary
//! 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
//! 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
//! and unknown or damaged objects are skipped, never trusted.
use std::collections::HashMap;
use cadcraft_geom::{Vec2, Vec3};
use serde::Serialize;
use crate::foreign::ForeignObject;
use crate::{Drawing, Handle, RawTag};
mod apply;
mod grips;
mod reference;
pub use apply::{drag_grip, set_property};
pub use grips::{DynGrip, GripPart, GripShape, grips};
pub use reference::{CustomProperty, Reference, Value, custom_properties, reference};
/// Most objects one evaluation graph is read with.
const MAX_NODES: usize = 10_000;
/// Handles as hex strings in JSON, as commands report them.
fn hex<S: serde::Serializer>(h: &Handle, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(&h.hex())
}
fn hexes<S: serde::Serializer>(hs: &[Handle], s: S) -> Result<S::Ok, S::Error> {
s.collect_seq(hs.iter().map(|h| h.hex()))
}
fn hex_vertices<S: serde::Serializer>(v: &[(Handle, Vec<i32>)], s: S) -> Result<S::Ok, S::Error> {
s.collect_seq(v.iter().map(|(h, i)| (h.hex(), i)))
}
/// A dynamic block's parameters and actions.
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DynamicBlock {
pub parameters: Vec<Parameter>,
pub actions: Vec<Action>,
/// Graph members of types not modelled here (grips, constraints…), by DXF type.
pub other: Vec<String>,
/// The grip objects by graph id (parameters name their grips by id; visibility states list
/// the grips shown in them by object).
#[serde(skip)]
pub grip_objects: Vec<(i32, Handle)>,
}
/// One parameter: a property a reference to the block can change.
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Parameter {
/// Its id in the evaluation graph (actions and lookups refer to parameters by it).
pub node: i32,
#[serde(serialize_with = "hex")]
pub handle: Handle,
/// The name the block editor shows ("Linear", "Visibility State").
pub element: String,
/// The parameter's properties appear in a reference's Properties palette.
pub show_properties: bool,
pub kind: ParameterKind,
/// Graph ids of its grips: a two-point parameter's base and end grips (0 for none), a
/// one-point parameter's grip. A reference's history records grip drags by these.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub grips: Vec<i32>,
}
/// What a parameter is, with its definition geometry in block coordinates.
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")]
pub enum ParameterKind {
/// A distance between two points (Distance1).
Linear {
base: Vec3,
end: Vec3,
label: String,
description: String,
values: ValueSet,
/// The value changes the end point (`false`) or both points from the midpoint (`true`).
from_midpoint: bool,
},
/// A distance and an angle from a base point.
Polar { base: Vec3, end: Vec3 },
/// Horizontal and vertical distances from a base point.
Xy { base: Vec3, end: Vec3 },
/// An angle about a base point.
Rotation { base: Vec3, end: Vec3 },
/// Aligns the reference with nearby geometry.
Alignment { base: Vec3, end: Vec3 },
/// Mirrors the objects about a reflection line.
Flip { base: Vec3, end: Vec3 },
/// A position (Position1).
Point { at: Vec3, label: String, description: String },
/// Named sets of visible objects.
Visibility {
at: Vec3,
label: String,
description: String,
#[serde(serialize_with = "hexes")]
controlled: Vec<Handle>,
states: Vec<VisibilityState>,
},
/// A named value chosen from a lookup table.
Lookup { at: Vec3, label: String, description: String },
/// Moves the block's base point.
BasePoint { at: Vec3 },
/// A parameter type not modelled yet.
Other { dxf_type: String },
}
impl ParameterKind {
/// The property name a reference shows ("Distance1", "Visibility1"), when it has one.
pub fn label(&self) -> Option<&str> {
match self {
ParameterKind::Linear { label, .. }
| ParameterKind::Point { label, .. }
| ParameterKind::Visibility { label, .. }
| ParameterKind::Lookup { label, .. } => Some(label),
_ => None,
}
}
}
/// One visibility state: its name and the objects shown in it (the parameter's other controlled
/// objects are hidden).
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct VisibilityState {
pub name: String,
#[serde(serialize_with = "hexes")]
pub visible: Vec<Handle>,
/// The parameters and actions active in this state (their objects).
#[serde(serialize_with = "hexes")]
pub active: Vec<Handle>,
}
/// The values a distance or angle may take.
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ValueSet {
/// Which of the limits below apply (see the methods).
pub flags: i32,
pub minimum: f64,
pub maximum: f64,
pub increment: f64,
/// The allowed values, for a list.
pub list: Vec<f64>,
}
impl ValueSet {
pub fn has_minimum(&self) -> bool {
self.flags & 1 != 0
}
pub fn has_maximum(&self) -> bool {
self.flags & 2 != 0
}
pub fn has_increment(&self) -> bool {
self.flags & 4 != 0
}
pub fn is_list(&self) -> bool {
self.flags & 8 != 0
}
}
/// One action: what changing parameters does to the block's objects.
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Action {
pub node: i32,
#[serde(serialize_with = "hex")]
pub handle: Handle,
/// Its name in the block editor ("Stretch", "Lookup1").
pub name: String,
/// Where the block editor shows it.
pub at: Vec3,
/// The objects it acts on.
#[serde(serialize_with = "hexes")]
pub selection: Vec<Handle>,
/// Graph ids of the parameters it follows, with the parameter value each connection reads
/// ("EndXDelta").
pub connections: Vec<(i32, String)>,
pub kind: ActionKind,
}
/// What an action does.
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")]
pub enum ActionKind {
/// Moves the vertices inside `frame` of the objects listed (all of an object not listed).
Stretch {
frame: Vec<Vec2>,
#[serde(serialize_with = "hex_vertices")]
vertices: Vec<(Handle, Vec<i32>)>,
multiplier: f64,
angle_offset: f64,
},
/// A table: each row sets the listed parameter values together.
Lookup {
columns: Vec<LookupColumn>,
rows: Vec<Vec<String>>,
},
/// Moves the objects by the change of the parameter values it follows.
Move {
multiplier: f64,
angle_offset: f64,
},
Scale,
Rotate,
Flip,
Array,
PolarStretch,
/// An action type not modelled yet.
Other {
dxf_type: String,
},
}
/// A lookup table column: the parameter value it reads or sets.
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LookupColumn {
/// The parameter's graph id.
pub node: i32,
/// The value it stands for ("VisibilityState", "lookupString").
pub property: String,
/// A lookup property (shown on the reference) rather than an input.
pub lookup: bool,
/// What the reference shows when no row matches ("Custom").
pub unmatched: String,
}
/// The groups of an object split at its subclass markers (100): `(marker, groups after it)`.
fn sections(tags: &[RawTag]) -> Vec<(&str, &[RawTag])> {
let mut out: Vec<(&str, &[RawTag])> = Vec::new();
let mut start = 0;
let mut name = "";
for (i, t) in tags.iter().enumerate() {
if t.code == 100 {
out.push((name, tags.get(start..i).unwrap_or_default()));
name = t.value.as_str();
start = i + 1;
}
}
out.push((name, tags.get(start..).unwrap_or_default()));
out
}
/// The groups after subclass marker `marker`, empty when the object has none.
fn section<'a>(secs: &[(&str, &'a [RawTag])], marker: &str) -> &'a [RawTag] {
secs.iter().find(|(n, _)| n.eq_ignore_ascii_case(marker)).map_or(&[], |(_, t)| t)
}
/// Reads groups in order, as an object writes them.
struct Cursor<'a> {
tags: &'a [RawTag],
at: usize,
/// Groups scanned so far. A missing group neither moves the cursor nor ends the loops that
/// ask for it, so a hostile record could make nested counts rescan it again and again; past
/// `budget` every lookup fails at once and every count is zero.
work: usize,
budget: usize,
}
impl<'a> Cursor<'a> {
fn new(tags: &'a [RawTag]) -> Self {
Cursor { tags, at: 0, work: 0, budget: tags.len().saturating_mul(16).saturating_add(1024) }
}
/// The next group with `code` from here on (the cursor moves past it).
fn next(&mut self, code: i32) -> Option<&'a str> {
if self.work >= self.budget {
return None;
}
let rest = self.tags.get(self.at..)?;
let Some(rel) = rest.iter().position(|t| t.code == code) else {
self.work = self.work.saturating_add(rest.len().max(1));
return None;
};
self.work = self.work.saturating_add(rel + 1);
self.at += rel + 1;
self.tags.get(self.at - 1).map(|t| t.value.as_str())
}
fn string(&mut self, code: i32) -> String {
self.next(code).unwrap_or_default().to_string()
}
fn int(&mut self, code: i32) -> i32 {
self.next(code).and_then(|v| v.trim().parse().ok()).unwrap_or(0)
}
fn real(&mut self, code: i32) -> f64 {
self.next(code).and_then(|v| v.trim().parse::<f64>().ok()).filter(|v| v.is_finite()).unwrap_or(0.0)
}
/// A count read from the file, capped by the groups left (each item needs at least one).
fn count(&mut self, code: i32) -> usize {
let n = usize::try_from(self.int(code)).unwrap_or(0).min(self.tags.len().saturating_sub(self.at));
if self.work >= self.budget { 0 } else { n }
}
fn handle(&mut self, code: i32) -> Option<Handle> {
self.next(code).and_then(Handle::parse_hex)
}
fn handles(&mut self, code: i32, n: usize) -> Vec<Handle> {
(0..n).filter_map(|_| self.handle(code)).collect()
}
/// A point written with codes `x`, `x + 10`, `x + 20`.
fn point(&mut self, x: i32) -> Vec3 {
let px = self.real(x);
let py = self.real(x + 10);
let at = self.at;
// Z is optional: only take it when it is the very next group.
let pz = match self.tags.get(at) {
Some(t) if t.code == x + 20 => self.real(x + 20),
_ => 0.0,
};
Vec3::new(px, py, pz)
}
}
/// The objects `xdict`'s `ACAD_ENHANCEDBLOCK` evaluation graph lists, when the dictionary has one.
fn graph_members<'a>(objects: &HashMap<Handle, &'a ForeignObject>, xdict: Handle) -> Option<Vec<&'a ForeignObject>> {
let dict = objects.get(&xdict)?;
let mut graph = None;
let mut tags = dict.tags.iter();
while let Some(t) = tags.next() {
if t.code == 3 && t.value.eq_ignore_ascii_case("ACAD_ENHANCEDBLOCK") {
graph = tags.next().filter(|t| t.code == 350 || t.code == 360).and_then(|t| Handle::parse_hex(&t.value));
break;
}
}
let graph = objects.get(&graph?).filter(|o| o.kind.eq_ignore_ascii_case("ACAD_EVALUATION_GRAPH"))?;
let members = section(&sections(&graph.tags), "AcDbEvalGraph").iter().filter(|t| t.code == 360).filter_map(|t| Handle::parse_hex(&t.value));
Some(members.take(MAX_NODES).filter_map(|h| objects.get(&h).copied()).collect())
}
/// The dynamic block data of block `name` (any case; `*Model_Space` for a drawing that is a
/// block's own drawing), or `None` when it has none.
pub fn dynamic_block(d: &Drawing, name: &str) -> Option<DynamicBlock> {
let xdict = d.foreign.records.get(&("BLOCK_RECORD".to_string(), name.trim().to_ascii_uppercase()))?.xdict?;
let objects: HashMap<Handle, &ForeignObject> = d.foreign.objects.iter().map(|o| (o.handle, o)).collect();
let mut out = DynamicBlock::default();
for o in graph_members(&objects, xdict)? {
let secs = sections(&o.tags);
let node = Cursor::new(section(&secs, "AcDbEvalExpr")).int(90);
let element = Cursor::new(section(&secs, "AcDbBlockElement")).string(300);
let kind = o.kind.to_ascii_uppercase();
if kind.ends_with("PARAMETER") {
let show_properties = Cursor::new(section(&secs, "AcDbBlockParameter")).int(280) != 0;
let grips = grip_ids(&secs);
out.parameters.push(Parameter { node, handle: o.handle, element, show_properties, kind: parameter(&kind, &secs), grips });
} else if kind.ends_with("ACTION") {
out.actions.push(action(o.handle, node, element, &kind, &secs));
} else {
if kind.ends_with("GRIP") {
out.grip_objects.push((node, o.handle));
}
out.other.push(o.kind.clone());
}
}
Some(out)
}
/// A parameter's grip ids: after a two-point parameter's points, the grip count (170) and the
/// base and end grips (91); after a one-point parameter's point, its grip (93).
fn grip_ids(secs: &[(&str, &[RawTag])]) -> Vec<i32> {
let two = section(secs, "AcDbBlock2PtParameter");
if !two.is_empty() {
let mut c = Cursor::new(two);
c.point(1010);
c.point(1011);
let n = c.count(170).min(2);
return (0..n).map(|_| c.int(91)).collect();
}
let mut c = Cursor::new(section(secs, "AcDbBlock1PtParameter"));
c.point(1010);
c.next(93).and_then(|v| v.trim().parse().ok()).into_iter().collect()
}
fn parameter(kind: &str, secs: &[(&str, &[RawTag])]) -> ParameterKind {
let two = || {
let mut c = Cursor::new(section(secs, "AcDbBlock2PtParameter"));
(c.point(1010), c.point(1011))
};
let one = || Cursor::new(section(secs, "AcDbBlock1PtParameter")).point(1010);
match kind {
"BLOCKLINEARPARAMETER" => {
let (base, end) = two();
let mut c = Cursor::new(section(secs, "AcDbBlockLinearParameter"));
let label = c.string(305);
let description = c.string(306);
let from_midpoint = Cursor::new(section(secs, "AcDbBlock2PtParameter")).int(177) == 1;
ParameterKind::Linear { base, end, label, description, values: value_set(&mut c), from_midpoint }
}
"BLOCKPOINTPARAMETER" => {
let mut c = Cursor::new(section(secs, "AcDbBlockPointParameter"));
ParameterKind::Point { at: one(), label: c.string(303), description: c.string(304) }
}
"BLOCKVISIBILITYPARAMETER" => visibility(one(), section(secs, "AcDbBlockVisibilityParameter")),
"BLOCKLOOKUPPARAMETER" => {
let mut c = Cursor::new(section(secs, "AcDbBlockLookupParameter"));
ParameterKind::Lookup { at: one(), label: c.string(303), description: c.string(304) }
}
"BLOCKBASEPOINTPARAMETER" => ParameterKind::BasePoint { at: one() },
"BLOCKPOLARPARAMETER" | "BLOCKXYPARAMETER" | "BLOCKROTATIONPARAMETER" | "BLOCKALIGNMENTPARAMETER" | "BLOCKFLIPPARAMETER" => {
let (base, end) = two();
match kind {
"BLOCKPOLARPARAMETER" => ParameterKind::Polar { base, end },
"BLOCKXYPARAMETER" => ParameterKind::Xy { base, end },
"BLOCKROTATIONPARAMETER" => ParameterKind::Rotation { base, end },
"BLOCKALIGNMENTPARAMETER" => ParameterKind::Alignment { base, end },
_ => ParameterKind::Flip { base, end },
}
}
other => ParameterKind::Other { dxf_type: other.to_string() },
}
}
/// A value set after a parameter's name and description: description (307), flags (96),
/// minimum, maximum and increment (141–143), then the listed values (175 count, 144 each).
fn value_set(c: &mut Cursor) -> ValueSet {
c.next(307);
let flags = c.int(96);
let (minimum, maximum, increment) = (c.real(141), c.real(142), c.real(143));
let n = c.count(175);
let list = (0..n).filter_map(|_| c.next(144).and_then(|v| v.trim().parse::<f64>().ok()).filter(|v| v.is_finite())).collect();
ValueSet { flags, minimum, maximum, increment, list }
}
/// A visibility parameter: its name and description (301, 302), the objects it controls (93
/// count, 331 each), then each state (92 count): name (303), visible objects (94, 332) and the
/// parameters and actions active in it (95, 333).
fn visibility(at: Vec3, tags: &[RawTag]) -> ParameterKind {
let mut c = Cursor::new(tags);
let label = c.string(301);
let description = c.string(302);
let n = c.count(93);
let controlled = c.handles(331, n);
let states = (0..c.count(92))
.map(|_| {
let name = c.string(303);
let n = c.count(94);
let visible = c.handles(332, n);
let n = c.count(95);
VisibilityState { name, visible, active: c.handles(333, n) }
})
.collect();
ParameterKind::Visibility { at, label, description, controlled, states }
}
fn action(handle: Handle, node: i32, name: String, kind: &str, secs: &[(&str, &[RawTag])]) -> Action {
let mut c = Cursor::new(section(secs, "AcDbBlockAction"));
c.next(70);
let n = c.count(71);
let selection = c.handles(330, n);
let at = c.point(1010);
let (connections, kind) = match kind {
"BLOCKSTRETCHACTION" => stretch(section(secs, "AcDbBlockStretchAction")),
"BLOCKLOOKUPACTION" => (Vec::new(), lookup(section(secs, "AcDbBlockLookupAction"))),
"BLOCKMOVEACTION" => {
let tags = section(secs, "AcDbBlockMoveAction");
let mut c = Cursor::new(tags);
let (multiplier, angle_offset) = (c.real(140), c.real(141));
(two_connections(tags), ActionKind::Move { multiplier, angle_offset })
}
"BLOCKSCALEACTION" => (Vec::new(), ActionKind::Scale),
"BLOCKROTATEACTION" => (Vec::new(), ActionKind::Rotate),
"BLOCKFLIPACTION" => (Vec::new(), ActionKind::Flip),
"BLOCKARRAYACTION" => (Vec::new(), ActionKind::Array),
"BLOCKPOLARSTRETCHACTION" => (Vec::new(), ActionKind::PolarStretch),
other => (Vec::new(), ActionKind::Other { dxf_type: other.to_string() }),
};
Action { node, handle, name, at, selection, connections, kind }
}
/// Two connections: parameter ids (92, 93) and the values they read (301, 302). Writers order
/// these groups differently (92 93 301 302, or 92 301 93 302), so each is read on its own.
fn two_connections(tags: &[RawTag]) -> Vec<(i32, String)> {
let first = |code: i32| Cursor::new(tags).next(code).unwrap_or_default().to_string();
let int = |code: i32| first(code).trim().parse().unwrap_or(0);
vec![(int(92), first(301)), (int(93), first(302))]
}
/// A stretch action: its two connections, the frame (72 count, 1011/1021 points), the objects
/// whose vertices it moves (73 count; each a 331 handle, a 74 count and 94 vertex indices), then
/// the distance multiplier (140) and angle offset (141).
fn stretch(tags: &[RawTag]) -> (Vec<(i32, String)>, ActionKind) {
let connections = two_connections(tags);
let mut c = Cursor::new(tags);
let n = c.count(72);
let frame = (0..n)
.map(|_| {
let x = c.real(1011);
Vec2::new(x, c.real(1021))
})
.collect();
let objects = c.count(73);
let vertices = (0..objects)
.filter_map(|_| {
let h = c.handle(331)?;
let n = c.count(74);
Some((h, (0..n).map(|_| c.int(94)).collect()))
})
.collect();
c.next(75);
let (multiplier, angle_offset) = (c.real(140), c.real(141));
(connections, ActionKind::Stretch { frame, vertices, multiplier, angle_offset })
}
/// A lookup table: rows (92) and columns (93), the cells row by row (302, after a 301 header),
/// then each column: unmatched name (303 then 305), parameter id (94), lookup flag (282) and the
/// parameter value it stands for (304).
fn lookup(tags: &[RawTag]) -> ActionKind {
let mut c = Cursor::new(tags);
let rows = c.count(92);
let cols = c.count(93);
c.next(301);
let cells = rows.saturating_mul(cols).min(tags.len());
let flat: Vec<String> = (0..cells).map(|_| c.string(302)).collect();
let rows = flat.chunks(cols.max(1)).map(<[String]>::to_vec).collect();
let columns = (0..cols)
.map(|_| {
c.next(303);
let node = c.int(94);
let lookup = c.int(282) != 0;
let unmatched = c.string(305);
let property = c.string(304);
LookupColumn { node, property, lookup, unmatched }
})
.collect();
ActionKind::Lookup { columns, rows }
}
#[cfg(test)]
mod tests {
use super::*;
fn tag(code: i32, value: &str) -> RawTag {
RawTag { code, value: value.to_string() }
}
/// Counts that promise groups the record doesn't have: each nested loop would rescan the
/// record for them. The work budget keeps this linear in the record's size.
#[test]
fn hostile_counts_with_missing_groups_stay_linear() {
let n = 20_000;
let mut tags = vec![tag(93, "0"), tag(92, &n.to_string())];
tags.extend((0..n).map(|_| tag(94, &n.to_string())));
let ParameterKind::Visibility { states, .. } = visibility(Vec3::default(), &tags) else { panic!("a visibility parameter") };
assert!(states.len() <= n && states.iter().all(|s| s.visible.is_empty()));
let mut tags = vec![tag(73, &n.to_string())];
tags.extend((0..n).flat_map(|_| [tag(331, "1F"), tag(74, &n.to_string())]));
let (_, ActionKind::Stretch { vertices, .. }) = stretch(&tags) else { panic!("a stretch action") };
let ints: usize = vertices.iter().map(|(_, v)| v.len()).sum();
assert!(ints <= 16 * tags.len() + 1024, "{ints}");
}
}
+958
View File
@@ -0,0 +1,958 @@
//! Changing a dynamic block reference's custom properties.
//!
//! A changed reference shows an anonymous block (`*U12`) made from its current one with the
//! change applied, and says so the way AutoCAD's own references do, so AutoCAD still reads it as
//! a reference to the dynamic block: the anonymous block's BLOCK_RECORD carries
//! `AcDbBlockRepBTag` xdata naming the dynamic block's record, each copied object keeps the
//! `AcDbBlockRepETag` xdata that pairs it with its object in the dynamic block, and the reference's
//! `AcDbBlockRepresentation` data holds its values (created when it had none) and its history
//! says what was done to it (AutoCAD replays the history when the dynamic block is redefined).
//!
//! What can change so far: a visibility state, a lookup value, a distance and a position, with
//! the stretch and move actions they drive and the lookups that follow. The other parameters'
//! actions come later.
use std::collections::{BTreeMap, HashMap, HashSet};
use cadcraft_geom::{Mat3, Vec2, Vec3};
use super::{ActionKind, DynamicBlock, ParameterKind, Value, dynamic_block, reference};
use crate::foreign::{ForeignExtra, ForeignObject};
use crate::{Block, Drawing, Entity, EntityKind, Handle, RawTag};
/// Xdata application naming a copied object's object in the dynamic block (its element tag).
const ETAG: &str = "AcDbBlockRepETag";
/// Xdata application naming an anonymous block's dynamic block.
const BTAG: &str = "AcDbBlockRepBTag";
fn tag(code: i32, value: impl Into<String>) -> RawTag {
RawTag { code, value: value.into() }
}
/// The element tag (`AcDbBlockRepETag` 1071) of object `h`, if it has one.
fn etag(d: &Drawing, h: Handle) -> Option<i64> {
let x = &d.foreign.entities.get(&h)?.xdata;
let at = x.iter().position(|t| t.code == 1001 && t.value.eq_ignore_ascii_case(ETAG))?;
x.iter().skip(at + 1).take_while(|t| t.code != 1001).find(|t| t.code == 1071).and_then(|t| t.value.trim().parse().ok())
}
/// Each object of `b` keyed by its element tag, or by its position when it has none.
fn by_tag(d: &Drawing, b: &Block) -> Vec<(i64, Handle)> {
b.entities.iter().enumerate().map(|(i, e)| (etag(d, e.handle).unwrap_or(i as i64 + 1_000_000), e.handle)).collect()
}
/// The file handle of block `name`'s BLOCK_RECORD, as the kept data refers to it.
fn record_handle(d: &Drawing, name: &str) -> Option<Handle> {
let key = name.to_ascii_uppercase();
d.foreign.targets.iter().find(|(_, (k, n))| k == "BLOCK_RECORD" && *n == key).map(|(h, _)| *h)
}
/// The object entry `key` of dictionary `dict` refers to.
fn entry(d: &Drawing, dict: Handle, key: &str) -> Option<Handle> {
let o = d.foreign.objects.iter().find(|o| o.handle == dict)?;
let mut tags = o.tags.iter();
while let Some(t) = tags.next() {
if t.code == 3 && t.value.eq_ignore_ascii_case(key) {
return tags.next().filter(|t| t.code == 350 || t.code == 360).and_then(|t| Handle::parse_hex(&t.value));
}
}
None
}
/// Every entry of dictionary `dict`: (key, object).
fn entries(d: &Drawing, dict: Handle) -> Vec<(String, Handle)> {
let Some(o) = d.foreign.objects.iter().find(|o| o.handle == dict) else { return Vec::new() };
let mut out = Vec::new();
let mut key: Option<&str> = None;
for t in &o.tags {
match t.code {
3 => key = Some(&t.value),
350 | 360 => {
if let (Some(k), Some(h)) = (key.take(), Handle::parse_hex(&t.value)) {
out.push((k.to_string(), h));
}
}
_ => {}
}
}
out
}
/// A parameter's value as a change sets it (block coordinates).
#[derive(Clone, Debug, PartialEq)]
enum Val {
/// A visibility state or lookup value.
Text(String),
/// A point parameter's position.
Point(Vec3),
/// A two-point parameter's base and end points.
Points(Vec3, Vec3),
}
/// A parameter's current value on the reference: the value it was given, else the block's own.
fn current(m: &DynamicBlock, values: &BTreeMap<i32, Value>, node: i32) -> Option<Val> {
let p = m.parameters.iter().find(|p| p.node == node)?;
let given = values.get(&node);
Some(match (&p.kind, given) {
(_, Some(Value::Text { value })) => Val::Text(value.clone()),
(_, Some(Value::Point { at })) => Val::Point(*at),
(_, Some(Value::Points { base, end })) => Val::Points(*base, *end),
(ParameterKind::Visibility { states, .. }, None) => Val::Text(states.first().map(|s| s.name.clone()).unwrap_or_default()),
(ParameterKind::Point { at, .. }, None) => Val::Point(*at),
(
ParameterKind::Linear { base, end, .. }
| ParameterKind::Polar { base, end }
| ParameterKind::Xy { base, end }
| ParameterKind::Rotation { base, end }
| ParameterKind::Alignment { base, end }
| ParameterKind::Flip { base, end },
None,
) => Val::Points(*base, *end),
_ => return None,
})
}
/// `v` as a value set allows it: the nearest listed value, a multiple of the increment from the
/// minimum, within the minimum and maximum.
fn allowed(set: &super::ValueSet, v: f64) -> f64 {
let mut v = v;
if set.is_list() && !set.list.is_empty() {
v = set.list.iter().copied().fold(f64::NAN, |best, x| if best.is_nan() || (x - v).abs() < (best - v).abs() { x } else { best });
}
if set.has_increment() && set.increment > 0.0 {
let from = if set.has_minimum() { set.minimum } else { 0.0 };
v = from + ((v - from) / set.increment).round() * set.increment;
}
if set.has_minimum() {
v = v.max(set.minimum);
}
if set.has_maximum() && set.maximum > 0.0 {
v = v.min(set.maximum);
}
v
}
/// A linear parameter's points for distance `dist`: the end point moves along the parameter, or
/// both points move apart from the midpoint.
fn linear_points(base: Vec3, end: Vec3, dist: f64, from_midpoint: bool) -> (Vec3, Vec3) {
let dir = end - base;
let len = dir.len();
let u = if len > 1e-12 { dir * (1.0 / len) } else { Vec3::new(1.0, 0.0, 0.0) };
if from_midpoint {
let mid = (base + end) * 0.5;
(mid - u * (dist / 2.0), mid + u * (dist / 2.0))
} else {
(base, base + u * dist)
}
}
/// The changes one property change makes, before actions run: parameter values by graph id.
#[derive(Default)]
struct Plan {
set: BTreeMap<i32, Val>,
}
impl Plan {
/// Set the value a lookup table cell gives property `property` of parameter `node`.
fn cell(&mut self, m: &DynamicBlock, values: &BTreeMap<i32, Value>, node: i32, property: &str, cell: &str) -> Result<(), String> {
let p = m.parameters.iter().find(|p| p.node == node).ok_or("A lookup table refers to a missing parameter.")?;
match (&p.kind, property.to_ascii_lowercase().as_str()) {
(_, "lookupstring") | (ParameterKind::Visibility { .. }, "visibilitystate") => {
self.set.insert(node, Val::Text(cell.to_string()));
}
(ParameterKind::Linear { from_midpoint, .. }, "updateddistance" | "distance") => {
let dist = cell
.trim()
.parse::<f64>()
.ok()
.filter(|v| v.is_finite())
.ok_or_else(|| format!("A lookup table gives \"{cell}\" for a distance."))?;
let Some(Val::Points(b, e)) = self.set.get(&node).cloned().or_else(|| current(m, values, node)) else { return Ok(()) };
let (b2, e2) = linear_points(b, e, dist, *from_midpoint);
self.set.insert(node, Val::Points(b2, e2));
}
(_, other) => return Err(format!("A lookup table sets {other}, which CADCraft can't change yet.")),
}
Ok(())
}
/// Lookup parameter `node` set to `value`: every lookup table that sets it gives its row's
/// inputs. A table without that value is left alone; none having it is an error.
fn lookup(&mut self, m: &DynamicBlock, values: &BTreeMap<i32, Value>, node: i32, value: &str, skip: Option<usize>) -> Result<bool, String> {
let mut found = false;
for (i, a) in m.actions.iter().enumerate() {
let ActionKind::Lookup { columns, rows } = &a.kind else { continue };
if Some(i) == skip {
continue;
}
let Some(out) = columns.iter().position(|c| c.lookup && c.node == node) else { continue };
let Some(row) = rows.iter().find(|r| r.get(out).is_some_and(|v| v.trim().eq_ignore_ascii_case(value.trim()))) else { continue };
found = true;
for (c, cell) in columns.iter().zip(row) {
self.cell(m, values, c.node, &c.property, cell)?;
}
}
Ok(found)
}
/// After input property `property` of parameter `node` changed: a lookup table with that input
/// whose inputs (with the new value) match a row gives its lookup value, and the lookup's
/// other tables then set their inputs for that value (as AutoCAD does).
fn chain(&mut self, m: &DynamicBlock, values: &BTreeMap<i32, Value>, node: i32, property: &str) -> Result<(), String> {
let read = |plan: &Plan, n: i32, prop: &str| -> Option<String> {
let v = plan.set.get(&n).cloned().or_else(|| current(m, values, n))?;
match (v, prop.to_ascii_lowercase().as_str()) {
(Val::Text(t), "visibilitystate" | "lookupstring") => Some(t),
(Val::Points(b, e), "updateddistance" | "distance") => Some(format!("{}", (e - b).len())),
_ => None,
}
};
for (i, a) in m.actions.iter().enumerate() {
let ActionKind::Lookup { columns, rows } = &a.kind else { continue };
if !columns.iter().any(|c| !c.lookup && c.node == node && c.property.eq_ignore_ascii_case(property)) {
continue;
}
let Some(out) = columns.iter().position(|c| c.lookup) else { continue };
let lookup_node = columns.get(out).map_or(0, |c| c.node);
let hit = rows.iter().find(|r| {
columns.iter().enumerate().filter(|(_, c)| !c.lookup).all(|(j, c)| match (r.get(j), read(self, c.node, &c.property)) {
(Some(cell), Some(v)) => same(cell, &v),
_ => false,
})
});
if let Some(v) = hit.and_then(|r| r.get(out)).cloned() {
self.set.insert(lookup_node, Val::Text(v.clone()));
self.lookup(m, values, lookup_node, &v, Some(i))?;
}
}
Ok(())
}
}
/// Whether table cell `cell` reads as `value` (numbers compare as numbers).
fn same(cell: &str, value: &str) -> bool {
match (cell.trim().parse::<f64>(), value.trim().parse::<f64>()) {
(Ok(a), Ok(b)) => (a - b).abs() <= 1e-6 * a.abs().max(b.abs()).max(1.0),
_ => cell.trim().eq_ignore_ascii_case(value.trim()),
}
}
/// A number typed for a property, in drawing units.
fn number(value: &str) -> Result<f64, String> {
value.trim().parse::<f64>().ok().filter(|v| v.is_finite()).ok_or_else(|| format!("\"{value}\" is not a number."))
}
/// Set custom property `name` (as the Properties palette names it, any case) of dynamic block
/// reference `insert` to `value` (lengths and positions in drawing units, as Properties shows them).
pub fn set_property(d: &mut Drawing, insert: Handle, name: &str, value: &str) -> Result<(), String> {
let r = reference(d, insert).ok_or("Not a dynamic block reference.")?;
let m = dynamic_block(d, &r.definition).ok_or("Not a dynamic block reference.")?;
let EntityKind::Insert(ins) = &d.entity(insert).ok_or("No such object.")?.kind else { return Err("Not a block reference.".into()) };
let (sx, sy) = (ins.scale.x.abs().max(1e-12), ins.scale.y.abs().max(1e-12));
let block_base = d.block(&r.definition).map(|b| b.base).unwrap_or_default();
let want = name.trim();
// A point parameter's position is two properties, "Position1 X" and "Position1 Y".
let (label, axis) = match want.rsplit_once(' ') {
Some((l, a)) if a.eq_ignore_ascii_case("X") || a.eq_ignore_ascii_case("Y") => (l, Some(a.eq_ignore_ascii_case("X"))),
_ => (want, None),
};
let p = m
.parameters
.iter()
.find(|p| {
p.show_properties && p.kind.label().is_some_and(|l| l.eq_ignore_ascii_case(want) || (axis.is_some() && l.eq_ignore_ascii_case(label)))
})
.ok_or_else(|| format!("The reference has no property \"{name}\"."))?;
let mut plan = Plan::default();
match (&p.kind, axis) {
(ParameterKind::Visibility { states, label, .. }, _) => {
let s = states.iter().find(|s| s.name.eq_ignore_ascii_case(value.trim())).ok_or_else(|| {
format!("\"{value}\" is not a state of {label}: {}.", states.iter().map(|s| s.name.as_str()).collect::<Vec<_>>().join(", "))
})?;
plan.set.insert(p.node, Val::Text(s.name.clone()));
plan.chain(&m, &r.values, p.node, "VisibilityState")?;
}
(ParameterKind::Lookup { label, .. }, _) => {
plan.set.insert(p.node, Val::Text(value.trim().to_string()));
if !plan.lookup(&m, &r.values, p.node, value, None)? {
return Err(format!("\"{value}\" is not a value of {label}."));
}
}
(ParameterKind::Linear { values, from_midpoint, .. }, None) => {
let typed = number(value)? / sx;
if typed <= 0.0 {
return Err(format!("{name} must be more than 0."));
}
let dist = allowed(values, typed);
if dist <= 0.0 {
return Err(format!("{name} must be more than 0."));
}
let Some(Val::Points(b, e)) = current(&m, &r.values, p.node) else { return Err(format!("{name} has no points.")) };
let (b2, e2) = linear_points(b, e, dist, *from_midpoint);
plan.set.insert(p.node, Val::Points(b2, e2));
plan.chain(&m, &r.values, p.node, "UpdatedDistance")?;
}
(ParameterKind::Point { .. }, Some(is_x)) => {
let Some(Val::Point(at)) = current(&m, &r.values, p.node) else { return Err(format!("{name} has no position.")) };
let v = number(value)?;
let at = if is_x { Vec3::new(block_base.x + v / sx, at.y, at.z) } else { Vec3::new(at.x, block_base.y + v / sy, at.z) };
plan.set.insert(p.node, Val::Point(at));
}
_ => return Err(format!("CADCraft can't change {name} yet.")),
}
apply(d, insert, &r.definition, &r.values, &plan.set, p.node)
}
/// Drag grip `part` of parameter `node` of dynamic block reference `insert` to `to` (drawing
/// coordinates), as AutoCAD's dynamic grips do: a distance's end grip (or base grip) changes the
/// distance by the drag along the parameter, snapped to its value set (both ends move when the
/// distance is measured from its midpoint); a position grip moves the position. The actions and
/// lookups follow as for [`set_property`].
pub fn drag_grip(d: &mut Drawing, insert: Handle, node: i32, part: super::GripPart, to: Vec2) -> Result<(), String> {
use super::GripPart;
if !to.is_finite() {
return Err("Invalid point.".into());
}
let r = reference(d, insert).ok_or("Not a dynamic block reference.")?;
let m = dynamic_block(d, &r.definition).ok_or("Not a dynamic block reference.")?;
let EntityKind::Insert(ins) = &d.entity(insert).ok_or("No such object.")?.kind else { return Err("Not a block reference.".into()) };
let base = d.block(&r.definition).map(|b| b.base.xy()).unwrap_or_default();
let q = ins.transform(base).inverse().ok_or("The reference has a zero scale.")?.apply(to);
let p = m.parameters.iter().find(|p| p.node == node).ok_or("The reference has no such parameter.")?;
let mut plan = Plan::default();
match (&p.kind, part) {
(ParameterKind::Linear { values, from_midpoint, label, .. }, GripPart::Base | GripPart::End) => {
let Some(Val::Points(b, e)) = current(&m, &r.values, node) else { return Err(format!("{label} has no points.")) };
let len = (e - b).len();
if len < 1e-12 {
return Err(format!("{label} has no direction."));
}
let u = (e - b) * (1.0 / len);
let q = Vec3::new(q.x, q.y, b.z);
let raw = if *from_midpoint {
2.0 * (q - (b + e) * 0.5).dot(u).abs()
} else if part == GripPart::End {
(q - b).dot(u)
} else {
(e - q).dot(u)
};
let dist = allowed(values, raw);
if !(raw > 0.0 && dist > 0.0 && dist.is_finite()) {
return Err(format!("{label} must be more than 0."));
}
let (b2, e2) = match (from_midpoint, part) {
(true, _) => linear_points(b, e, dist, true),
(false, GripPart::Base) => (e - u * dist, e),
(false, _) => (b, b + u * dist),
};
plan.set.insert(node, Val::Points(b2, e2));
plan.chain(&m, &r.values, node, "UpdatedDistance")?;
}
(ParameterKind::Point { label, .. }, GripPart::At) => {
let Some(Val::Point(at)) = current(&m, &r.values, node) else { return Err(format!("{label} has no position.")) };
plan.set.insert(node, Val::Point(Vec3::new(q.x, q.y, at.z)));
}
_ => return Err("That grip can't be dragged.".into()),
}
apply(d, insert, &r.definition, &r.values, &plan.set, node)
}
/// The change of each parameter value an action can follow, by parameter graph id and the
/// connection's name ("EndXDelta", "XDelta"…), from `old` and `new` values.
fn delta(old: &Val, new: &Val, connection: &str) -> f64 {
let c = connection.to_ascii_lowercase();
let pick = |v: Vec3| if c.contains('x') { v.x } else { v.y };
match (old, new) {
(Val::Point(a), Val::Point(b)) => pick(*b - *a),
(Val::Points(ab, ae), Val::Points(bb, be)) => {
if c.starts_with("base") || c.starts_with("updatedbase") {
pick(*bb - *ab)
} else {
pick(*be - *ae)
}
}
_ => 0.0,
}
}
/// Move entity `e` by `v`: the listed vertices of a line or polyline, else the whole object.
fn move_entity(e: &mut Entity, vertices: Option<&[i32]>, v: Vec2) {
match (&mut e.kind, vertices) {
(EntityKind::LwPolyline(pl), Some(idx)) => {
for i in idx {
if let Some(p) = usize::try_from(*i).ok().and_then(|i| pl.vertices.get_mut(i)) {
p.p += v;
}
}
}
(EntityKind::Line(l), Some(idx)) => {
for i in idx {
match i {
0 => l.a = l.a + v.to3(0.0),
1 => l.b = l.b + v.to3(0.0),
_ => {}
}
}
}
_ => e.kind.transform(&Mat3::translate(v)),
}
}
/// Whether point `p` lies inside a stretch frame (two corners, or a polygon).
fn in_frame(frame: &[Vec2], p: Vec2) -> bool {
match frame {
[a, b] => p.x >= a.x.min(b.x) && p.x <= a.x.max(b.x) && p.y >= a.y.min(b.y) && p.y <= a.y.max(b.y),
f if f.len() >= 3 => {
let mut inside = false;
let mut j = f.len() - 1;
for i in 0..f.len() {
let (Some(pi), Some(pj)) = (f.get(i), f.get(j)) else { break };
if (pi.y > p.y) != (pj.y > p.y) && p.x < (pj.x - pi.x) * (p.y - pi.y) / (pj.y - pi.y) + pi.x {
inside = !inside;
}
j = i;
}
inside
}
_ => true,
}
}
/// Give `insert` a new anonymous block with the plan's values applied (visibility states, and
/// the stretch and move actions of the changed parameters, with the parameters in their selections
/// carried along), and record the new values on it.
fn apply(
d: &mut Drawing,
insert: Handle,
definition: &str,
values: &BTreeMap<i32, Value>,
set: &BTreeMap<i32, Val>,
direct: i32,
) -> Result<(), String> {
let m = dynamic_block(d, definition).ok_or("Not a dynamic block reference.")?;
let EntityKind::Insert(ins) = &d.entity(insert).ok_or("No such object.")?.kind else { return Err("Not a block reference.".into()) };
let ins = ins.clone();
let def = d.block(definition).cloned().ok_or("The dynamic block is missing.")?;
let src = d.block(&ins.block).cloned().ok_or("The reference's block is missing.")?;
// The dynamic block's objects and the reference's copies of them, by element tag.
let def_tags: HashMap<Handle, i64> = by_tag(d, &def).into_iter().map(|(t, h)| (h, t)).collect();
let src_by_tag: HashMap<i64, Handle> = by_tag(d, &src).into_iter().collect();
let copy_of = |dh: &Handle| def_tags.get(dh).and_then(|t| src_by_tag.get(t)).copied();
// New values, starting from the change; actions add the parameters they carry along.
let mut new: BTreeMap<i32, Val> = set.clone();
let mut moves: HashMap<Handle, Vec<(Option<Vec<i32>>, Vec2)>> = HashMap::new();
let param_of: HashMap<Handle, i32> = m.parameters.iter().map(|p| (p.handle, p.node)).collect();
for a in &m.actions {
let changed: Vec<&(i32, String)> = a.connections.iter().filter(|(n, _)| set.contains_key(n)).collect();
if changed.is_empty() {
continue;
}
let (mult, angle, frame, vertices): (f64, f64, &[Vec2], &[(Handle, Vec<i32>)]) = match &a.kind {
ActionKind::Stretch { frame, vertices, multiplier, angle_offset } => (*multiplier, *angle_offset, frame, vertices),
ActionKind::Move { multiplier, angle_offset } => (*multiplier, *angle_offset, &[], &[]),
_ => continue,
};
// The two connections give the change in x and y.
let comp = |i: usize| {
a.connections.get(i).map_or(0.0, |(n, conn)| match (set.get(n), current(&m, values, *n)) {
(Some(nv), Some(ov)) => delta(&ov, nv, conn),
_ => 0.0,
})
};
let mult = if mult == 0.0 { 1.0 } else { mult };
let v = Vec2::new(comp(0), comp(1)).rotate(angle) * mult;
if !(v.x.is_finite() && v.y.is_finite()) || (v.x == 0.0 && v.y == 0.0) {
continue;
}
for h in &a.selection {
if let Some(node) = param_of.get(h) {
// A parameter in the selection is carried along: its points in the frame. Which
// points are in it is decided where the dynamic block has them (AutoCAD evaluates
// the actions from the block itself, so a point another action has moved out of
// the frame still follows).
if set.contains_key(node) {
continue;
}
let Some(cur) = new.get(node).cloned().or_else(|| current(&m, values, *node)) else { continue };
let home = current(&m, &BTreeMap::new(), *node);
let shift = |p: Vec3, at: Option<Vec3>| {
if frame.is_empty() || in_frame(frame, at.unwrap_or(p).xy()) { p + v.to3(0.0) } else { p }
};
let moved = match (cur, home) {
(Val::Point(p), Some(Val::Point(h))) => Val::Point(shift(p, Some(h))),
(Val::Point(p), _) => Val::Point(shift(p, None)),
(Val::Points(b, e), Some(Val::Points(hb, he))) => Val::Points(shift(b, Some(hb)), shift(e, Some(he))),
(Val::Points(b, e), _) => Val::Points(shift(b, None), shift(e, None)),
(t, _) => t,
};
new.insert(*node, moved);
continue;
}
let Some(c) = copy_of(h) else { continue };
let idx = vertices.iter().find(|(vh, _)| vh == h).map(|(_, i)| i.clone());
moves.entry(c).or_default().push((idx, v));
}
}
// Visibility states.
let mut shown: HashMap<Handle, bool> = HashMap::new();
for (node, v) in &new {
let (Some(ParameterKind::Visibility { controlled, states, .. }), Val::Text(state)) =
(m.parameters.iter().find(|p| p.node == *node).map(|p| &p.kind), v)
else {
continue;
};
let visible: HashSet<Handle> =
states.iter().find(|s| s.name.eq_ignore_ascii_case(state)).map(|s| s.visible.iter().copied().collect()).unwrap_or_default();
for h in controlled {
shown.insert(*h, visible.contains(h));
}
}
let shown_copy: HashMap<Handle, bool> = shown.iter().filter_map(|(dh, v)| copy_of(dh).map(|c| (c, *v))).collect();
// The new anonymous block.
let name = d.anonymous_block_name("*U");
// AutoCAD's anonymous copies have no units of their own.
let mut b =
Block { name: name.clone(), anonymous: true, xref_path: None, array: None, units: 0, entities: crate::EntityStore::new(), ..(*src).clone() };
let mut copies: HashMap<Handle, Handle> = HashMap::new();
for e in src.entities.iter() {
copies.insert(e.handle, d.new_handle());
}
// How far each attribute definition moves (block coordinates): the reference's attribute of
// that tag moves with it, as AutoCAD moves it.
let attrib_moves: HashMap<String, Vec2> = src
.entities
.iter()
.filter_map(|e| match &e.kind {
EntityKind::AttDef(ad) => {
let v = moves.get(&e.handle).into_iter().flatten().fold(Vec2::ZERO, |acc, (_, v)| acc + *v);
(v != Vec2::ZERO).then(|| (ad.tag.to_ascii_uppercase(), v))
}
_ => None,
})
.collect();
for e in src.entities.iter() {
let Some(nh) = copies.get(&e.handle).copied() else { continue };
let mut c: Entity = (**e).clone();
c.handle = nh;
if let Some(v) = shown_copy.get(&e.handle) {
c.common.visible = *v;
}
for (idx, v) in moves.get(&e.handle).into_iter().flatten() {
move_entity(&mut c, idx.as_deref(), *v);
}
if let EntityKind::Hatch(h) = &mut c.kind {
for l in &mut h.loops {
l.sources = l.sources.iter().map(|s| copies.get(s).copied().unwrap_or(*s)).collect();
}
}
b.entities.push(c);
}
refill_hatches(&mut b);
let def_record = Some(record_handle(d, definition).unwrap_or_else(|| {
let h = d.new_handle();
let f = std::sync::Arc::make_mut(&mut d.foreign);
f.targets.insert(h, ("BLOCK_RECORD".to_string(), definition.to_ascii_uppercase()));
h
}));
let old = ins.block.clone();
d.blocks.insert(name.clone(), std::sync::Arc::new(b));
let f = std::sync::Arc::make_mut(&mut d.foreign);
for (old_h, new_h) in &copies {
if let Some(mut x) = f.entities.get(old_h).cloned() {
for t in &mut x.xdata {
if t.code == 1005 && Handle::parse_hex(&t.value) == Some(*old_h) {
t.value = new_h.hex();
}
}
x.xdict = None;
// Reactors within the block (an associative hatch's outline) follow to the copies;
// ones outside it are not ours.
x.reactors = x.reactors.iter().filter_map(|r| copies.get(r).copied()).collect();
f.entities.insert(*new_h, x);
}
}
if let Some(rec) = def_record {
let xdata = vec![tag(1001, BTAG), tag(1070, "1"), tag(1005, rec.hex())];
f.records.insert(("BLOCK_RECORD".into(), name.to_ascii_uppercase()), ForeignExtra { xdata, ..Default::default() });
}
// The block-to-drawing transform's linear part turns a block-coordinate move into the move of
// the reference's attribute.
let to_world = ins.transform(src.base.xy());
let origin = to_world.apply(Vec2::ZERO);
d.modify_entity(insert, |e| {
if let EntityKind::Insert(i) = &mut e.kind {
i.block = name.clone();
for a in &mut i.attribs {
if let Some(v) = attrib_moves.get(&a.tag.to_ascii_uppercase()) {
let w = to_world.apply(*v) - origin;
let mut k = EntityKind::Text(a.text.clone());
k.transform(&Mat3::translate(w));
if let EntityKind::Text(t) = k {
a.text = t;
}
}
}
}
})
.map_err(|e| e.to_string())?;
let was = current(&m, values, direct);
record_values(d, insert, def_record, &m, &new, (direct, was.as_ref()));
drop_if_unused(d, &old);
Ok(())
}
/// Associative hatches of block `b` whose loop is one closed polyline of the block take its
/// outline again (it may have been stretched).
fn refill_hatches(b: &mut Block) {
let outlines: HashMap<Handle, Vec<cadcraft_geom::PolyVertex>> = b
.entities
.iter()
.filter_map(|e| match &e.kind {
EntityKind::LwPolyline(pl) if pl.closed => Some((e.handle, pl.vertices.clone())),
_ => None,
})
.collect();
let hatches: Vec<Handle> = b.entities.iter().filter(|e| matches!(&e.kind, EntityKind::Hatch(h) if h.associative)).map(|e| e.handle).collect();
for h in hatches {
b.entities.modify(h, |e| {
if let EntityKind::Hatch(hh) = &mut e.kind {
for l in &mut hh.loops {
if let [s] = l.sources.as_slice()
&& let Some(vs) = outlines.get(s)
{
l.vertices.clone_from(vs);
}
}
}
});
}
}
/// The value records of the references to the dynamic block whose record is `def_record` (from
/// any one of them, by graph id): AutoCAD's layout of each record, class ids included.
fn templates(d: &Drawing, def_record: Handle) -> HashMap<String, Vec<RawTag>> {
let objects: HashMap<Handle, &ForeignObject> = d.foreign.objects.iter().map(|o| (o.handle, o)).collect();
let mut out: HashMap<String, Vec<RawTag>> = HashMap::new();
for rep in d.foreign.objects.iter().filter(|o| o.kind.eq_ignore_ascii_case("DICTIONARY")) {
// A representation dictionary whose data names this dynamic block.
let Some(rd) = entry(d, rep.handle, "AcDbRepData").and_then(|h| objects.get(&h)) else { continue };
if !rd.tags.iter().any(|t| t.code == 340 && Handle::parse_hex(&t.value) == Some(def_record)) {
continue;
}
let Some(values) = entry(d, rep.handle, "AppDataCache").and_then(|c| entry(d, c, "ACAD_ENHANCEDBLOCKDATA")) else { continue };
for (k, h) in entries(d, values) {
if let Some(x) = objects.get(&h).filter(|x| x.kind.eq_ignore_ascii_case("XRECORD")) {
out.entry(k).or_insert_with(|| x.tags.clone());
}
}
}
out
}
/// A dictionary object `h` owned by `owner` with `entries`.
fn dictionary(h: Handle, owner: Handle, entries: &[(&str, Handle)]) -> ForeignObject {
let mut tags = vec![tag(5, h.hex()), tag(330, owner.hex()), tag(100, "AcDbDictionary"), tag(280, "1"), tag(281, "1")];
for (k, v) in entries {
tags.push(tag(3, *k));
tags.push(tag(360, v.hex()));
}
ForeignObject { handle: h, kind: "DICTIONARY".into(), tags }
}
/// `tags` (a template's groups) as new record `h` owned by `owner`: the template's handle, owner
/// and reactors are not ours (inside reactor brackets, 102, everything goes).
fn own(mut tags: Vec<RawTag>, h: Handle, owner: Handle) -> Vec<RawTag> {
let mut inside = false;
tags.retain(|t| {
if t.code == 102 {
inside = t.value.starts_with('{');
return false;
}
!(inside || t.code == 5 || t.code == 330)
});
let at = tags.iter().position(|t| t.code == 100).unwrap_or(0);
tags.insert(at, tag(330, owner.hex()));
tags.insert(0, tag(5, h.hex()));
tags
}
/// Record values `set` (by parameter graph id) in `insert`'s representation data, and the direct
/// change (of parameter `direct`, from value `old`) in its history, creating them when the reference has none. A
/// new reference's data gets a record for everything other references of the dynamic block have
/// (AutoCAD keeps one for each parameter and stretch action), with its own values; with no other
/// reference to copy, a changed value gets a minimal record.
fn record_values(
d: &mut Drawing,
insert: Handle,
def_record: Option<Handle>,
m: &DynamicBlock,
set: &BTreeMap<i32, Val>,
(direct, old): (i32, Option<&Val>),
) {
let xdict = d.foreign.entities.get(&insert).and_then(|x| x.xdict);
let rep = xdict.and_then(|x| entry(d, x, "AcDbBlockRepresentation"));
let cache = rep.and_then(|r| entry(d, r, "AppDataCache"));
let data = cache.and_then(|c| entry(d, c, "ACAD_ENHANCEDBLOCKDATA"));
let templates = def_record.map(|r| templates(d, r)).unwrap_or_default();
let param = |node: i32| m.parameters.iter().find(|p| p.node == node).map(|p| &p.kind);
let at = |node: i32| match param(node) {
Some(
ParameterKind::Visibility { at, .. }
| ParameterKind::Lookup { at, .. }
| ParameterKind::Point { at, .. }
| ParameterKind::BasePoint { at },
) => *at,
_ => Vec3::ZERO,
};
let mut objects: Vec<ForeignObject> = Vec::new();
let mut fresh: Vec<(String, Vec<RawTag>)> = Vec::new();
let (cache, data) = match (cache, data) {
(Some(c), Some(h)) => (c, h),
_ => {
// Create the representation: reference xdict ▸ AcDbBlockRepresentation ▸ AcDbRepData
// (the dynamic block's record) and AppDataCache ▸ ACAD_ENHANCEDBLOCKDATA.
let Some(rec) = def_record else { return };
let xd = xdict.unwrap_or_else(|| d.new_handle());
let (rep, rd, cache, data) = (d.new_handle(), d.new_handle(), d.new_handle(), d.new_handle());
objects.push(dictionary(rep, xd, &[("AcDbRepData", rd), ("AppDataCache", cache)]));
objects.push(ForeignObject {
handle: rd,
kind: "ACDB_BLOCKREPRESENTATION_DATA".into(),
tags: vec![tag(5, rd.hex()), tag(330, rep.hex()), tag(100, "AcDbBlockRepresentationData"), tag(70, "1"), tag(340, rec.hex())],
});
objects.push(dictionary(cache, rep, &[("ACAD_ENHANCEDBLOCKDATA", data)]));
objects.push(dictionary(data, cache, &[]));
// Every record the other references have, with this reference's values: its
// parameters' positions and defaults (lookups as Properties shows them); action
// records as they are.
let lookups: HashMap<String, String> = super::custom_properties(d, insert)
.map(|(_, ps)| ps.into_iter().map(|p| (p.name.to_ascii_uppercase(), p.value)).collect())
.unwrap_or_default();
let mut keys: Vec<&String> = templates.keys().collect();
keys.sort();
for k in keys {
let (Ok(node), Some(t)) = (k.parse::<i32>(), templates.get(k)) else { continue };
let mut tags = t.clone();
match param(node) {
Some(ParameterKind::Visibility { .. } | ParameterKind::Point { .. } | ParameterKind::BasePoint { .. }) => {
write_value(&mut tags, &Val::Point(at(node)));
}
Some(ParameterKind::Lookup { label, .. }) => {
write_value(&mut tags, &Val::Point(at(node)));
if let Some(v) = lookups.get(&label.to_ascii_uppercase()) {
write_value(&mut tags, &Val::Text(v.clone()));
}
}
_ => {}
}
if let Some(v) = current(m, &BTreeMap::new(), node) {
write_value(&mut tags, &v);
}
fresh.push((k.clone(), tags));
}
let f = std::sync::Arc::make_mut(&mut d.foreign);
match f.objects.iter_mut().find(|o| Some(o.handle) == xdict) {
Some(x) => {
x.tags.push(tag(3, "AcDbBlockRepresentation"));
x.tags.push(tag(360, rep.hex()));
}
None => {
objects.push(dictionary(xd, insert, &[("AcDbBlockRepresentation", rep)]));
f.entities.entry(insert).or_default().xdict = Some(xd);
}
}
(cache, data)
}
};
let existing: HashMap<String, Handle> = match objects.iter().find(|o| o.handle == data) {
Some(_) => HashMap::new(),
None => entries(d, data).into_iter().collect(),
};
let mut add: Vec<(String, Handle)> = Vec::new();
for (key, mut tags) in fresh {
if let Some(v) = key.parse::<i32>().ok().and_then(|n| set.get(&n)) {
write_value(&mut tags, v);
}
let h = d.new_handle();
objects.push(ForeignObject { handle: h, kind: "XRECORD".into(), tags: own(tags, h, data) });
add.push((key, h));
}
for (node, v) in set {
let key = node.to_string();
if add.iter().any(|(k, _)| *k == key) {
continue;
}
match existing.get(&key) {
Some(h) => {
let f = std::sync::Arc::make_mut(&mut d.foreign);
if let Some(x) = f.objects.iter_mut().find(|o| o.handle == *h) {
write_value(&mut x.tags, v);
}
}
None => {
let h = d.new_handle();
let mut tags = templates.get(&key).cloned().unwrap_or_else(|| {
let p = at(*node);
vec![tag(100, "AcDbXrecord"), tag(280, "1"), tag(10, p.x.to_string()), tag(20, p.y.to_string()), tag(30, p.z.to_string())]
});
write_value(&mut tags, v);
objects.push(ForeignObject { handle: h, kind: "XRECORD".into(), tags: own(tags, h, data) });
add.push((key, h));
}
}
}
// The history: AutoCAD replays it over the dynamic block (when the block is redefined), so
// it must say what was done to this reference.
let history = entry(d, cache, "ACAD_ENHANCEDBLOCKHISTORY").filter(|h| d.foreign.objects.iter().any(|o| o.handle == *h));
let mut new_history = None;
if let (Some(v), Some(p)) = (set.get(&direct), m.parameters.iter().find(|p| p.node == direct)) {
match history {
Some(h) => {
let f = std::sync::Arc::make_mut(&mut d.foreign);
if let Some(x) = f.objects.iter_mut().find(|o| o.handle == h) {
write_history(&mut x.tags, p, old, v);
}
}
None => {
let h = d.new_handle();
let mut tags = vec![tag(5, h.hex()), tag(330, cache.hex()), tag(100, "AcDbXrecord"), tag(280, "1"), tag(1070, "3")];
write_history(&mut tags, p, old, v);
objects.push(ForeignObject { handle: h, kind: "XRECORD".into(), tags });
new_history = Some(h);
}
}
}
let f = std::sync::Arc::make_mut(&mut d.foreign);
f.objects.extend(objects);
if let Some(x) = f.objects.iter_mut().find(|o| o.handle == data) {
for (k, h) in add {
x.tags.push(tag(3, k));
x.tags.push(tag(360, h.hex()));
}
}
if let (Some(h), Some(x)) = (new_history, f.objects.iter_mut().find(|o| o.handle == cache)) {
x.tags.push(tag(3, "ACAD_ENHANCEDBLOCKHISTORY"));
x.tags.push(tag(360, h.hex()));
}
}
/// Record the direct change of parameter `p` to `v` in a reference's history (groups `tags`):
/// after its header (`280 1`, `1070 3`), an entry per change: `1071` the id, `300` what, then
/// the value. A visibility state or lookup value goes by the property's name (`1` the text), as
/// does a distance (`40`, in block units: typed in Properties, it replaces its grips' drags); a
/// position as the drag of its grip, `GRIPLOC` with how far it is from where the dynamic block
/// has it (`11/21/31`), when the change moves it (from `old`). An entry for the same id is
/// replaced; a grip back in its place has none.
fn write_history(tags: &mut Vec<RawTag>, p: &super::Parameter, old: Option<&Val>, v: &Val) {
let grip = |id: i32, moved: Vec3| {
let home = moved.x.abs() < 1e-9 && moved.y.abs() < 1e-9 && moved.z.abs() < 1e-9;
let e = vec![
tag(1071, id.to_string()),
tag(300, "GRIPLOC"),
tag(11, moved.x.to_string()),
tag(21, moved.y.to_string()),
tag(31, moved.z.to_string()),
];
(id, if home { Vec::new() } else { e })
};
// Each grip with where it was, where it is and where the dynamic block has it.
let grips = |places: &[(Vec3, Vec3, Vec3)]| -> Vec<(i32, Vec<RawTag>)> {
p.grips
.iter()
.zip(places)
.filter(|(g, (was, is, _))| **g != 0 && (*was - *is).len() > 1e-9)
.map(|(g, (_, is, home))| grip(*g, *is - *home))
.collect()
};
let was = match old {
Some(Val::Point(q)) => Some(*q),
_ => None,
};
let changes: Vec<(i32, Vec<RawTag>)> = match (&p.kind, v) {
(ParameterKind::Visibility { label, .. } | ParameterKind::Lookup { label, .. }, Val::Text(t)) => {
vec![(p.node, vec![tag(1071, p.node.to_string()), tag(300, label.clone()), tag(1, t.clone())])]
}
(ParameterKind::Linear { label, .. }, Val::Points(b, e)) => {
let mut c: Vec<(i32, Vec<RawTag>)> = p.grips.iter().filter(|g| **g != 0).map(|g| (*g, Vec::new())).collect();
c.push((p.node, vec![tag(1071, p.node.to_string()), tag(300, label.clone()), tag(40, (*e - *b).len().to_string())]));
c
}
(ParameterKind::Point { at, .. }, Val::Point(q)) => grips(&[(was.unwrap_or(*at), *q, *at)]),
_ => return,
};
// The header, then the entries (each from its 1071).
let start = tags.iter().position(|t| t.code == 1071).unwrap_or(tags.len());
let mut entries: Vec<Vec<RawTag>> = Vec::new();
for t in tags.drain(start..) {
if t.code == 1071 || entries.is_empty() {
entries.push(Vec::new());
}
if let Some(e) = entries.last_mut() {
e.push(t);
}
}
let id = |e: &[RawTag]| e.first().filter(|t| t.code == 1071).and_then(|t| t.value.trim().parse::<i32>().ok());
entries.retain(|e| !changes.iter().any(|(c, _)| id(e) == Some(*c)));
entries.extend(changes.into_iter().map(|(_, e)| e).filter(|e| !e.is_empty()));
tags.extend(entries.into_iter().flatten());
}
/// Write value `v` into a value record's groups: its text (group 1), or its points in order over
/// the record's points (10/20/30), adding them when it has fewer.
fn write_value(tags: &mut Vec<RawTag>, v: &Val) {
let points: Vec<Vec3> = match v {
Val::Text(t) => {
match tags.iter_mut().find(|x| x.code == 1) {
Some(x) => x.value.clone_from(t),
None => tags.push(tag(1, t.clone())),
}
return;
}
Val::Point(p) => vec![*p],
Val::Points(b, e) => vec![*b, *e],
};
for (i, p) in points.iter().enumerate() {
// The i-th point's groups, else new ones before any text.
let find = |tags: &[RawTag], code: i32, n: usize| tags.iter().enumerate().filter(|(_, t)| t.code == code).nth(n).map(|(j, _)| j);
match (find(tags, 10, i), find(tags, 20, i)) {
(Some(jx), Some(jy)) => {
if let Some(t) = tags.get_mut(jx) {
t.value = p.x.to_string();
}
if let Some(t) = tags.get_mut(jy) {
t.value = p.y.to_string();
}
if let Some(t) = find(tags, 30, i).and_then(|jz| tags.get_mut(jz)) {
t.value = p.z.to_string();
}
}
_ => {
let at = tags.iter().position(|t| t.code == 1).unwrap_or(tags.len());
tags.splice(at..at, [tag(10, p.x.to_string()), tag(20, p.y.to_string()), tag(30, p.z.to_string())]);
}
}
}
}
/// Remove anonymous block `name` when nothing refers to it any more (the block a changed
/// reference used to show), with the data kept for its record.
fn drop_if_unused(d: &mut Drawing, name: &str) {
if !name.starts_with('*') {
return;
}
let refers = |e: &Entity| matches!(&e.kind, EntityKind::Insert(i) if i.block.eq_ignore_ascii_case(name));
let used = d.model.iter().any(|e| refers(e))
|| d.layouts.iter().any(|l| l.entities.iter().any(|e| refers(e)))
|| d.blocks.values().any(|b| b.entities.iter().any(|e| refers(e)));
if used {
return;
}
if let Some(b) = d.blocks.remove(name) {
let f = std::sync::Arc::make_mut(&mut d.foreign);
f.records.remove(&("BLOCK_RECORD".to_string(), name.to_ascii_uppercase()));
f.records.remove(&("BLOCK".to_string(), name.to_ascii_uppercase()));
for e in b.entities.iter() {
f.entities.remove(&e.handle);
}
}
}
+179
View File
@@ -0,0 +1,179 @@
//! The grips of a dynamic block reference, as AutoCAD shows them on a selected reference: an
//! arrow at each end of a distance (linear parameter), a square at a position (point parameter)
//! and a down-pointing triangle for a list (visibility states, lookup values). Dragging an arrow
//! or square is [`super::drag_grip`]; a list grip offers its property's choices.
//!
//! With a visibility parameter, a state lists the grips shown in it; the others are hidden.
use std::collections::{HashMap, HashSet};
use cadcraft_geom::{Vec2, Vec3};
use serde::Serialize;
use super::{ParameterKind, Value, custom_properties, dynamic_block, reference};
use crate::{Drawing, EntityKind, Handle};
/// Most grips listed for one reference.
const MAX_GRIPS: usize = 200;
/// Which of a parameter's points a grip is at.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub enum GripPart {
/// A distance's base point.
Base,
/// A distance's end point.
End,
/// A one-point parameter's point (a position, a visibility or lookup grip).
At,
}
impl GripPart {
pub fn name(self) -> &'static str {
match self {
GripPart::Base => "base",
GripPart::End => "end",
GripPart::At => "at",
}
}
pub fn from_name(s: &str) -> Option<GripPart> {
match s.trim().to_ascii_lowercase().as_str() {
"base" => Some(GripPart::Base),
"end" => Some(GripPart::End),
"at" => Some(GripPart::At),
_ => None,
}
}
}
/// How a grip looks and what it does.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub enum GripShape {
/// An arrow pointing away from the distance: drag it to change the distance.
Linear,
/// A square: drag it to move the position.
Point,
/// A down-pointing triangle: click it for the property's choices.
List,
}
/// One grip of a dynamic block reference.
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DynGrip {
/// The parameter's graph id.
pub node: i32,
pub part: GripPart,
pub shape: GripShape,
/// Where it is, in drawing coordinates.
#[serde(serialize_with = "point")]
pub at: Vec2,
/// The way an arrow points (a unit vector in drawing coordinates; zero for the others).
#[serde(serialize_with = "point")]
pub dir: Vec2,
/// The property it changes, as Properties names it.
pub property: String,
/// A list grip's choices.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub choices: Vec<String>,
}
fn point<S: serde::Serializer>(p: &Vec2, s: S) -> Result<S::Ok, S::Error> {
[p.x, p.y].serialize(s)
}
/// The grips of dynamic block reference `insert` (none when it is not one).
pub fn grips(d: &Drawing, insert: Handle) -> Vec<DynGrip> {
let Some(r) = reference(d, insert) else { return Vec::new() };
let Some(m) = dynamic_block(d, &r.definition) else { return Vec::new() };
let Some(EntityKind::Insert(ins)) = d.entity(insert).map(|e| &e.kind) else { return Vec::new() };
let base = d.block(&r.definition).map(|b| b.base.xy()).unwrap_or_default();
let xf = ins.transform(base);
let world = |p: Vec3| xf.apply(p.xy());
let toward = |p: Vec3, u: Vec3| {
let v = xf.apply(p.xy() + u.xy()) - xf.apply(p.xy());
if v.len() > 1e-12 { v * (1.0 / v.len()) } else { Vec2::ZERO }
};
// The grips the current visibility state shows (all of them without visibility states).
let objects: HashMap<i32, Handle> = m.grip_objects.iter().copied().collect();
let mut shown_in_state: Option<HashSet<Handle>> = None;
let mut visibility_node = None;
for p in &m.parameters {
if let ParameterKind::Visibility { states, .. } = &p.kind {
let current = match r.values.get(&p.node) {
Some(Value::Text { value }) => states.iter().find(|s| s.name.eq_ignore_ascii_case(value)),
_ => states.first(),
};
if let Some(s) = current {
shown_in_state = Some(s.active.iter().copied().collect());
}
visibility_node = Some(p.node);
break;
}
}
let shown = |node: i32, id: i32| {
id != 0 && (Some(node) == visibility_node || shown_in_state.as_ref().is_none_or(|a| objects.get(&id).is_some_and(|h| a.contains(h))))
};
let choices: HashMap<String, Vec<String>> =
custom_properties(d, insert).map(|(_, ps)| ps.into_iter().map(|p| (p.name.to_ascii_uppercase(), p.choices)).collect()).unwrap_or_default();
let mut out = Vec::new();
for p in &m.parameters {
let grip = |i: usize| p.grips.get(i).copied().unwrap_or(0);
match &p.kind {
ParameterKind::Linear { base, end, label, .. } => {
let (b, e) = match r.values.get(&p.node) {
Some(Value::Points { base, end }) => (*base, *end),
_ => (*base, *end),
};
let u = e - b;
let u = if u.len() > 1e-12 { u * (1.0 / u.len()) } else { Vec3::new(1.0, 0.0, 0.0) };
for (i, part, at, way) in [(0, GripPart::Base, b, u * -1.0), (1, GripPart::End, e, u)] {
if shown(p.node, grip(i)) {
let (at, dir) = (world(at), toward(at, way));
out.push(DynGrip { node: p.node, part, shape: GripShape::Linear, at, dir, property: label.clone(), choices: Vec::new() });
}
}
}
ParameterKind::Point { at, label, .. } => {
let at = match r.values.get(&p.node) {
Some(Value::Point { at }) => *at,
_ => *at,
};
if shown(p.node, grip(0)) {
let at = world(at);
out.push(DynGrip {
node: p.node,
part: GripPart::At,
shape: GripShape::Point,
at,
dir: Vec2::ZERO,
property: label.clone(),
choices: Vec::new(),
});
}
}
ParameterKind::Visibility { at, label, .. } | ParameterKind::Lookup { at, label, .. } => {
let Some(list) = choices.get(&label.to_ascii_uppercase()).filter(|c| !c.is_empty()) else { continue };
if shown(p.node, grip(0)) {
let at = world(*at);
out.push(DynGrip {
node: p.node,
part: GripPart::At,
shape: GripShape::List,
at,
dir: Vec2::ZERO,
property: label.clone(),
choices: list.clone(),
});
}
}
_ => {}
}
if out.len() >= MAX_GRIPS {
break;
}
}
out.retain(|g| g.at.is_finite());
out
}
+283
View File
@@ -0,0 +1,283 @@
//! Dynamic block references: which dynamic block a block reference shows, the values it was given
//! and the custom properties those make.
//!
//! A reference whose values differ from the block's own refers to an anonymous block (`*U12`)
//! holding the result, and its extension dictionary says what it is: `AcDbBlockRepresentation`
//! has `AcDbRepData` (pointing at the dynamic block's BLOCK_RECORD) and an `AppDataCache` whose
//! `ACAD_ENHANCEDBLOCKDATA` dictionary holds one XRECORD per parameter, keyed by its graph id, with
//! the value in block coordinates. A reference to the dynamic block itself has the block's values.
use std::collections::{BTreeMap, HashMap};
use cadcraft_geom::Vec3;
use serde::Serialize;
use super::{ActionKind, DynamicBlock, ParameterKind, dynamic_block};
use crate::foreign::ForeignObject;
use crate::{Drawing, EntityKind, Handle};
/// Most values one reference is read with.
const MAX_VALUES: usize = 10_000;
/// A parameter's value on one reference, in block coordinates.
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")]
pub enum Value {
/// A visibility state's name or a lookup value.
Text { value: String },
/// A point parameter's position.
Point { at: Vec3 },
/// A two-point parameter's points.
Points { base: Vec3, end: Vec3 },
}
/// A dynamic block reference.
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Reference {
/// The dynamic block it shows (its name in the drawing).
pub definition: String,
/// The values it was given, by parameter graph id; parameters missing here have the block's
/// own values.
pub values: BTreeMap<i32, Value>,
}
/// One custom property of a reference, as the Properties palette shows it.
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CustomProperty {
pub name: String,
pub value: String,
/// The values it may be set to, when it is a choice (visibility states, lookup values, a
/// distance's list).
pub choices: Vec<String>,
/// CADCraft can change it ([`super::set_property`]): a visibility state, a distance, a
/// position, or a lookup whose tables set only those.
pub editable: bool,
}
fn objects(d: &Drawing) -> HashMap<Handle, &ForeignObject> {
d.foreign.objects.iter().map(|o| (o.handle, o)).collect()
}
/// The object entry `key` of dictionary `dict` refers to.
fn entry(objects: &HashMap<Handle, &ForeignObject>, dict: Handle, key: &str) -> Option<Handle> {
let o = objects.get(&dict)?;
let mut tags = o.tags.iter();
while let Some(t) = tags.next() {
if t.code == 3 && t.value.eq_ignore_ascii_case(key) {
return tags.next().filter(|t| t.code == 350 || t.code == 360).and_then(|t| Handle::parse_hex(&t.value));
}
}
None
}
/// Every entry of dictionary `dict`: (key, object).
fn entries(objects: &HashMap<Handle, &ForeignObject>, dict: Handle) -> Vec<(String, Handle)> {
let Some(o) = objects.get(&dict) else { return Vec::new() };
let mut out = Vec::new();
let mut key: Option<&str> = None;
for t in &o.tags {
match t.code {
3 => key = Some(t.value.as_str()),
350 | 360 => {
if let (Some(k), Some(h)) = (key.take(), Handle::parse_hex(&t.value)) {
out.push((k.to_string(), h));
}
}
_ => {}
}
}
out
}
/// A value XRECORD: its text (group 1) when it has one, else its points (10/20/30).
fn value(o: &ForeignObject) -> Option<Value> {
if let Some(t) = o.tags.iter().find(|t| t.code == 1) {
return Some(Value::Text { value: t.value.clone() });
}
let num = |code: i32, i: usize| {
o.tags.iter().filter(|t| t.code == code).nth(i).and_then(|t| t.value.trim().parse::<f64>().ok()).filter(|v| v.is_finite())
};
let point = |i: usize| Some(Vec3::new(num(10, i)?, num(20, i)?, num(30, i).unwrap_or(0.0)));
match (point(0), point(1)) {
(Some(base), Some(end)) => Some(Value::Points { base, end }),
(Some(at), None) => Some(Value::Point { at }),
_ => None,
}
}
/// The dynamic block reference `insert` is, or `None` when it isn't one.
pub fn reference(d: &Drawing, insert: Handle) -> Option<Reference> {
let EntityKind::Insert(ins) = &d.entity(insert)?.kind else { return None };
let objects = objects(d);
let rep = d.foreign.entities.get(&insert).and_then(|x| x.xdict).and_then(|x| entry(&objects, x, "AcDbBlockRepresentation"));
let Some(rep) = rep else {
// No representation: a reference to a dynamic block with its own values.
return dynamic_block(d, &ins.block)
.is_some()
.then(|| Reference { definition: d.block(&ins.block).map_or_else(|| ins.block.clone(), |b| b.name.clone()), values: BTreeMap::new() });
};
let data = entry(&objects, rep, "AcDbRepData").and_then(|h| objects.get(&h).copied())?;
let target = data.tags.iter().find(|t| t.code == 340).and_then(|t| Handle::parse_hex(&t.value))?;
let (kind, name) = d.foreign.targets.get(&target)?;
let definition = d.block(name).filter(|_| kind == "BLOCK_RECORD")?.name.clone();
dynamic_block(d, &definition)?;
let mut values = BTreeMap::new();
let cache = entry(&objects, rep, "AppDataCache").and_then(|c| entry(&objects, c, "ACAD_ENHANCEDBLOCKDATA"));
for (key, h) in cache.map(|c| entries(&objects, c)).unwrap_or_default().into_iter().take(MAX_VALUES) {
if let (Ok(node), Some(v)) = (key.trim().parse::<i32>(), objects.get(&h).and_then(|o| value(o))) {
values.insert(node, v);
}
}
Some(Reference { definition, values })
}
/// A number as the drawing shows lengths (LUPREC decimals).
fn number(d: &Drawing, v: f64) -> String {
let prec = usize::try_from(d.header.i64("LUPREC", 4).clamp(0, 8)).unwrap_or(4);
format!("{v:.prec$}")
}
/// Whether table cell `cell` reads as `value` (numbers compare as numbers).
fn same(cell: &str, value: &str) -> bool {
match (cell.trim().parse::<f64>(), value.trim().parse::<f64>()) {
(Ok(a), Ok(b)) => (a - b).abs() <= 1e-6 * a.abs().max(b.abs()).max(1.0),
_ => cell.trim().eq_ignore_ascii_case(value.trim()),
}
}
/// The current values of a reference's parameters, for evaluating its lookup tables.
struct Current<'a> {
m: &'a DynamicBlock,
values: &'a BTreeMap<i32, Value>,
}
impl Current<'_> {
/// Parameter `node`'s value of property `property`, as table cells write it.
fn property(&self, node: i32, property: &str) -> Option<String> {
let p = self.m.parameters.iter().find(|p| p.node == node)?;
let v = self.values.get(&node);
match (&p.kind, property.to_ascii_lowercase().as_str()) {
(ParameterKind::Visibility { states, .. }, "visibilitystate") => match v {
Some(Value::Text { value }) => Some(value.clone()),
_ => states.first().map(|s| s.name.clone()),
},
(ParameterKind::Linear { base, end, .. }, "updateddistance" | "distance") => {
let (b, e) = match v {
Some(Value::Points { base, end }) => (*base, *end),
_ => (*base, *end),
};
Some(format!("{}", (e - b).len()))
}
(ParameterKind::Lookup { .. }, "lookupstring") => match v {
Some(Value::Text { value }) => Some(value.clone()),
_ => None,
},
_ => None,
}
}
/// Lookup parameter `node`'s value: the value every lookup table that sets it gives for the
/// current inputs, or the tables' "no match" name ("Custom") when they don't agree or a table
/// has no matching row. Also the values it may be set to.
fn lookup(&self, node: i32) -> (String, Vec<String>) {
let mut found: Option<Option<String>> = None;
let mut unmatched = String::from("Custom");
let mut choices: Vec<String> = Vec::new();
for a in &self.m.actions {
let ActionKind::Lookup { columns, rows } = &a.kind else { continue };
let Some(out) = columns.iter().position(|c| c.lookup && c.node == node) else { continue };
if rows.is_empty() {
continue;
}
if let Some(c) = columns.get(out) {
unmatched = c.unmatched.clone();
}
for r in rows {
if let Some(v) = r.get(out).filter(|v| !choices.contains(v)) {
choices.push(v.clone());
}
}
let hit = rows.iter().find(|r| {
columns.iter().enumerate().filter(|(_, c)| !c.lookup).all(|(i, c)| match (r.get(i), self.property(c.node, &c.property)) {
(Some(cell), Some(v)) => same(cell, &v),
_ => false,
})
});
let v = hit.and_then(|r| r.get(out).cloned());
found = Some(match found {
None => v,
Some(prev) if prev == v => prev,
Some(_) => None,
});
}
(found.flatten().unwrap_or(unmatched), choices)
}
}
/// The custom properties block reference `insert` shows (the Properties palette's Custom
/// section): its dynamic block's name and, in parameter order, each parameter shown in Properties
/// with its value. Lengths and positions are in drawing units (block units × the reference's
/// scale); positions are relative to the block's base point.
pub fn custom_properties(d: &Drawing, insert: Handle) -> Option<(String, Vec<CustomProperty>)> {
let r = reference(d, insert)?;
let EntityKind::Insert(ins) = &d.entity(insert)?.kind else { return None };
let m = dynamic_block(d, &r.definition)?;
let base = d.block(&r.definition).map(|b| b.base).unwrap_or_default();
let (sx, sy) = (ins.scale.x.abs(), ins.scale.y.abs());
let cur = Current { m: &m, values: &r.values };
let mut out = Vec::new();
for p in m.parameters.iter().filter(|p| p.show_properties) {
let v = r.values.get(&p.node);
match &p.kind {
ParameterKind::Linear { base: b, end: e, label, values, .. } => {
let (b, e) = match v {
Some(Value::Points { base, end }) => (*base, *end),
_ => (*b, *e),
};
let choices = if values.is_list() { values.list.iter().map(|x| number(d, x * sx)).collect() } else { Vec::new() };
out.push(CustomProperty { name: label.clone(), value: number(d, (e - b).len() * sx), choices, editable: true });
}
ParameterKind::Point { at, label, .. } => {
let at = match v {
Some(Value::Point { at }) => *at,
_ => *at,
};
out.push(CustomProperty { name: format!("{label} X"), value: number(d, (at.x - base.x) * sx), choices: Vec::new(), editable: true });
out.push(CustomProperty { name: format!("{label} Y"), value: number(d, (at.y - base.y) * sy), choices: Vec::new(), editable: true });
}
ParameterKind::Visibility { label, states, .. } => {
let value = cur.property(p.node, "VisibilityState").unwrap_or_default();
out.push(CustomProperty { name: label.clone(), value, choices: states.iter().map(|s| s.name.clone()).collect(), editable: true });
}
ParameterKind::Lookup { label, .. } => {
let (value, choices) = cur.lookup(p.node);
let editable = lookup_sets_what_changes(&m, p.node);
out.push(CustomProperty { name: label.clone(), value, choices, editable });
}
_ => {}
}
}
Some((r.definition, out))
}
/// Whether every lookup table that sets lookup parameter `node` sets only what
/// [`super::set_property`] can apply with it: visibility states and distances.
fn lookup_sets_what_changes(m: &DynamicBlock, node: i32) -> bool {
let mut any = false;
for a in &m.actions {
let ActionKind::Lookup { columns, rows } = &a.kind else { continue };
if rows.is_empty() || !columns.iter().any(|c| c.lookup && c.node == node) {
continue;
}
any = true;
if !columns
.iter()
.all(|c| matches!(c.property.to_ascii_lowercase().as_str(), "lookupstring" | "visibilitystate" | "updateddistance" | "distance"))
{
return false;
}
}
any
}
+111 -1
View File
@@ -103,6 +103,11 @@ pub struct Common {
/// 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 {
@@ -119,6 +124,7 @@ impl Default for Common {
extrusion: Vec3::Z,
plot_style: "ByLayer".into(),
anno_scales: Vec::new(),
xdata: Vec::new(),
}
}
}
@@ -343,6 +349,98 @@ pub struct Attrib {
/// 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
@@ -825,6 +923,10 @@ pub struct TableCell {
/// 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")]
@@ -1155,7 +1257,10 @@ impl EntityKind {
r.dir = m.apply_vec(r.dir.xy()).normalized().to3(0.0);
}
EntityKind::Text(t) => transform_text(t, m, s, rot, mirror),
EntityKind::AttDef(a) => transform_text(&mut a.text, m, s, rot, mirror),
EntityKind::AttDef(a) => {
transform_text(&mut a.text, m, s, rot, mirror);
a.transform_multiline(m);
}
EntityKind::MText(t) => {
t3(&mut t.insert);
t.height *= s;
@@ -1177,6 +1282,7 @@ impl EntityKind {
}
for a in &mut i.attribs {
transform_text(&mut a.text, m, s, rot, false);
a.transform_multiline(m);
}
i.col_spacing *= s;
i.row_spacing *= s;
@@ -1206,6 +1312,10 @@ impl EntityKind {
if let Some(t) = &mut ml.text {
t3(&mut t.insert);
t.height *= s;
t.width *= s;
if let Some(c) = &mut t.columns {
c.scale(s);
}
}
if let Some(b) = &mut ml.block {
// Transformed exactly like a block reference.
+39 -22
View File
@@ -88,10 +88,12 @@ pub fn entity_bounds(d: &Drawing, e: &Entity, depth: usize) -> Bounds2 {
}
/// [`entity_bounds`] with the bounds of block contents (in block coordinates) already computed,
/// by block name: every further reference to a block reuses them instead of expanding the block
/// again, so nested or self-referencing blocks cost one pass over each definition instead of one
/// per reference (which grows exponentially with the nesting depth).
fn bounds_in(d: &Drawing, e: &Entity, depth: usize, blocks: &mut HashMap<String, Bounds2>) -> Bounds2 {
/// by block name and depth: every further reference to a block at that depth reuses them instead of
/// expanding the block again, so nested or self-referencing blocks cost at most one pass over each
/// definition per depth instead of one per reference (which grows exponentially with the nesting
/// depth). The depth is part of the key because [`MAX_BLOCK_DEPTH`] cuts a deep visit short: its
/// bounds must not stand in for a shallower reference to the same block (#706).
fn bounds_in<'a>(d: &'a Drawing, e: &Entity, depth: usize, blocks: &mut HashMap<(&'a str, usize), Bounds2>) -> Bounds2 {
match &e.kind {
EntityKind::Table(t) if t.is_broken() => t.bounds(),
EntityKind::Text(t) if blank(&t.value) => Bounds2::EMPTY,
@@ -106,28 +108,34 @@ fn bounds_in(d: &Drawing, e: &Entity, depth: usize, blocks: &mut HashMap<String,
// A reference to a missing block (an unloaded xref, a purged definition) or to an empty
// one draws nothing, so it contributes nothing: ZOOM Extents must not fly off to its
// insertion point. Attributes below still count.
let Some(blk) = d.block(&ins.block) else { return Bounds2::EMPTY };
let m = ins.transform(blk.base.xy());
let inner = match blocks.get(&blk.name) {
Some(b) => *b,
None => {
let mut inner = Bounds2::EMPTY;
for be in blk.entities.iter().filter(|be| in_block_visible(d, be)) {
inner = inner.union(&bounds_in(d, be, depth + 1, blocks));
let mut b = Bounds2::EMPTY;
if let Some(blk) = d.block(&ins.block) {
let m = ins.transform(blk.base.xy());
let inner = match blocks.get(&(blk.name.as_str(), depth)) {
Some(b) => *b,
None => {
let mut inner = Bounds2::EMPTY;
for be in blk.entities.iter().filter(|be| in_block_visible(d, be)) {
inner = inner.union(&bounds_in(d, be, depth + 1, blocks));
}
blocks.insert((blk.name.as_str(), depth), inner);
inner
}
blocks.insert(blk.name.clone(), inner);
inner
};
b = if inner.is_empty() { Bounds2::EMPTY } else { Bounds2::from_points(inner.corners().map(|c| m.apply(c))) };
if ins.cols > 1 || ins.rows > 1 {
let off =
Vec2::new(ins.col_spacing * f64::from(ins.cols.saturating_sub(1)), ins.row_spacing * f64::from(ins.rows.saturating_sub(1)))
.rotate(ins.rotation);
b = b.union(&Bounds2::from_points(b.corners().map(|c| c + off)));
}
};
let mut b = if inner.is_empty() { Bounds2::EMPTY } else { Bounds2::from_points(inner.corners().map(|c| m.apply(c))) };
if ins.cols > 1 || ins.rows > 1 {
let off = Vec2::new(ins.col_spacing * f64::from(ins.cols.saturating_sub(1)), ins.row_spacing * f64::from(ins.rows.saturating_sub(1)))
.rotate(ins.rotation);
b = b.union(&Bounds2::from_points(b.corners().map(|c| c + off)));
}
for a in &ins.attribs {
if !a.invisible {
b = b.union(&text_box(&a.text, a.text.value.chars().count()));
b = b.union(&match a.multiline_text() {
Some(m) => mtext_box(&m),
None => text_box(&a.text, a.text.value.chars().count()),
});
}
}
b
@@ -309,7 +317,16 @@ mod tests {
#[test]
fn right_justified_attdef_is_left_of_alignment_point() {
let text = text(HAlign::Right, VAlign::Baseline);
let a = crate::Attrib { tag: "HELLO".into(), text, invisible: false, constant: false, prompt: String::new(), props: Default::default() };
let a = crate::Attrib {
tag: "HELLO".into(),
text,
invisible: false,
constant: false,
prompt: String::new(),
props: Default::default(),
multiline: None,
modes: Default::default(),
};
let b = bounds(EntityKind::AttDef(a));
assert!(b.min.x <= -4.4 && b.max.x < 1.0);
}
+13 -1
View File
@@ -34,7 +34,8 @@ pub struct ForeignExtra {
pub xdict: Option<Handle>,
/// Objects of [`Foreign::objects`] that react to it (persistent reactors).
pub reactors: Vec<Handle>,
/// Other applications' extended data: groups from `1001` on.
/// Other applications' extended data of a table record or block: groups from `1001` on. An
/// entity's is part of the entity ([`crate::Common::xdata`]), so it follows the entity's copies.
pub xdata: Vec<RawTag>,
/// The colour book colour (group 430) and the true colour (group 420) it was read with: it is
/// written back while the colour is unchanged.
@@ -65,6 +66,11 @@ pub struct Foreign {
pub records: BTreeMap<ForeignKey, ForeignExtra>,
/// The roles of the file handles the kept data refers to, for objects CADCraft rewrites.
pub targets: BTreeMap<Handle, ForeignKey>,
/// The file's data store section (ACDSDATA, AutoCAD 2013 and later), as read: its groups
/// after the section name. It holds the ACIS geometry of 3D solids, regions, bodies and
/// surfaces, which from 2013 on those entities don't carry themselves: each `ACDSRECORD`
/// names its entity with a 320 handle (see [`Foreign::data_store_entities`]).
pub data_store: Vec<RawTag>,
}
impl Foreign {
@@ -75,5 +81,11 @@ impl Foreign {
&& self.records.is_empty()
&& self.classes.is_empty()
&& self.targets.is_empty()
&& self.data_store.is_empty()
}
/// The entities the data store has records for (their 320 handles).
pub fn data_store_entities(&self) -> std::collections::BTreeSet<Handle> {
self.data_store.iter().filter(|t| t.code == 320).filter_map(|t| Handle::parse_hex(t.value.trim())).collect()
}
}
+35
View File
@@ -15,6 +15,7 @@ mod header;
pub mod library;
mod plotstyle;
mod plotter;
mod refedit;
mod store;
mod tables;
@@ -30,10 +31,12 @@ pub use gdt::*;
pub use header::{HVal, Header};
pub use plotstyle::*;
pub use plotter::*;
pub use refedit::{RefEdit, WorkItem};
pub use store::EntityStore;
pub use tables::*;
/// File data CADCraft doesn't model, kept for saving (#528).
pub mod dynblock;
pub mod foreign;
#[derive(Debug, thiserror::Error)]
@@ -59,6 +62,26 @@ pub enum Space {
Paper(String),
}
impl Space {
/// Whether this is a layout's paper space.
pub fn is_paper(&self) -> bool {
matches!(self, Space::Paper(_))
}
/// The layout whose paper space this is; `None` for model space.
pub fn layout(&self) -> Option<&str> {
match self {
Space::Paper(n) => Some(n),
Space::Model => None,
}
}
/// The tab name (CTAB): `Model`, or the layout's name.
pub fn tab_name(&self) -> &str {
self.layout().unwrap_or("Model")
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct Drawing {
pub header: Header,
@@ -95,6 +118,15 @@ pub struct Drawing {
/// Objects hidden for this session by ISOLATEOBJECTS / HIDEOBJECTS. Never written to a file:
/// a drawing opens with everything shown again.
pub hidden_objects: BTreeSet<Handle>,
/// What reading the file left out (a DWG's object records the reader couldn't decode), told
/// to the user when the drawing opens. Never written to a file.
pub read_notes: Vec<String>,
/// The in-place block reference edit in progress (REFEDIT). Never written to a file; files are
/// written from [`Drawing::committed`].
pub refedit: Option<RefEdit>,
/// Records of the file this drawing was read from that the reader could not place (a damaged
/// file), counted by the section they came from. Never written to a file.
pub skipped_records: BTreeMap<String, usize>,
}
impl Default for Drawing {
@@ -148,6 +180,9 @@ impl Drawing {
scales: imperial_scales(),
handseed: 0x100,
hidden_objects: BTreeSet::new(),
read_notes: Vec::new(),
refedit: None,
skipped_records: BTreeMap::new(),
}
}
+83
View File
@@ -0,0 +1,83 @@
//! In-place editing of a block reference (REFEDIT / REFSET / REFCLOSE): the state kept with the
//! drawing while it lasts.
//!
//! The reference being edited is hidden and the block's objects stand in the drawing as ordinary
//! objects (the *working set*, transformed to where the reference shows them), the rest of the
//! block as faded copies (*ghosts*) that cannot be edited. REFCLOSE writes the working set back
//! into the block definition. The state lives in the [`Drawing`] itself, so undo and redo move
//! the working copies and the state together and can never leave one without the other.
use std::borrow::Cow;
use cadcraft_geom::Mat3;
use crate::{Drawing, Entity, Handle, Space};
/// One object of the working set.
#[derive(Clone, Debug, PartialEq)]
pub struct WorkItem {
/// The object in the drawing (a copy of the block object, or a drawing object added to the set).
pub copy: Handle,
/// The block object it was copied from; `None` for an object added from the drawing.
pub source: Option<Handle>,
}
/// An active in-place edit of a block reference. Never written to a file.
#[derive(Clone, Debug, PartialEq)]
pub struct RefEdit {
/// The block being edited.
pub block: String,
/// The reference that was picked (hidden while editing).
pub insert: Handle,
/// Whether the reference was visible before it was hidden.
pub insert_was_visible: bool,
/// The space the reference is in.
pub space: Space,
/// Block coordinates to drawing coordinates, through the picked reference.
pub to_world: Mat3,
/// The working set.
pub items: Vec<WorkItem>,
/// The rest of the block as faded copies: (copy, block object).
pub ghosts: Vec<(Handle, Handle)>,
/// Block objects taken out of the working set with their edits (block coordinates, the block
/// object's handle).
pub released: Vec<Entity>,
}
impl RefEdit {
/// Whether `h` is a working set object.
pub fn is_working(&self, h: Handle) -> bool {
self.items.iter().any(|i| i.copy == h)
}
/// Whether `h` is something the edit manages (working set, ghost, the hidden reference).
pub fn owns(&self, h: Handle) -> bool {
h == self.insert || self.is_working(h) || self.ghosts.iter().any(|(g, _)| *g == h)
}
}
impl Drawing {
/// The drawing as REFCLOSE Discard would leave it: the working copies and ghosts gone, the
/// reference shown, the block untouched. Borrowed when no edit is active. Files are written
/// from this, so a saved or autosaved drawing never holds half an edit.
pub fn committed(&self) -> Cow<'_, Drawing> {
let Some(r) = &self.refedit else { return Cow::Borrowed(self) };
let mut d = self.clone();
d.end_refedit_discard(r);
Cow::Owned(d)
}
/// Remove the edit's working copies and ghosts, show the reference again and clear the state.
pub fn end_refedit_discard(&mut self, r: &RefEdit) {
for i in &r.items {
if i.source.is_some() {
self.remove_entity(i.copy);
}
}
for (g, _) in &r.ghosts {
self.remove_entity(*g);
}
let vis = r.insert_was_visible;
let _ = self.modify_entity(r.insert, |e| e.common.visible = vis);
self.refedit = None;
}
}
+4
View File
@@ -747,6 +747,9 @@ pub struct PageSetup {
/// Draw the layout on screen (and in its PNG/SVG exports) with its plot style table
/// applied ("Display plot styles"; DXF plot layout flag 2).
pub show_plot_styles: bool,
/// Plot objects with their transparency ("Plot transparency"; PLOTTRANSPARENCYOVERRIDE can
/// override it). The DXF plot layout flags have no bit for it, so it is not saved in DXF.
pub plot_transparency: bool,
}
impl Default for PageSetup {
@@ -765,6 +768,7 @@ impl Default for PageSetup {
center: false,
lineweights: true,
show_plot_styles: false,
plot_transparency: false,
}
}
}
+98
View File
@@ -79,6 +79,59 @@ fn inserts_of_missing_or_empty_blocks_do_not_stretch_extents() {
assert_eq!(d.extents(&Space::Model).max, Vec2::new(12.0, 12.0));
}
#[test]
fn visible_attributes_of_a_missing_block_still_count() {
// The renderer draws the attributes of a reference whose definition is missing, so they are
// extents and selection targets, as with an empty definition. The insertion point still isn't.
let attrib = |invisible: bool| Attrib {
tag: "TAG".into(),
text: Text {
insert: Vec3::new(1000.0, 1000.0, 0.0),
align_pt: None,
height: 10.0,
value: "hello".into(),
rotation: 0.0,
width_factor: 1.0,
oblique: 0.0,
style: "Standard".into(),
halign: HAlign::Left,
valign: VAlign::Baseline,
},
invisible,
constant: false,
prompt: String::new(),
props: Default::default(),
multiline: None,
modes: Default::default(),
};
let ins = |block: &str, attribs: Vec<Attrib>| {
Entity::new(
Handle(1),
EntityKind::Insert(Insert {
block: block.into(),
insert: Vec3::ZERO,
scale: Vec3::new(1.0, 1.0, 1.0),
rotation: 0.0,
attribs,
cols: 1,
rows: 1,
col_spacing: 0.0,
row_spacing: 0.0,
}),
)
};
let mut d = Drawing::new_imperial();
d.blocks.insert("Empty".into(), std::sync::Arc::new(Block::new("Empty")));
let missing = entity_bounds(&d, &ins("Missing", vec![attrib(false)]), 0);
let empty = entity_bounds(&d, &ins("Empty", vec![attrib(false)]), 0);
assert!(!missing.is_empty());
assert_eq!((missing.min, missing.max), (empty.min, empty.max));
assert_eq!(missing.min, Vec2::new(1000.0, 1000.0));
// Without a visible attribute there is nothing to measure.
assert!(entity_bounds(&d, &ins("Missing", vec![]), 0).is_empty());
assert!(entity_bounds(&d, &ins("Missing", vec![attrib(true)]), 0).is_empty());
}
#[test]
fn transform_arc_mirror_keeps_shape() {
let mut k = EntityKind::Arc(Arc { center: Vec3::ZERO, radius: 1.0, start: 0.0, end: std::f64::consts::FRAC_PI_2 });
@@ -269,6 +322,38 @@ fn extents_pair(d: &Drawing) -> (Vec2, Vec2) {
(b.min, b.max)
}
/// #706: block A reaches B once through a chain of 14 blocks (B at depth 15, its line's block E at
/// 16, past the limit) and once directly (B at depth 1). `children` picks which of the two A holds.
fn deep_and_shallow(children: &[&str]) -> Drawing {
let mut d = Drawing::new_imperial();
let mut add = |name: &str, kids: Vec<EntityKind>| {
let mut blk = Block::new(name);
for (i, k) in kids.into_iter().enumerate() {
blk.entities.push(Entity::new(Handle(i as u64 + 1), k));
}
d.blocks.insert(name.into(), std::sync::Arc::new(blk));
};
add("E", vec![line((1000.0, 1000.0), (2000.0, 1000.0))]);
add("B", vec![plain_insert("E")]);
for i in 1..=14 {
let next = if i == 14 { "B".to_string() } else { format!("C{}", i + 1) };
add(&format!("C{i}"), vec![plain_insert(&next)]);
}
add("A", children.iter().map(|c| plain_insert(c)).collect());
d.add(&Space::Model, Common::default(), plain_insert("A")).unwrap();
d
}
#[test]
fn deep_block_visit_does_not_hide_a_shallow_reference() {
let line = (Vec2::new(1000.0, 1000.0), Vec2::new(2000.0, 1000.0));
// Control: past the limit, the deep path alone contributes nothing.
assert!(deep_and_shallow(&["C1"]).extents(&Space::Model).is_empty());
assert_eq!(extents_pair(&deep_and_shallow(&["B"])), line);
assert_eq!(extents_pair(&deep_and_shallow(&["B", "C1"])), line);
assert_eq!(extents_pair(&deep_and_shallow(&["C1", "B"])), line);
}
#[test]
fn block_entity_with_invisible_flag_is_ignored_in_insert_extents() {
let mut far = far_line("0");
@@ -354,6 +439,8 @@ fn attdef_in_block_is_ignored_in_insert_extents() {
constant: false,
prompt: String::new(),
props: Default::default(),
multiline: None,
modes: Default::default(),
}),
);
let d = drawing_with_block(attdef);
@@ -466,3 +553,14 @@ fn invisible_dimblock_entity_is_ignored_in_dimension_extents() {
dim(&mut d, far_line("0"));
assert_eq!(d.extents(&Space::Model).max, Vec2::new(6000.0, 6000.0));
}
#[test]
fn space_names_its_tab() {
assert!(!Space::Model.is_paper());
assert_eq!(Space::Model.layout(), None);
assert_eq!(Space::Model.tab_name(), "Model");
let p = Space::Paper("Sheet A".into());
assert!(p.is_paper());
assert_eq!(p.layout(), Some("Sheet A"));
assert_eq!(p.tab_name(), "Sheet A");
}
+14
View File
@@ -0,0 +1,14 @@
//! `Sub::parse` accepts every spelling its documentation lists (#710).
use cadcraft_doc::Sub;
#[test]
fn short_segment_name_matches_the_documented_forms() {
for spelling in ["seg2", "segment2", "s2"] {
assert_eq!(Sub::parse(spelling), Some(Sub::Segment(2)), "spelling: {spelling}");
}
for spelling in ["s", "start"] {
assert_eq!(Sub::parse(spelling), Some(Sub::Start), "spelling: {spelling}");
}
assert_eq!(Sub::parse("unknown"), None);
}
+1 -1
View File
@@ -10,4 +10,4 @@ workspace = true
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
# MPL-2.0: used as an unmodified dependency only (see plan/adr/0001 and NOTICE).
acadrust = "0.6.3"
acadrust = "0.6.4"
+170 -5
View File
@@ -61,12 +61,30 @@ mod native {
/// guard: the input size before the DWG reader runs, the DXF size while it is written, so a
/// runaway conversion stops at the limit instead of filling memory first.
pub fn dwg_to_dxf_with(bytes: &[u8], limits: Limits) -> Result<Vec<u8>, String> {
dwg_to_dxf_noted(bytes, limits).map(|(dxf, _)| dxf)
}
/// [`dwg_to_dxf_with`], also returning notes on what the read left out. The reader runs in
/// failsafe mode: an object record it can't decode is skipped instead of failing the whole
/// file, and the notes say how many were skipped and of which types, so a partial read never
/// passes for a complete one.
pub fn dwg_to_dxf_noted(bytes: &[u8], limits: Limits) -> Result<(Vec<u8>, Vec<String>), String> {
if bytes.len() > limits.max_dwg_bytes {
return Err(format!("DWG: the file is {}, larger than the {} CADCraft opens", mb(bytes.len() as u64), mb(limits.max_dwg_bytes as u64)));
}
let mut doc = std::panic::catch_unwind(|| acadrust::DwgReader::from_stream(Cursor::new(bytes)).read())
.map_err(|_| "the DWG reader failed on this file".to_string())?
.map_err(|e| format!("DWG: {e}"))?;
let outcome = std::panic::catch_unwind(|| {
acadrust::DwgReader::from_stream_with_options(Cursor::new(bytes), acadrust::DwgReadOptions::failsafe()).read_with_stats()
})
.map_err(|_| "the DWG reader failed on this file".to_string())?
.map_err(|e| format!("DWG: {e}"))?;
// Failsafe skips records, not whole files: when the objects stream itself couldn't be
// read (a damaged header or section map, or not a DWG at all), nothing was read.
if !outcome.stats.record_stream_read {
let why = outcome.stats.diagnostics.first().map(|d| d.message.trim_start_matches("Failsafe: ").to_string());
return Err(format!("DWG: {}", why.unwrap_or_else(|| "the file's objects couldn't be read".to_string())));
}
let notes = read_notes(&outcome.stats);
let mut doc = outcome.document;
table_merges(&mut doc);
table_style_rows(&mut doc, false);
let mut out = CappedWriter { buf: Vec::new(), limit: limits.max_dxf_bytes, exceeded: false };
@@ -81,7 +99,111 @@ mod native {
));
}
written.map_err(|e| format!("DWG→DXF: {e}"))?;
Ok(add_header_vars(out.buf, &doc.header))
Ok((add_header_vars(out.buf, &doc.header), notes))
}
/// Most record types named in the skipped-records note.
const NOTE_TYPES: usize = 6;
/// The note on object records a failsafe read skipped: how many, and how many of each type
/// (most first), or nothing when the read was complete.
pub(crate) fn read_notes(stats: &acadrust::ReadStats) -> Vec<String> {
let skipped = stats.skipped_source_records;
if skipped == 0 {
return Vec::new();
}
let label = |t: Option<&str>| match t.map(str::parse::<i16>) {
Some(Ok(code)) => type_name(code).map_or_else(|| format!("type {code}"), str::to_string),
Some(Err(_)) => t.unwrap_or_default().to_string(),
None => "unknown".to_string(),
};
let mut by_type: std::collections::BTreeMap<String, usize> = std::collections::BTreeMap::new();
for d in stats.diagnostics.iter().filter(|d| d.record_handle.is_some()) {
*by_type.entry(label(d.record_type.as_deref())).or_default() += 1;
}
let mut types: Vec<(String, usize)> = by_type.into_iter().collect();
types.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
let mut list: Vec<String> = types.iter().take(NOTE_TYPES).map(|(t, n)| format!("{t} × {n}")).collect();
if types.len() > NOTE_TYPES {
list.push(format!("{} more types", types.len() - NOTE_TYPES));
}
let of = if stats.source_records > 0 { format!(" of {}", stats.source_records) } else { String::new() };
let records = if skipped == 1 && of.is_empty() { "object record" } else { "object records" };
let (were, them) = if skipped == 1 { ("was", "it") } else { ("were", "them") };
let which = if list.is_empty() { String::new() } else { format!(" ({})", list.join(", ")) };
vec![format!(
"DWG: {skipped}{of} {records} couldn't be read and {were} left out{which}; \
saving over the original file would lose {them}"
)]
}
/// The DXF name of a DWG object type code with a fixed number (classes, 500 and up, are
/// numbered per file).
fn type_name(code: i16) -> Option<&'static str> {
Some(match code {
1 => "TEXT",
2 => "ATTRIB",
3 => "ATTDEF",
4 => "BLOCK",
5 => "ENDBLK",
6 => "SEQEND",
7 => "INSERT",
8 => "MINSERT",
10..=14 => "VERTEX",
15 | 16 | 29 | 30 => "POLYLINE",
17 => "ARC",
18 => "CIRCLE",
19 => "LINE",
20..=26 => "DIMENSION",
27 => "POINT",
28 => "3DFACE",
31 => "SOLID",
32 => "TRACE",
33 => "SHAPE",
34 => "VIEWPORT",
35 => "ELLIPSE",
36 => "SPLINE",
37 => "REGION",
38 => "3DSOLID",
39 => "BODY",
40 => "RAY",
41 => "XLINE",
42 => "DICTIONARY",
43 => "OLEFRAME",
44 => "MTEXT",
45 => "LEADER",
46 => "TOLERANCE",
47 => "MLINE",
48 => "BLOCK_CONTROL",
49 => "BLOCK_RECORD",
50 => "LAYER_CONTROL",
51 => "LAYER",
52 => "STYLE_CONTROL",
53 => "STYLE",
56 => "LTYPE_CONTROL",
57 => "LTYPE",
60 => "VIEW_CONTROL",
61 => "VIEW",
62 => "UCS_CONTROL",
63 => "UCS",
64 => "VPORT_CONTROL",
65 => "VPORT",
66 => "APPID_CONTROL",
67 => "APPID",
68 => "DIMSTYLE_CONTROL",
69 => "DIMSTYLE",
70 => "VX_CONTROL",
71 => "VX_TABLE_RECORD",
72 => "GROUP",
73 => "MLINESTYLE",
74 => "OLE2FRAME",
77 => "LWPOLYLINE",
78 => "HATCH",
79 => "XRECORD",
80 => "ACDBPLACEHOLDER",
82 => "LAYOUT",
_ => return None,
})
}
/// Merged table cells onto the cells. acadrust 0.6.3 reads a 2010+ (AC1024+) DWG table's
@@ -143,6 +265,44 @@ mod native {
}
}
/// Leave out what acadrust can't write as this DWG version: other programs' custom entities
/// and objects without raw DWG data of that version. acadrust 0.6.4 refuses the whole file
/// for one of them (0.6.3 left them out); CADCraft already tells the user which entity types
/// a DWG doesn't keep (`not_saved`), so the rest of the drawing is written. Returns how many.
fn drop_unwritable(doc: &mut acadrust::CadDocument) -> usize {
let version = doc.version;
let unwritable: Vec<acadrust::Handle> = doc
.entities()
.filter_map(|e| match e {
acadrust::entities::EntityType::Unknown(u)
if u.raw_dwg_data.is_none() || u.dwg_source_version.is_some_and(|v| v != version) || version == acadrust::DxfVersion::AC1009 =>
{
Some(u.common.handle)
}
_ => None,
})
.collect();
for h in &unwritable {
doc.remove_entity(*h);
}
let objects: Vec<acadrust::Handle> = doc
.objects
.iter()
.filter_map(|(h, o)| match o {
acadrust::objects::ObjectType::Unknown { raw_dwg_data, raw_dwg_version, .. }
if raw_dwg_data.is_none() || raw_dwg_version.is_some_and(|v| v != version) || version == acadrust::DxfVersion::AC1009 =>
{
Some(*h)
}
_ => None,
})
.collect();
for h in &objects {
doc.objects.remove(h);
}
unwritable.len() + objects.len()
}
/// Header variables the DWG reader fills in but acadrust's DXF writer (0.6.3) leaves out of
/// the HEADER section: (name, group code, value).
fn unwritten_header_vars(h: &acadrust::document::HeaderVariables) -> [(&'static str, i32, String); 7] {
@@ -239,6 +399,7 @@ mod native {
None => {}
}
table_style_rows(&mut doc, true);
drop_unwritable(&mut doc);
std::panic::catch_unwind(AssertUnwindSafe(|| acadrust::DwgWriter::write_to_vec(&doc)))
.map_err(|_| "the DWG writer failed on this drawing".to_string())?
.map_err(|e| format!("DWG write: {e}"))
@@ -246,7 +407,7 @@ mod native {
}
#[cfg(not(target_arch = "wasm32"))]
pub use native::{dwg_to_dxf, dwg_to_dxf_with, dxf_to_dwg, dxf_to_dwg_as};
pub use native::{dwg_to_dxf, dwg_to_dxf_noted, dwg_to_dxf_with, dxf_to_dwg, dxf_to_dwg_as};
/// The error for a DWG version CADCraft can't write.
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
@@ -260,6 +421,10 @@ pub fn dwg_to_dxf(_bytes: &[u8]) -> Result<Vec<u8>, String> {
Err("DWG files can't be opened in the web build yet; save as DXF".into())
}
#[cfg(target_arch = "wasm32")]
pub fn dwg_to_dxf_noted(_bytes: &[u8], _limits: Limits) -> Result<(Vec<u8>, Vec<String>), String> {
Err("DWG files can't be opened in the web build yet; save as DXF".into())
}
#[cfg(target_arch = "wasm32")]
pub fn dwg_to_dxf_with(_bytes: &[u8], _limits: Limits) -> Result<Vec<u8>, String> {
Err("DWG files can't be opened in the web build yet; save as DXF".into())
}
+46 -16
View File
@@ -109,6 +109,9 @@ pub enum Job {
struct Tracked {
/// The drawing as last autosaved (a weak pointer: compared, never kept alive).
written: Option<Weak<Drawing>>,
/// The drawing outside the Block Editor as last autosaved (the same as `written` when the
/// editor is closed): saving the editor's changes into the block changes only this.
host: Option<Weak<Drawing>>,
/// Its autosave file, once written (or adopted).
file: Option<String>,
/// When a change not yet autosaved was first seen (seconds).
@@ -122,8 +125,9 @@ pub struct Autosaver {
docs: HashMap<u64, Tracked>,
/// Drawings whose autosave is being written right now.
busy: HashSet<u64>,
/// Writes in progress; each tells whether its file was written.
#[cfg(not(target_arch = "wasm32"))]
running: Vec<(u64, std::thread::JoinHandle<()>)>,
running: Vec<(u64, std::thread::JoinHandle<bool>)>,
#[cfg(not(target_arch = "wasm32"))]
start: std::time::Instant,
#[cfg(not(target_arch = "wasm32"))]
@@ -149,8 +153,9 @@ impl Autosaver {
/// Recovery, whose current state it holds): later autosaves overwrite it, and it is deleted
/// once the drawing is saved or closed.
pub fn adopt(&mut self, s: &Session, uid: u64, path: &str) {
let written = s.docs.iter().find(|d| d.uid == uid).map(|d| Arc::downgrade(&d.doc));
self.docs.insert(uid, Tracked { written, file: Some(path.to_string()), since: None });
let st = s.docs.iter().find(|d| d.uid == uid);
let (written, host) = (st.map(|d| Arc::downgrade(&d.doc)), st.map(|d| Arc::downgrade(d.drawing())));
self.docs.insert(uid, Tracked { written, host, file: Some(path.to_string()), since: None });
}
/// The autosave files this app has written or adopted.
@@ -193,7 +198,8 @@ impl Autosaver {
*t = Tracked::default();
continue;
}
let changed = t.written.as_ref().is_none_or(|w| !std::ptr::eq(w.as_ptr(), Arc::as_ptr(&st.doc)));
let same = |w: &Option<Weak<Drawing>>, d: &Arc<Drawing>| w.as_ref().is_some_and(|w| std::ptr::eq(w.as_ptr(), Arc::as_ptr(d)));
let changed = !same(&t.written, &st.doc) || !same(&t.host, st.drawing());
if !changed {
t.since = None;
continue;
@@ -208,8 +214,10 @@ impl Autosaver {
.unwrap_or_else(|| std::path::Path::new(dir).join(file_name(&st.title, &self.instance, st.uid)).to_string_lossy().to_string());
t.file = Some(path.clone());
t.written = Some(Arc::downgrade(&st.doc));
t.host = Some(Arc::downgrade(st.drawing()));
t.since = None;
jobs.push(Job::Write { uid: st.uid, path, doc: st.doc.clone() });
// In the Block Editor: the drawing with the editor's unsaved changes in the block.
jobs.push(Job::Write { uid: st.uid, path, doc: st.recovery_drawing() });
}
jobs
}
@@ -341,14 +349,7 @@ mod native {
/// Returns how long until the next autosave is due, so the host can wake up for it.
pub fn tick(&mut self, s: &Session) -> Option<Duration> {
let now = self.start.elapsed().as_secs_f64();
let busy = &mut self.busy;
self.running.retain(|(uid, h)| {
let running = !h.is_finished();
if !running {
busy.remove(uid);
}
running
});
self.reap();
let dir = savefilepath();
let minutes = savetime();
let jobs = self.plan(s, now, minutes, &dir);
@@ -371,6 +372,26 @@ mod native {
writes
}
/// Take in the writes that have finished ([`Autosaver::tick`] does this first). A drawing
/// whose write failed is no longer counted as autosaved: the next plan restarts its clock,
/// so it is tried again SAVETIME later even if it doesn't change.
pub fn reap(&mut self) {
let (done, running) = std::mem::take(&mut self.running).into_iter().partition::<Vec<_>, _>(|(_, h)| h.is_finished());
self.running = running;
for (uid, h) in done {
self.busy.remove(&uid);
if !h.join().unwrap_or(false) {
self.unwritten(uid);
}
}
}
fn unwritten(&mut self, uid: u64) {
if let Some(t) = self.docs.get_mut(&uid) {
t.written = None;
}
}
/// Carry out jobs from [`Autosaver::plan`]: writes on a background thread, deletes now.
pub fn perform(&mut self, jobs: Vec<Job>) {
for job in jobs {
@@ -382,15 +403,24 @@ mod native {
continue;
};
let spawned = std::thread::Builder::new().name("autosave".into()).spawn(move || match write_file(write, &doc, &path) {
Ok(()) => log::info!("autosaved {path}"),
Err(e) => log::warn!("autosave {path}: {e}"),
Ok(()) => {
log::info!("autosaved {path}");
true
}
Err(e) => {
log::warn!("autosave {path}: {e}");
false
}
});
match spawned {
Ok(h) => {
self.busy.insert(uid);
self.running.push((uid, h));
}
Err(e) => log::warn!("autosave: {e}"),
Err(e) => {
log::warn!("autosave: {e}");
self.unwritten(uid);
}
}
}
}
+313
View File
@@ -0,0 +1,313 @@
//! The Block Editor's state (BEDIT). While it is open, the drawing's tab shows the block's objects
//! as its model space, moved so that the block's base point is at the origin, and every command
//! edits them as it would any drawing. The drawing itself waits, untouched, with its undo history,
//! selection and views: BSAVE writes the objects back into the block definition (so every
//! reference shows them), BCLOSE puts the drawing back. The commands are in
//! `cmd::blocks::bedit`.
//!
//! Layer, style and block table changes made in the editor belong to the drawing too, as in
//! AutoCAD: saving keeps the editor's tables and only puts back what is the drawing's own (its
//! model and paper space objects, groups, constraints, tiled viewports and hidden objects).
//!
//! The editor can also open on the drawing itself ([`CURRENT_DRAWING`], AutoCAD's
//! `<Current Drawing>`): a dynamic block's own drawing, whose model space holds the block's
//! objects and authoring elements. Its objects are edited where they are; BSAVE keeps the
//! changes as the drawing's, BCLOSE without saving puts the drawing back as it was.
use std::sync::Arc;
use cadcraft_doc::{Drawing, EntityKind, EntityStore};
use cadcraft_geom::{Mat3, Vec2};
use crate::{DocState, EngineError, Result, Snapshot};
/// The undo step a save into the block records in the drawing.
const UNDO_LABEL: &str = "Block Editor";
/// The name the Block Editor gives the drawing itself (BEDIT `<Current Drawing>`).
pub const CURRENT_DRAWING: &str = "<Current Drawing>";
/// Whether block `name` (or `*Model_Space`, a drawing's own) carries dynamic block data from
/// another program: parameters and actions in the `ACAD_ENHANCEDBLOCK` dictionary of its
/// BLOCK_RECORD table entry.
pub fn has_dynamic_data(d: &Drawing, name: &str) -> bool {
let key = ("BLOCK_RECORD".to_string(), name.to_ascii_uppercase());
let Some(xdict) = d.foreign.records.get(&key).and_then(|x| x.xdict) else { return false };
d.foreign.objects.iter().any(|o| o.handle == xdict && o.tags.iter().any(|t| t.code == 3 && t.value.eq_ignore_ascii_case("ACAD_ENHANCEDBLOCK")))
}
/// Whether drawing `d` has authoring elements: it is a dynamic block's own drawing, its model
/// space carrying the block's parameters and actions (AutoCAD offers to open it in the Block
/// Editor).
pub fn has_authoring_elements(d: &Drawing) -> bool {
has_dynamic_data(d, "*Model_Space")
}
/// The Block Editor open in a drawing.
#[derive(Clone, Debug)]
pub struct BlockEdit {
/// The block being edited, as the drawing names it.
pub name: String,
/// The drawing outside the editor: its drawing, undo history, selection and views, put back
/// by BCLOSE. Its own `block_edit` is always `None`.
pub host: Box<DocState>,
/// The editor's drawing as last saved into the block; later changes are unsaved.
pub saved: Arc<Drawing>,
/// The block's objects as the drawing has them (block coordinates): objects left unchanged in
/// the editor are saved back exactly as they were.
original: EntityStore,
/// The block's base point, at the origin in the editor.
base: Vec2,
/// The editor is open on the drawing itself ([`CURRENT_DRAWING`]).
pub current: bool,
}
/// Why a block can't be opened in the Block Editor, if it can't.
pub fn refusal(d: &Drawing, name: &str) -> Option<String> {
let Some(b) = d.block(name) else { return Some(format!("Block \"{name}\" not found.")) };
if b.xref_path.is_some() {
return Some(format!("\"{}\" is an external reference; open its drawing (XOPEN) to edit it.", b.name));
}
if b.anonymous || b.name.starts_with('*') {
return Some(format!("\"{}\" is an anonymous block made by another object; it can't be edited in the Block Editor.", b.name));
}
None
}
/// `store` moved by `m`, objects in the same order with the same handles.
fn moved(store: &EntityStore, m: &Mat3) -> EntityStore {
let mut out = EntityStore::new();
for e in store.iter() {
let mut e = (**e).clone();
e.kind.transform(m);
out.push(e);
}
out
}
/// Whether any object of `store` refers to block `name`, directly or through nested blocks of `d`.
fn refers_to(d: &Drawing, store: &EntityStore, name: &str) -> bool {
let mut todo: Vec<String> = store.iter().filter_map(|e| if let EntityKind::Insert(i) = &e.kind { Some(i.block.clone()) } else { None }).collect();
let mut seen = std::collections::HashSet::new();
while let Some(b) = todo.pop() {
if b.eq_ignore_ascii_case(name) {
return true;
}
if seen.insert(b.to_ascii_uppercase())
&& let Some(blk) = d.block(&b)
{
todo.extend(blk.entities.iter().filter_map(|e| if let EntityKind::Insert(i) = &e.kind { Some(i.block.clone()) } else { None }));
}
}
false
}
impl BlockEdit {
/// The editor's objects in block coordinates, ready to be the block's: unchanged objects are
/// the block's own, the others are moved back from the editor's origin to the base point.
fn block_objects(&self, editor: &Drawing) -> EntityStore {
if self.base == Vec2::ZERO {
return editor.model.clone();
}
let (to_editor, back) = (Mat3::translate(-self.base), Mat3::translate(self.base));
let mut out = EntityStore::new();
for e in editor.model.iter() {
let unchanged = self.original.get(e.handle).filter(|o| {
let mut shown = (***o).clone();
shown.kind.transform(&to_editor);
shown == **e
});
match unchanged {
Some(o) => out.push((**o).clone()),
None => {
let mut e = (**e).clone();
e.kind.transform(&back);
out.push(e);
}
}
}
out
}
/// The drawing with the editor's objects saved into block `name` (the edited block, or a new
/// name for BSAVEAS), from `editor`, the editor's drawing.
pub fn compose(&self, editor: &Drawing, name: &str) -> Result<Drawing> {
// The drawing itself: the editor's drawing is the drawing.
if self.current {
if !name.eq_ignore_ascii_case(CURRENT_DRAWING) {
return Err(EngineError::Other("The current drawing can't be saved as a block from the Block Editor; use WBLOCK or BLOCK.".into()));
}
return Ok(editor.clone());
}
if refers_to(editor, &editor.model, name) {
return Err(EngineError::Other(format!("Block \"{name}\" can't contain a reference to itself.")));
}
let host = &self.host.doc;
let mut d = editor.clone();
d.model = host.model.clone();
d.layouts = host.layouts.clone();
d.groups = host.groups.clone();
d.constraints = host.constraints.clone();
d.vports = host.vports.clone();
d.hidden_objects = host.hidden_objects.clone();
let entities = self.block_objects(editor);
// The edited block's properties (the editor's definition, else the drawing's): the objects
// are placed for its base point, also when saved under another name (BSAVEAS).
let template = editor.block(&self.name).or_else(|| host.block(&self.name));
let mut b = match template {
Some(b) => (**b).clone(),
None => cadcraft_doc::Block::new(name),
};
// Block names are case-insensitive: an existing block keeps its name's spelling.
let key = d.blocks.keys().find(|k| k.eq_ignore_ascii_case(name)).cloned().unwrap_or_else(|| name.to_string());
if !b.name.eq_ignore_ascii_case(name) {
b.name = key.clone();
}
b.base.x = self.base.x;
b.base.y = self.base.y;
b.entities = entities;
d.blocks.insert(key, Arc::new(b));
Ok(d)
}
}
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> {
match &self.block_edit {
Some(be) => &be.host.doc,
None => &self.doc,
}
}
/// [`DocState::drawing`], to change it (a save's file version).
pub fn drawing_arc_mut(&mut self) -> &mut Arc<Drawing> {
match &mut self.block_edit {
Some(be) => &mut be.host.doc,
None => &mut self.doc,
}
}
/// The block the Block Editor is editing, when it is open.
pub fn block_editor(&self) -> Option<&str> {
self.block_edit.as_ref().map(|b| b.name.as_str())
}
/// The Block Editor has changes not yet saved into the block.
pub fn block_unsaved(&self) -> bool {
self.block_edit.as_ref().is_some_and(|b| !Arc::ptr_eq(&b.saved, &self.doc))
}
/// The drawing to keep for recovery (autosave): with the Block Editor's unsaved changes in the
/// block, so a crash doesn't lose them.
pub fn recovery_drawing(&self) -> Arc<Drawing> {
match &self.block_edit {
Some(be) if self.block_unsaved() => be.compose(&self.doc, &be.name).map(Arc::new).unwrap_or_else(|_| be.host.doc.clone()),
Some(be) => be.host.doc.clone(),
None => self.doc.clone(),
}
}
/// Open the Block Editor on block `name` (BEDIT).
/// With `create`, a name no block has yet starts a new, empty block, which BSAVE creates.
pub fn open_block_editor(&mut self, name: &str, create: bool) -> Result<()> {
if let Some(open) = self.block_editor() {
return Err(EngineError::Other(format!("The Block Editor is already open on \"{open}\"; close it first (BCLOSE).")));
}
if name.trim().eq_ignore_ascii_case(CURRENT_DRAWING) {
let host = Box::new(self.clone());
let editor = self.doc.clone();
self.enter_block_editor(BlockEdit {
name: CURRENT_DRAWING.into(),
host,
saved: editor,
original: EntityStore::new(),
base: Vec2::ZERO,
current: true,
});
return Ok(());
}
let block = match self.doc.block(name) {
None if create => Arc::new(cadcraft_doc::Block::new(name)),
_ => {
if let Some(why) = refusal(&self.doc, name) {
return Err(EngineError::Other(why));
}
self.doc.block(name).cloned().ok_or_else(|| EngineError::Other(format!("Block \"{name}\" not found.")))?
}
};
let base = block.base.xy();
let mut d = (*self.doc).clone();
d.model = if base == Vec2::ZERO { block.entities.clone() } else { moved(&block.entities, &Mat3::translate(-base)) };
// The drawing's own groups, constraints and hidden objects stay with it.
d.groups.clear();
d.constraints.clear();
d.hidden_objects.clear();
let host = Box::new(self.clone());
let editor = Arc::new(d);
self.doc = editor.clone();
self.enter_block_editor(BlockEdit { name: block.name.clone(), host, saved: editor, original: block.entities.clone(), base, current: false });
Ok(())
}
/// Start editing in the Block Editor `be` (the drawing is already the editor's): a fresh
/// undo history, nothing selected, model space.
fn enter_block_editor(&mut self, be: BlockEdit) {
self.undo.clear();
self.redo.clear();
self.undo_ctl = Default::default();
self.selection.clear();
self.previous_selection.clear();
self.space = cadcraft_doc::Space::Model;
self.mspace = None;
self.tiles = Default::default();
self.views.clear();
self.view_history.clear();
self.block_edit = Some(be);
self.revision += 1;
}
/// Save the Block Editor's objects into the block (BSAVE), or into a new block `as_name`
/// (BSAVEAS), which the editor then edits. One undo step in the drawing.
pub fn save_block_editor(&mut self, as_name: Option<&str>) -> Result<()> {
let Some(be) = &self.block_edit else { return Err(EngineError::Other("The Block Editor is not open.".into())) };
let name = as_name.unwrap_or(&be.name).to_string();
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);
let editor = self.doc.clone();
let Some(be) = &mut self.block_edit else { return Ok(()) };
let before = be.host.doc.clone();
let selection = be.host.selection.clone();
be.host.push_undo(Snapshot { label: UNDO_LABEL.into(), doc: before, selection });
be.host.doc = Arc::new(d);
be.host.revision += 1;
be.original = original;
be.saved = editor;
be.name = name;
self.revision += 1;
Ok(())
}
/// Close the Block Editor (BCLOSE), saving its changes into the block first when `save`.
pub fn close_block_editor(&mut self, save: bool) -> Result<()> {
if self.block_edit.is_none() {
return Err(EngineError::Other("The Block Editor is not open.".into()));
}
if save && self.block_unsaved() {
self.save_block_editor(None)?;
}
let Some(be) = self.block_edit.take() else { return Ok(()) };
let host = *be.host;
self.doc = host.doc;
self.undo = host.undo;
self.redo = host.redo;
self.undo_ctl = host.undo_ctl;
// The drawing comes back with nothing selected; Previous is what was selected before.
self.selection.clear();
self.previous_selection = if host.selection.is_empty() { host.previous_selection } else { host.selection };
self.space = host.space;
self.mspace = host.mspace;
self.tiles = host.tiles;
self.views = host.views;
self.view_history = host.view_history;
self.revision += 1;
Ok(())
}
}
+87 -4
View File
@@ -1,5 +1,7 @@
//! Blocks: BLOCK, INSERT, ATTDEF, ATTEDIT (programmatic), WBLOCK, BASE, PURGE, block listing.
//! Blocks: BLOCK, INSERT, ATTDEF, ATTEDIT (programmatic), WBLOCK, BASE, PURGE, block listing, the
//! Block Editor.
pub(crate) mod bedit;
mod purge;
use std::sync::Arc;
@@ -14,7 +16,9 @@ use super::*;
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
mod attedit;
mod dyngrip;
mod extract;
pub(crate) mod refedit;
pub fn specs() -> Vec<CommandSpec> {
vec![
@@ -42,6 +46,24 @@ pub fn specs() -> Vec<CommandSpec> {
attedit::dash_attedit_spec(),
CommandSpec::new("wblock", "Write Block", run_wblock).alias(&["w"]).params("{path, name? | handles?, base?}"),
CommandSpec::new("base", "Base", run_base).menu(&["Draw", "Block", "Base"]).params("{at: [x,y]}"),
CommandSpec::new("blocks.dynamic", "Dynamic Block Data", run_dynamic)
.params("{name (a block, or \"*Model_Space\" for a drawing that is a dynamic block's own)} → {parameters, actions, other} | null (not a dynamic block)")
.enabled(has_doc)
.noundo(),
CommandSpec::new("blocks.reference", "Dynamic Block Reference", run_reference)
.params("{handle (a block reference)} → {definition, values (by parameter graph id), properties: [{name, value, choices}]} | null (not a dynamic block reference)")
.enabled(has_doc)
.noundo(),
CommandSpec::new("blocks.reference.set", "Set Dynamic Property", run_reference_set)
.params("{handle (a dynamic block reference), name (a custom property as Properties names it: a visibility state, a lookup, a distance or \"Position1 X\"), value (lengths in drawing units)} → {properties}")
.enabled(has_doc),
CommandSpec::new("blocks.reference.grips", "Dynamic Block Grips", dyngrip::run_grips)
.params("{handle} → [{node, part: base|end|at, shape: linear|point|list, at: [x,y], dir: [x,y], property, choices?}] (empty: not a dynamic block reference)")
.enabled(has_doc)
.noundo(),
CommandSpec::new("blocks.reference.grip", "Drag Dynamic Grip", dyngrip::run_grip)
.params("{handle, node (the parameter's graph id), part: base|end|at, to: [x,y] (drawing coordinates)} → {properties}: a distance's arrow changes the distance (snapped to its values), a position's square moves it")
.enabled(has_doc),
CommandSpec::new("purge", "Purge", purge::run)
.alias(&["pu", "-purge"])
.params("{type?: all|blocks|dimstyles|groups|layers|linetypes|mleaderstyles|tablestyles|textstyles|zerolength|emptytext (default all: every named type), names?: \"A*,B\" (default *)} → purged")
@@ -55,6 +77,8 @@ pub fn specs() -> Vec<CommandSpec> {
]
.into_iter()
.chain(xref::specs())
.chain(refedit::specs())
.chain(bedit::specs())
.collect()
}
@@ -97,6 +121,9 @@ pub(crate) fn make_block(s: &mut Session, name: &str, base: Vec2, hs: &[Handle],
if references_block(d, &ents, name) {
return Err(bad("block", format!("Block \"{name}\" references itself.")));
}
if d.refedit.as_ref().is_some_and(|r| r.block.eq_ignore_ascii_case(name)) {
return Err(bad("block", format!("Block \"{name}\" is being edited in place; finish with REFCLOSE first.")));
}
// Block names are case-insensitive: redefining keeps the existing definition's name.
let name = d.blocks.keys().find(|k| k.eq_ignore_ascii_case(name)).cloned().unwrap_or_else(|| name.to_string());
let name = name.as_str();
@@ -180,6 +207,8 @@ pub(crate) fn insert_with(
constant: false,
prompt: String::new(),
props: Default::default(),
multiline: ad.multiline.as_ref().map(|ml| ml.transformed(&m)),
modes: ad.modes.for_reference(),
});
}
}
@@ -252,6 +281,8 @@ fn attdef_kind(s: &Session, tag: &str, prompt: &str, default: &str, at: Vec2, he
constant: false,
prompt: prompt.into(),
props: Default::default(),
multiline: None,
modes: Default::default(),
})
}
@@ -321,9 +352,61 @@ fn run_base(s: &mut Session, p: &Value) -> Result<Value> {
fn run_list(s: &mut Session, _p: &Value) -> Result<Value> {
let d = s.doc()?;
Ok(
json!({ "blocks": d.blocks.values().filter(|b| !b.anonymous).map(|b| json!({"name": b.name, "entities": b.entities.len(), "base": [b.base.x, b.base.y], "description": b.description})).collect::<Vec<_>>() }),
)
let block = |b: &Block| {
let dynamic = cadcraft_doc::dynblock::dynamic_block(d, &b.name).is_some();
json!({"name": b.name, "entities": b.entities.len(), "base": [b.base.x, b.base.y], "description": b.description, "dynamic": dynamic})
};
Ok(json!({ "blocks": d.blocks.values().filter(|b| !b.anonymous).map(|b| block(b)).collect::<Vec<_>>() }))
}
/// A dynamic block's parameters and actions as read from the file (null for a block that has
/// none).
fn run_dynamic(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "name").ok_or_else(|| bad("blocks.dynamic", "`name` is required"))?;
let d = s.doc()?;
if d.block(name).is_none() && !name.trim().eq_ignore_ascii_case("*Model_Space") {
return Err(bad("blocks.dynamic", format!("no block `{name}`")));
}
serde_json::to_value(cadcraft_doc::dynblock::dynamic_block(d, name)).map_err(|e| bad("blocks.dynamic", e.to_string()))
}
/// The dynamic block a block reference shows, the values it was given and its custom properties
/// (null for any other reference).
fn run_reference(s: &mut Session, p: &Value) -> Result<Value> {
let h = super::curves::handle_param(p, "handle").ok_or_else(|| bad("blocks.reference", "`handle` is required"))?;
let d = s.doc()?;
match d.entity(h).map(|e| &e.kind) {
Some(EntityKind::Insert(_)) => {}
Some(_) => return Err(bad("blocks.reference", "`handle` is not a block reference")),
None => return Err(bad("blocks.reference", format!("no object {}", h.hex()))),
}
let (Some(r), Some((_, properties))) = (cadcraft_doc::dynblock::reference(d, h), cadcraft_doc::dynblock::custom_properties(d, h)) else {
return Ok(Value::Null);
};
Ok(json!({ "definition": r.definition, "values": r.values, "properties": properties }))
}
/// Change a custom property of a dynamic block reference (one undo step).
fn run_reference_set(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "blocks.reference.set";
let h = super::curves::handle_param(p, "handle").ok_or_else(|| bad(cmd, "`handle` is required"))?;
let name = str_param(p, "name").ok_or_else(|| bad(cmd, "`name` is required"))?;
let value = match p.get("value") {
Some(Value::String(v)) => v.clone(),
Some(Value::Number(n)) => n.to_string(),
_ => return Err(bad(cmd, "`value` is required")),
};
if !matches!(s.doc()?.entity(h).map(|e| &e.kind), Some(EntityKind::Insert(_))) {
return Err(bad(cmd, "`handle` is not a block reference"));
}
if super::curves::is_locked(s, h) {
return Err(bad(cmd, "the reference is on a locked layer"));
}
let mut d = s.doc()?.clone();
cadcraft_doc::dynblock::set_property(&mut d, h, name, &value).map_err(|e| bad(cmd, e))?;
let properties = cadcraft_doc::dynblock::custom_properties(&d, h).map(|(_, p)| p).unwrap_or_default();
*s.doc_mut()? = d;
Ok(json!({ "properties": properties }))
}
// ---------------- interactive ----------------
+10 -1
View File
@@ -97,7 +97,16 @@ fn synced(blk: &Block, defs: &[&Attrib], ins: &Insert) -> Vec<Attrib> {
.find(|a| a.tag.eq_ignore_ascii_case(&ad.tag))
.map(|a| a.text.value.clone())
.unwrap_or_else(|| ad.text.value.clone());
Attrib { tag: ad.tag.clone(), text: t, invisible: ad.invisible, constant: false, prompt: String::new(), props: Default::default() }
Attrib {
tag: ad.tag.clone(),
text: t,
invisible: ad.invisible,
constant: false,
prompt: String::new(),
props: Default::default(),
multiline: ad.multiline.as_ref().map(|ml| ml.transformed(&m)),
modes: ad.modes.for_reference(),
}
})
.collect()
}
+320
View File
@@ -0,0 +1,320 @@
//! BEDIT, BSAVE, BSAVEAS and BCLOSE: the Block Editor. BEDIT opens a block definition in the
//! drawing's own tab, its base point at the origin; the block's objects are edited with the
//! ordinary commands; BSAVE saves them into the definition, so every reference to the block in the
//! drawing shows the change; BCLOSE goes back to the drawing, saving or discarding what isn't saved.
//! The state is [`crate::blockedit`].
use cadcraft_doc::{Drawing, EntityKind, Handle};
use serde_json::{Value, json};
use crate::cmd::curves::{KW, handle_value};
use crate::cmd::{CommandSpec, bad, bool_or, str_param};
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
pub(super) fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("bedit", "Block Editor", run_bedit)
.menu(&["Tools", "Block Editor"])
.alias(&["be", "-bedit"])
.params("{name? (\"<Current Drawing>\": the drawing itself, a dynamic block's own drawing) | handle? (a block reference: edits its block); default the one selected block reference, create?: bool (a name no block has yet starts a new block; BSAVE creates it)} → {block, new}")
.enabled(not_editing)
.noundo()
.interactive(|_| Ok(Box::new(BeditM))),
CommandSpec::new("bsave", "Save Block", run_bsave).params("{} → {block}").enabled(editing).noundo(),
CommandSpec::new("bsaveas", "Save Block As", run_bsaveas)
.params("{name, replace?: bool (redefine an existing block of that name)} → {block}")
.enabled(editing_a_block)
.noundo()
.interactive(|_| Ok(Box::new(BsaveasM::default()))),
CommandSpec::new("bclose", "Close Block Editor", run_bclose)
.params("{save?: bool (default true; false discards the changes not saved into the block)} → {block, saved}")
.enabled(editing)
.noundo()
.interactive(|_| Ok(Box::new(BcloseM))),
]
}
fn editing(s: &Session) -> std::result::Result<(), String> {
match s.state() {
Ok(st) if st.block_edit.is_some() => Ok(()),
Ok(_) => Err("The Block Editor is not open.".into()),
Err(e) => Err(e.to_string()),
}
}
/// The Block Editor is open on a block (not on the drawing itself, which BSAVEAS can't save as one).
fn editing_a_block(s: &Session) -> std::result::Result<(), String> {
editing(s)?;
match s.state() {
Ok(st) if st.block_edit.as_ref().is_some_and(|b| b.current) => {
Err("The Block Editor is open on the current drawing; there is no block to save under another name.".into())
}
_ => Ok(()),
}
}
fn not_editing(s: &Session) -> std::result::Result<(), String> {
match s.state() {
Ok(st) => match st.block_editor() {
Some(name) => Err(format!("The Block Editor is already open on \"{name}\"; close it first (BCLOSE).")),
None => Ok(()),
},
Err(e) => Err(e.to_string()),
}
}
/// Commands that leave model space, which the Block Editor is: not available while it is open.
const NOT_IN_EDITOR: &[&str] = &[
"layout",
"layout.new",
"layout.delete",
"layout.rename",
"layout.copy",
"layout.set",
"mview",
"mview.polygonal",
"mspace",
"pspace",
"chspace",
];
/// Why command `id` can't run now, when the Block Editor is open and it is one of [`NOT_IN_EDITOR`].
pub(crate) fn refused_in_editor(s: &Session, id: &str) -> Option<String> {
let name = s.state().ok()?.block_editor()?;
NOT_IN_EDITOR.contains(&id).then(|| format!("Not available in the Block Editor (editing \"{name}\"); close it first (BCLOSE)."))
}
/// The block a block reference refers to.
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,
}
}
/// The dynamic block whose anonymous block `name` is (a dynamic block reference with values of
/// its own shows one, `*U12`): its BLOCK_RECORD's `AcDbBlockRepBTag` xdata names the dynamic
/// block's record. `None` for other blocks.
fn dynamic_definition(d: &Drawing, name: &str) -> Option<String> {
if !name.starts_with('*') {
return None;
}
let x = &d.foreign.records.get(&("BLOCK_RECORD".to_string(), name.to_ascii_uppercase()))?.xdata;
let at = x.iter().position(|t| t.code == 1001 && t.value.eq_ignore_ascii_case("AcDbBlockRepBTag"))?;
let record = x.iter().skip(at + 1).take_while(|t| t.code != 1001).find(|t| t.code == 1005).and_then(|t| Handle::parse_hex(&t.value))?;
let (kind, block) = d.foreign.targets.get(&record)?;
(kind == "BLOCK_RECORD").then(|| d.block(block).map(|b| b.name.clone())).flatten()
}
/// Open the Block Editor on block `name` and frame its objects.
fn open(s: &mut Session, name: &str, create: bool) -> Result<Value> {
let new = create && !name.eq_ignore_ascii_case(crate::blockedit::CURRENT_DRAWING) && s.doc()?.block(name).is_none();
s.state_mut()?.open_block_editor(name, create)?;
let name = s.state()?.block_editor().unwrap_or(name).to_string();
// The block's objects with room around them (and the editor's bar above them).
s.zoom_extents()?;
let c = s.state()?.view().center;
s.zoom_about(0.75, c)?;
s.echo(if new {
format!("Block Editor: new block \"{name}\". BSAVE creates it in the drawing; BCLOSE closes the editor.")
} else {
format!("Block Editor: editing \"{name}\". BSAVE saves it into the drawing; BCLOSE closes the editor.")
});
let dynamic =
|be: &crate::blockedit::BlockEdit| crate::blockedit::has_dynamic_data(&be.host.doc, if be.current { "*Model_Space" } else { &be.name });
if s.state().is_ok_and(|st| st.block_edit.as_ref().is_some_and(dynamic)) {
s.echo("This is a dynamic block: its parameters and actions are kept but can't be edited in CADCraft yet.");
}
Ok(json!({ "block": name, "new": new }))
}
/// The one block reference selected, if exactly one is.
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() {
[one] => Some(one.clone()),
_ => None,
}
}
fn run_bedit(s: &mut Session, p: &Value) -> Result<Value> {
let name = match (str_param(p, "name"), p.get("handle")) {
(Some(n), _) => n.to_string(),
(None, Some(h)) => {
let h = handle_value(h).ok_or_else(|| bad("bedit", "`handle` must be a handle"))?;
block_of(s.doc()?, h).ok_or_else(|| bad("bedit", "`handle` is not a block reference"))?
}
(None, None) => selected_reference(s).ok_or_else(|| bad("bedit", "`name` or `handle` is required (or select one block reference)"))?,
};
let create = bool_or(p, "create", false);
let name = name.trim();
let current = name.eq_ignore_ascii_case(crate::blockedit::CURRENT_DRAWING);
if create && !current && s.doc()?.block(name).is_none() && !super::valid_block_name(name) {
return Err(bad("bedit", format!("\"{name}\" is not a valid block name")));
}
open(s, name, create)
}
fn run_bsave(s: &mut Session, _p: &Value) -> Result<Value> {
s.state_mut()?.save_block_editor(None)?;
let name = s.state()?.block_editor().unwrap_or_default().to_string();
s.echo(format!("Block \"{name}\" saved."));
Ok(json!({ "block": name }))
}
/// Check a BSAVEAS name: valid, and not another existing block unless `replace`.
fn check_save_as(s: &Session, name: &str, replace: bool) -> Result<()> {
if !super::valid_block_name(name) {
return Err(bad("bsaveas", format!("\"{name}\" is not a valid block name")));
}
let d = s.doc()?;
let current = s.state()?.block_editor().is_some_and(|b| b.eq_ignore_ascii_case(name));
if !current && !replace && d.block(name).is_some() {
return Err(bad("bsaveas", format!("Block \"{name}\" already exists; pass `replace: true` to redefine it")));
}
if let Some(why) = d.block(name).filter(|_| !current).and_then(|_| crate::blockedit::refusal(d, name)) {
return Err(bad("bsaveas", why));
}
Ok(())
}
fn run_bsaveas(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "name").ok_or_else(|| bad("bsaveas", "`name` is required"))?.trim().to_string();
check_save_as(s, &name, bool_or(p, "replace", false))?;
s.state_mut()?.save_block_editor(Some(&name))?;
let name = s.state()?.block_editor().unwrap_or_default().to_string();
s.echo(format!("Block \"{name}\" saved; the Block Editor now edits it."));
Ok(json!({ "block": name }))
}
fn close(s: &mut Session, save: bool) -> Result<Value> {
let st = s.state()?;
let name = st.block_editor().unwrap_or_default().to_string();
let saved = save && st.block_unsaved();
s.state_mut()?.close_block_editor(save)?;
if saved {
s.echo(format!("Block \"{name}\" saved."));
}
Ok(json!({ "block": name, "saved": saved }))
}
fn run_bclose(s: &mut Session, p: &Value) -> Result<Value> {
close(s, bool_or(p, "save", true))
}
// ---------------- interactive ----------------
/// BEDIT: the selected block reference's block, else a block name typed or a reference picked.
struct BeditM;
impl Interactive for BeditM {
fn name(&self) -> &'static str {
"BEDIT"
}
fn begin(&mut self, s: &mut Session) -> Result<Step> {
match selected_reference(s) {
Some(name) => open(s, &name, false).map(|_| Step::Done),
None => Ok(Step::Continue),
}
}
fn prompt(&self, _s: &Session) -> Prompt {
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> {
match i {
Input::Text(t) if t.trim() == "?" => {
let names: Vec<String> = {
let d = s.doc()?;
d.blocks.values().filter(|b| crate::blockedit::refusal(d, &b.name).is_none()).map(|b| b.name.clone()).collect()
};
s.echo(if names.is_empty() { "No blocks to edit.".to_string() } else { names.join(", ") });
Ok(Step::Continue)
}
Input::Text(t) | Input::Keyword(t) => open(s, t.trim(), false).map(|_| Step::Done),
Input::Pick(hs) => {
let name = {
let d = s.doc()?;
hs.iter().find_map(|h| block_of(d, *h))
};
match name {
Some(n) => open(s, &n, false).map(|_| Step::Done),
None => Err(EngineError::Other("Select a block reference.".into())),
}
}
Input::Enter => Ok(Step::Done),
_ => Ok(Step::Continue),
}
}
}
/// BCLOSE: asks whether to save changes not saved yet.
struct BcloseM;
impl Interactive for BcloseM {
fn name(&self) -> &'static str {
"BCLOSE"
}
fn begin(&mut self, s: &mut Session) -> Result<Step> {
if s.state()?.block_unsaved() { Ok(Step::Continue) } else { close(s, false).map(|_| Step::Done) }
}
fn prompt(&self, s: &Session) -> Prompt {
let name = s.state().ok().and_then(|st| st.block_editor().map(str::to_string)).unwrap_or_default();
Prompt::new(format!("Save changes to block \"{name}\"?"), KW).kw(&["Yes", "No"]).default("Y")
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
let answer = match i {
Input::Enter => "Y".to_string(),
Input::Keyword(k) | Input::Text(k) => k,
_ => return Ok(Step::Continue),
};
match answer.trim().chars().next().map(|c| c.to_ascii_uppercase()) {
Some('Y') => close(s, true).map(|_| Step::Done),
Some('N') => close(s, false).map(|_| Step::Done),
_ => Err(EngineError::Other("Enter Yes or No.".into())),
}
}
}
/// BSAVEAS: a new block name; an existing block is redefined only when confirmed.
#[derive(Default)]
struct BsaveasM {
/// The name given, waiting for "redefine?" because a block of that name exists.
name: Option<String>,
}
impl BsaveasM {
fn save(s: &mut Session, name: &str, replace: bool) -> Result<Step> {
run_bsaveas(s, &json!({ "name": name, "replace": replace })).map(|_| Step::Done)
}
}
impl Interactive for BsaveasM {
fn name(&self) -> &'static str {
"BSAVEAS"
}
fn prompt(&self, _s: &Session) -> Prompt {
match &self.name {
Some(n) => Prompt::new(format!("Block \"{n}\" already exists. Redefine it?"), KW).kw(&["Yes", "No"]).default("N"),
None => Prompt::new("Enter block name", Accept::TEXT),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
if let Some(n) = self.name.clone() {
let yes = matches!(&i, Input::Keyword(k) | Input::Text(k) if k.trim().to_ascii_uppercase().starts_with('Y'));
return if yes { Self::save(s, &n, true) } else { Ok(Step::Done) };
}
let name = match i {
Input::Text(t) => t.trim().to_string(),
Input::Enter => return Ok(Step::Done),
_ => return Ok(Step::Continue),
};
let current = s.state()?.block_editor().is_some_and(|b| b.eq_ignore_ascii_case(&name));
if !current && s.doc()?.block(&name).is_some() {
check_save_as(s, &name, true)?;
self.name = Some(name);
return Ok(Step::Continue);
}
Self::save(s, &name, false)
}
}
+161
View File
@@ -0,0 +1,161 @@
//! Grips of dynamic block references: `blocks.reference.grips` lists them, `blocks.reference.grip`
//! drags one (one undo step), and [`Session::start_dyn_grip`] starts the drag the UI's click on a
//! grip begins, with the reference re-evaluated under the cursor as its preview. Typing a number
//! at a distance's grip sets the distance.
use cadcraft_doc::dynblock::{self, GripPart, GripShape};
use cadcraft_doc::{Drawing, EntityKind, Handle};
use cadcraft_geom::Vec2;
use serde_json::{Value, json};
use crate::cmd::{bad, point_param, str_param};
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Running, Session, Step};
/// Most objects one preview draws.
const MAX_PREVIEW: usize = 2000;
/// The grips of a dynamic block reference (an empty list for any other object).
pub(super) fn run_grips(s: &mut Session, p: &Value) -> Result<Value> {
let h = super::super::curves::handle_param(p, "handle").ok_or_else(|| bad("blocks.reference.grips", "`handle` is required"))?;
serde_json::to_value(dynblock::grips(s.doc()?, h)).map_err(|e| bad("blocks.reference.grips", e.to_string()))
}
/// Drag a grip of a dynamic block reference to a point (one undo step).
pub(super) fn run_grip(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "blocks.reference.grip";
let h = super::super::curves::handle_param(p, "handle").ok_or_else(|| bad(cmd, "`handle` is required"))?;
let node = p
.get("node")
.and_then(Value::as_i64)
.and_then(|n| i32::try_from(n).ok())
.ok_or_else(|| bad(cmd, "`node` (a parameter's graph id) is required"))?;
let part = str_param(p, "part").and_then(GripPart::from_name).ok_or_else(|| bad(cmd, "`part` must be base, end or at"))?;
let to = point_param(p, "to").ok_or_else(|| bad(cmd, "`to` [x, y] is required"))?;
drag(s, h, node, part, to).map_err(|e| bad(cmd, e))?;
let properties = dynblock::custom_properties(s.doc()?, h).map(|(_, p)| p).unwrap_or_default();
Ok(json!({ "properties": properties }))
}
/// Drag grip (`node`, `part`) of reference `h` to `to` in the session's drawing.
fn drag(s: &mut Session, h: Handle, node: i32, part: GripPart, to: Vec2) -> std::result::Result<(), String> {
if !matches!(s.doc().map_err(|e| e.to_string())?.entity(h).map(|e| &e.kind), Some(EntityKind::Insert(_))) {
return Err("`handle` is not a block reference".into());
}
if super::super::curves::is_locked(s, h) {
return Err("the reference is on a locked layer".into());
}
let mut d = s.doc().map_err(|e| e.to_string())?.clone();
dynblock::drag_grip(&mut d, h, node, part, to)?;
*s.doc_mut().map_err(|e| e.to_string())? = d;
Ok(())
}
/// The reference `h` as drawn in drawing `d`: its block's shown objects placed by it, and its
/// attributes (what a preview draws, in the preview colour: solid fills are left out so they
/// don't cover the outline and text).
fn drawn(d: &Drawing, h: Handle) -> Vec<EntityKind> {
let Some(EntityKind::Insert(ins)) = d.entity(h).map(|e| &e.kind) else { return Vec::new() };
let Some(b) = d.block(&ins.block) else { return Vec::new() };
let m = ins.transform(b.base.xy());
let mut out: Vec<EntityKind> = b
.entities
.iter()
.filter(|e| e.common.visible && !matches!(&e.kind, EntityKind::AttDef(_) | EntityKind::Wipeout(_)))
.filter(|e| !matches!(&e.kind, EntityKind::Hatch(h) if h.solid))
.take(MAX_PREVIEW)
.map(|e| {
let mut k = e.kind.clone();
k.transform(&m);
k
})
.collect();
out.extend(ins.attribs.iter().filter(|a| !a.invisible).map(|a| EntityKind::Text(a.text.clone())));
out
}
/// A dynamic grip being dragged.
struct DynGripEdit {
handle: Handle,
node: i32,
part: GripPart,
/// Where the grip is (the rubber band's start).
from: Vec2,
/// The property it changes, and whether it is a distance (a typed number sets it).
property: String,
distance: bool,
}
impl Interactive for DynGripEdit {
fn name(&self) -> &'static str {
"GRIP"
}
fn begin(&mut self, _s: &mut Session) -> Result<Step> {
Ok(Step::Continue)
}
fn prompt(&self, _s: &Session) -> Prompt {
let (msg, accept) = if self.distance {
("** STRETCH ** Specify stretch point or distance", Accept::POINT_OR_NUMBER)
} else {
("** STRETCH ** Specify stretch point", Accept::POINT)
};
Prompt::new(msg, accept).kw(&["eXit"]).base(self.from)
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
let to = match i {
Input::Point(p) => p,
Input::Deferred(d) => d.at,
Input::Text(t) if self.distance => {
let mut d = s.doc()?.clone();
dynblock::set_property(&mut d, self.handle, &self.property, t.trim()).map_err(EngineError::Other)?;
*s.doc_mut()? = d;
return Ok(Step::Done);
}
Input::Keyword(k) if k == "eXit" => return Ok(Step::Done),
Input::Enter | Input::Cancel => return Ok(Step::Done),
_ => return Err(EngineError::Other("Requires a point.".into())),
};
drag(s, self.handle, self.node, self.part, to).map_err(EngineError::Other)?;
Ok(Step::Done)
}
fn preview(&self, s: &Session, c: Vec2) -> Vec<EntityKind> {
let Ok(d) = s.doc() else { return Vec::new() };
let mut d = d.clone();
let mut out = match dynblock::drag_grip(&mut d, self.handle, self.node, self.part, c) {
Ok(()) => drawn(&d, self.handle),
Err(_) => Vec::new(),
};
out.push(crate::cmd::helpers::line(self.from, c));
out
}
}
impl Session {
/// Start dragging grip `part` of parameter `node` of dynamic block reference `handle` (the
/// UI's click on a dynamic grip). A list grip (visibility, lookup) has no drag: the UI offers
/// its choices and sets them with `blocks.reference.set`.
pub fn start_dyn_grip(&mut self, handle: Handle, node: i32, part: GripPart) -> Result<()> {
if self.running.is_some() {
self.cancel();
}
let g = dynblock::grips(self.doc()?, handle)
.into_iter()
.find(|g| g.node == node && g.part == part)
.ok_or_else(|| EngineError::Other("no such grip".into()))?;
if g.shape == GripShape::List {
return Err(EngineError::Other("a list grip is chosen from, not dragged".into()));
}
let edit = DynGripEdit { handle, node, part, from: g.at, property: g.property, distance: g.shape == GripShape::Linear };
let machine = crate::guard::Guarded::wrap(Box::new(edit));
let st = self.state()?;
let (before, selection_before) = (st.doc.clone(), st.selection.clone());
self.running = Some(Running { id: "grip".into(), machine, before, selection_before });
self.pending_window = None;
self.point_mods.clear();
self.echo("** STRETCH **");
Ok(())
}
}
+469
View File
@@ -0,0 +1,469 @@
//! REFEDIT, REFSET and REFCLOSE: edit a block reference in place.
//!
//! REFEDIT hides the picked reference and puts the block's objects (all, or the ones named) into
//! the drawing where the reference shows them: the *working set*, ordinary objects that every
//! command edits. The rest of the block stands there as faded copies, and everything else in the
//! drawing is locked ([`crate::cmd::curves::is_locked`], enforced by [`after_command`]). Objects
//! drawn while editing join the working set. REFSET adds objects to or removes them from it.
//! REFCLOSE writes the working set back into the block definition, so every reference to the
//! block shows the change, or discards the edit.
//!
//! The state is [`Drawing::refedit`]: it travels with undo, is never written to a file (files
//! are written from [`Drawing::committed`]) and ends with the drawing it belongs to.
//!
//! Not yet: external references (their source drawing would have to be written back), nested
//! references picked at a depth, annotative scale contexts and attribute synchronisation.
use std::collections::HashSet;
use std::sync::Arc;
use cadcraft_doc::{Drawing, Entity, EntityStore, Handle, RefEdit, Transparency, WorkItem};
use cadcraft_geom::Mat3;
use serde_json::{Value, json};
use super::super::curves::handle_value;
use super::super::machines::{SelOutcome, SelectPhase, SelectRun};
use super::references_block;
use crate::cmd::{CommandSpec, bad, bool_or, has_doc, str_param, targets};
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
/// Transparency of the faded copies of the block's other objects.
const GHOST_FADE: u8 = 70;
pub(super) fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("refedit", "Edit Reference In-Place", run_refedit)
.menu(&["Tools", "Xref and Block In-place Editing", "Edit Reference In-Place"])
.params("{handle? | handles? | name? (a block reference, default the selection), objects?: [block object handles] (default all)} → {block, insert, working: [handles], faded}")
.enabled(has_doc)
.interactive(|_| Ok(Box::new(SelectRun::single("refedit", "REFEDIT", "Select reference")))),
CommandSpec::new("refset", "Working Set", run_refset)
.menu(&["Tools", "Xref and Block In-place Editing", "Add to Working Set"])
.params("{add?: [handles], remove?: [handles]} (or {handles, mode?: \"add\"|\"remove\"}); with neither: lists the working set → {working: [handles], added, removed}")
.enabled(editing)
.interactive(|_| Ok(Box::new(RefsetM::default()))),
CommandSpec::new("refclose", "Save Reference Edits", run_refclose)
.menu(&["Tools", "Xref and Block In-place Editing", "Save Reference Edits"])
.params("{option?: \"save\"|\"discard\" | save?: bool (default true)} → {block, saved, objects}")
.enabled(editing)
.interactive(|_| Ok(Box::new(RefcloseM))),
]
}
fn editing(s: &Session) -> std::result::Result<(), String> {
has_doc(s)?;
if s.doc().is_ok_and(|d| d.refedit.is_some()) { Ok(()) } else { Err("no block reference is being edited (REFEDIT)".into()) }
}
fn active(s: &Session, cmd: &str) -> Result<RefEdit> {
s.doc()?.refedit.clone().ok_or_else(|| bad(cmd, "no block reference is being edited (REFEDIT)"))
}
/// A block object as the drawing shows it through the edited reference.
fn to_host(to_world: &Mat3, e: &Entity, h: Handle, ghost: bool) -> Entity {
let mut c = e.clone();
c.handle = h;
c.kind.transform(to_world);
if ghost {
c.common.transparency = Transparency::Percent(GHOST_FADE);
}
c
}
/// A drawing object as it belongs in the block, with the block object's handle `h`.
fn to_block(inv: &Mat3, e: &Entity, h: Handle) -> Entity {
let mut c = e.clone();
c.handle = h;
c.kind.transform(inv);
c
}
fn put(d: &mut Drawing, r: &RefEdit, e: Entity) -> Result<()> {
d.ensure_layer(&e.common.layer);
d.space_mut(&r.space).ok_or_else(|| bad("refedit", "the space of the reference is gone"))?.push(e);
Ok(())
}
fn hexes(hs: impl IntoIterator<Item = Handle>) -> Vec<String> {
hs.into_iter().map(|h| h.hex()).collect()
}
// ---------------- REFEDIT ----------------
fn run_refedit(s: &mut Session, p: &Value) -> Result<Value> {
if s.doc()?.refedit.is_some() {
return Err(bad("refedit", "a reference is already being edited; finish with REFCLOSE"));
}
let d = s.doc()?;
let picked = match str_param(p, "name") {
Some(n) => d
.model
.iter()
.chain(d.layouts.iter().flat_map(|l| l.entities.iter()))
.find(|e| matches!(&e.kind, cadcraft_doc::EntityKind::Insert(i) if i.block.eq_ignore_ascii_case(n)))
.map(|e| e.handle),
None => targets(s, p)?.into_iter().find(|h| d.entity(*h).is_some_and(|e| matches!(e.kind, cadcraft_doc::EntityKind::Insert(_)))),
};
let ih = picked.ok_or_else(|| bad("refedit", "select a block reference"))?;
let (ins, space) = match d.entity(ih).map(|e| &e.kind) {
Some(cadcraft_doc::EntityKind::Insert(i)) => (i.clone(), d.space_of(ih).ok_or_else(|| bad("refedit", "the reference is in no space"))?),
_ => return Err(bad("refedit", "select a block reference")),
};
let blk = d.block(&ins.block).cloned().ok_or_else(|| bad("refedit", format!("block \"{}\" not found", ins.block)))?;
if blk.xref_path.is_some() {
return Err(bad(
"refedit",
format!(
"\"{}\" is an external reference: editing it in place would write its source drawing, which is not available yet. Open it with XOPEN, edit and save it, then reload.",
blk.name
),
));
}
if blk.name.starts_with('*') || blk.array.is_some() {
return Err(bad("refedit", format!("\"{}\" is generated geometry and can't be edited in place", blk.name)));
}
let to_world = ins.transform(blk.base.xy());
let (sx, sy) = (ins.scale.x.abs(), ins.scale.y.abs());
let finite = [to_world.a, to_world.b, to_world.c, to_world.d, to_world.e, to_world.f].iter().all(|v| v.is_finite());
if !finite || to_world.det().abs() < 1e-12 || (sx - sy).abs() > 1e-9 * sx.max(sy) {
return Err(bad("refedit", "the reference must be scaled equally in X and Y (and not flat) to be edited in place"));
}
let chosen: Option<HashSet<Handle>> = match p.get("objects") {
None | Some(Value::Null) => None,
Some(Value::String(a)) if a.eq_ignore_ascii_case("all") => None,
Some(Value::Array(a)) => {
let mut set = HashSet::new();
for v in a {
let h = handle_value(v).ok_or_else(|| bad("refedit", format!("`objects`: {v} is not an object handle")))?;
if blk.entities.get(h).is_none() {
return Err(bad("refedit", format!("object {} is not in block \"{}\"", h.hex(), blk.name)));
}
set.insert(h);
}
if set.is_empty() {
return Err(bad("refedit", "`objects` is empty"));
}
Some(set)
}
Some(v) => return Err(bad("refedit", format!("`objects` must be \"all\" or an array of handles, got {v}"))),
};
let was_visible = d.entity(ih).is_some_and(|e| e.common.visible);
let mut r = RefEdit {
block: blk.name.clone(),
insert: ih,
insert_was_visible: was_visible,
space,
to_world,
items: Vec::new(),
ghosts: Vec::new(),
released: Vec::new(),
};
let doc = s.doc_mut()?;
for e in blk.entities.iter() {
let working = chosen.as_ref().is_none_or(|c| c.contains(&e.handle));
let h = doc.new_handle();
put(doc, &r, to_host(&to_world, e, h, !working))?;
if working {
r.items.push(WorkItem { copy: h, source: Some(e.handle) });
} else {
r.ghosts.push((h, e.handle));
}
}
doc.modify_entity(ih, |e| e.common.visible = false).map_err(|e| bad("refedit", e.to_string()))?;
let out = json!({ "block": r.block, "insert": ih.hex(), "working": hexes(r.items.iter().map(|i| i.copy)), "faded": r.ghosts.len() });
let sel: Vec<Handle> = r.items.iter().map(|i| i.copy).collect();
doc.refedit = Some(r);
s.set_selection(sel);
Ok(out)
}
// ---------------- REFSET ----------------
fn handle_list(p: &Value, key: &str) -> Result<Vec<Handle>> {
match p.get(key) {
None | Some(Value::Null) => Ok(Vec::new()),
Some(Value::Array(a)) => {
a.iter().map(|v| handle_value(v).ok_or_else(|| bad("refset", format!("`{key}`: {v} is not an object handle")))).collect()
}
Some(v) => Err(bad("refset", format!("`{key}` must be an array of object handles, got {v}"))),
}
}
fn run_refset(s: &mut Session, p: &Value) -> Result<Value> {
let (mut add, mut remove) = (handle_list(p, "add")?, handle_list(p, "remove")?);
let hs = handle_list(p, "handles")?;
if str_param(p, "mode").is_some_and(|m| m.to_ascii_lowercase().starts_with('r')) {
remove.extend(hs)
} else {
add.extend(hs)
}
apply_refset(s, &add, &remove)
}
fn apply_refset(s: &mut Session, add: &[Handle], remove: &[Handle]) -> Result<Value> {
let mut r = active(s, "refset")?;
let inv = r.to_world.inverse().ok_or_else(|| bad("refset", "the reference can't be inverted"))?;
let d = s.doc()?;
for h in add.iter().chain(remove) {
if d.entity(*h).is_none() {
return Err(bad("refset", format!("no object {}", h.hex())));
}
}
if let Some(h) = remove.iter().find(|h| !r.is_working(**h)) {
return Err(bad("refset", format!("object {} is not in the working set", h.hex())));
}
let blk = d.block(&r.block).cloned().ok_or_else(|| bad("refset", format!("block \"{}\" is gone; REFCLOSE Discard", r.block)))?;
let (mut added, mut removed) = (0usize, 0usize);
let doc = s.doc_mut()?;
for h in add {
if *h == r.insert || r.is_working(*h) {
continue;
}
if let Some(pos) = r.ghosts.iter().position(|(g, _)| g == h) {
// A faded block object joins the working set (with the edits it had before).
let (g, src) = r.ghosts.remove(pos);
doc.remove_entity(g);
let cur = match r.released.iter().position(|e| e.handle == src) {
Some(i) => Some(r.released.remove(i)),
None => blk.entities.get(src).map(|e| (**e).clone()),
};
if let Some(cur) = cur {
let c = doc.new_handle();
put(doc, &r, to_host(&r.to_world, &cur, c, false))?;
r.items.push(WorkItem { copy: c, source: Some(src) });
added += 1;
}
} else {
r.items.push(WorkItem { copy: *h, source: None });
added += 1;
}
}
for h in remove {
let Some(pos) = r.items.iter().position(|i| i.copy == *h) else { continue };
let item = r.items.remove(pos);
if let Some(src) = item.source
&& let Some(e) = doc.entity(*h).map(|e| (**e).clone())
{
// Back to the block, edits kept, shown faded.
let kept = to_block(&inv, &e, src);
doc.remove_entity(*h);
let g = doc.new_handle();
put(doc, &r, to_host(&r.to_world, &kept, g, true))?;
r.ghosts.push((g, src));
r.released.push(kept);
}
removed += 1;
}
let working = hexes(r.items.iter().map(|i| i.copy));
doc.refedit = Some(r);
Ok(json!({ "working": working, "added": added, "removed": removed }))
}
// ---------------- REFCLOSE ----------------
fn run_refclose(s: &mut Session, p: &Value) -> Result<Value> {
let save = match str_param(p, "option") {
Some(o) if o.to_ascii_lowercase().starts_with('d') => false,
Some(o) if o.to_ascii_lowercase().starts_with('s') => true,
Some(o) => return Err(bad("refclose", format!("unknown option \"{o}\" (save or discard)"))),
None => bool_or(p, "save", true),
};
close(s, save)
}
fn close(s: &mut Session, save: bool) -> Result<Value> {
let r = active(s, "refclose")?;
// Work on a copy: the drawing changes only when everything went through.
let mut nd: Drawing = s.doc()?.clone();
let mut objects = 0;
if save {
objects = commit_block(&mut nd, &r)?;
}
nd.end_refedit_discard(&r);
*s.doc_mut()? = nd;
s.set_selection(Vec::new());
Ok(json!({ "block": r.block, "saved": save, "objects": objects }))
}
/// Replace the block's objects by the working set (and move the drawing objects added to it
/// into the block). Returns the number of objects the block has now.
fn commit_block(nd: &mut Drawing, r: &RefEdit) -> Result<usize> {
let blk = nd.block(&r.block).cloned().ok_or_else(|| bad("refclose", format!("block \"{}\" is gone; use REFCLOSE Discard", r.block)))?;
let inv = r.to_world.inverse().ok_or_else(|| bad("refclose", "the reference can't be inverted; use REFCLOSE Discard"))?;
let mut ents: Vec<Entity> = Vec::new();
for e in blk.entities.iter() {
match r.items.iter().find(|i| i.source == Some(e.handle)) {
// A working object the user erased leaves the block.
Some(i) => {
if let Some(c) = nd.entity(i.copy) {
ents.push(to_block(&inv, c, e.handle));
}
}
None => match r.released.iter().find(|x| x.handle == e.handle) {
Some(x) => ents.push(x.clone()),
None => ents.push((**e).clone()),
},
}
}
let mut moved = Vec::new();
for i in r.items.iter().filter(|i| i.source.is_none()) {
if let Some(c) = nd.entity(i.copy).map(|c| (**c).clone()) {
let h = nd.new_handle();
ents.push(to_block(&inv, &c, h));
moved.push(i.copy);
}
}
if references_block(nd, &ents, &r.block) {
return Err(bad("refclose", format!("Block \"{}\" would reference itself; remove that reference or use REFCLOSE Discard.", r.block)));
}
let n = ents.len();
let mut store = EntityStore::new();
for e in ents {
store.push(e);
}
let key = nd.blocks.keys().find(|k| k.eq_ignore_ascii_case(&r.block)).cloned().unwrap_or_else(|| r.block.clone());
let mut nb = (*blk).clone();
nb.entities = store;
nd.blocks.insert(key, Arc::new(nb));
for h in moved {
nd.remove_entity(h);
}
Ok(n)
}
// ---------------- while editing ----------------
/// After a command ran: objects it created join the working set, and a command that changed
/// anything outside the working set is undone (returns the reason).
pub(crate) fn after_command(s: &mut Session, before: &Arc<Drawing>, id: &str) -> Option<String> {
if matches!(id, "refedit" | "refset" | "refclose") || !crate::cmd::find_command(id).is_some_and(|c| c.undoable) {
return None;
}
let rb = before.refedit.as_ref()?;
let st = s.state_mut().ok()?;
if Arc::ptr_eq(before, &st.doc) {
return None;
}
let ra = st.doc.refedit.as_ref().filter(|ra| ra.insert == rb.insert)?;
let owned: HashSet<Handle> = rb.items.iter().map(|i| i.copy).chain(rb.ghosts.iter().map(|g| g.0)).chain([rb.insert]).collect();
let stores = |d: &Drawing| std::iter::once(&d.model).chain(d.layouts.iter().map(|l| &l.entities)).cloned().collect::<Vec<_>>();
let touched = stores(before)
.iter()
.any(|store| store.iter().any(|e| !owned.contains(&e.handle) && !st.doc.entity(e.handle).is_some_and(|a| Arc::ptr_eq(a, e))));
if touched {
st.doc = before.clone();
return Some("Only the working set can be edited during REFEDIT (REFSET adds objects, REFCLOSE ends the edit).".into());
}
let seed = before.handseed;
let mut new: Vec<Handle> = Vec::new();
for store in stores(&st.doc) {
new.extend(store.iter().map(|e| e.handle).filter(|h| h.0 >= seed && !ra.owns(*h)));
}
if !new.is_empty()
&& let Some(r) = Arc::make_mut(&mut st.doc).refedit.as_mut()
{
r.items.extend(new.into_iter().map(|copy| WorkItem { copy, source: None }));
}
None
}
/// Whether `h` may not be edited because a reference is being edited and it is not in the working set.
pub(crate) fn locked(d: &Drawing, h: Handle) -> bool {
d.refedit.as_ref().is_some_and(|r| !r.is_working(h))
}
// ---------------- interactive ----------------
#[derive(Default)]
enum RStage {
#[default]
Mode,
Pick(bool),
}
#[derive(Default)]
struct RefsetM {
stage: RStage,
sel: SelectPhase,
}
impl Interactive for RefsetM {
fn name(&self) -> &'static str {
"REFSET"
}
fn begin(&mut self, s: &mut Session) -> Result<Step> {
active(s, "refset").map(|_| Step::Continue)
}
fn prompt(&self, _s: &Session) -> Prompt {
match self.stage {
RStage::Mode => {
let mut p = Prompt::new("Enter an option [Add/Remove] <Add>", Accept::TEXT);
p.keywords = vec!["Add".into(), "Remove".into()];
p.default = Some("Add".into());
p
}
RStage::Pick(remove) => {
let mut p = self.sel.prompt();
p.message = format!("Select objects to {}", if remove { "remove" } else { "add" });
p
}
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
match self.stage {
RStage::Mode => {
let remove = match i {
Input::Keyword(k) | Input::Text(k) => k.to_ascii_lowercase().starts_with('r'),
Input::Enter => false,
_ => return Ok(Step::Cancel),
};
self.sel = SelectPhase::begin(s);
self.stage = RStage::Pick(remove);
if self.sel.done {
let hs = self.sel.picked.clone();
return self.finish(s, remove, &hs);
}
Ok(Step::Continue)
}
RStage::Pick(remove) => match self.sel.feed(s, &i)? {
SelOutcome::More => Ok(Step::Continue),
SelOutcome::Empty => Ok(Step::Done),
SelOutcome::Done(hs) => self.finish(s, remove, &hs),
},
}
}
}
impl RefsetM {
fn finish(&mut self, s: &mut Session, remove: bool, hs: &[Handle]) -> Result<Step> {
let r = if remove { apply_refset(s, &[], hs) } else { apply_refset(s, hs, &[]) }?;
s.echo(format!("{} object(s) in the working set.", r.get("working").and_then(Value::as_array).map_or(0, Vec::len)));
s.set_selection(Vec::new());
Ok(Step::Done)
}
}
struct RefcloseM;
impl Interactive for RefcloseM {
fn name(&self) -> &'static str {
"REFCLOSE"
}
fn prompt(&self, _s: &Session) -> Prompt {
let mut p = Prompt::new("Enter option [Save/Discard reference changes] <Save>", Accept::TEXT);
p.keywords = vec!["Save".into(), "Discard".into()];
p.default = Some("Save".into());
p
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
let save = match i {
Input::Keyword(k) | Input::Text(k) => !k.to_ascii_lowercase().starts_with('d'),
Input::Enter => true,
_ => return Ok(Step::Cancel),
};
let r = close(s, save)?;
s.echo(if save { "Reference changes saved." } else { "Reference changes discarded." });
let _ = r;
Ok(Step::Done)
}
}
+2 -2
View File
@@ -36,9 +36,9 @@ pub fn handle_param(p: &serde_json::Value, key: &str) -> Option<Handle> {
p.get(key).and_then(handle_value)
}
/// Is the entity on a locked layer?
/// Is the entity on a locked layer, or outside the working set while a block reference is edited in place (REFEDIT)?
pub fn is_locked(s: &Session, h: Handle) -> bool {
s.doc().ok().is_some_and(|d| d.entity(h).and_then(|e| d.layer(&e.common.layer)).is_some_and(|l| l.locked))
s.doc().ok().is_some_and(|d| d.entity(h).and_then(|e| d.layer(&e.common.layer)).is_some_and(|l| l.locked) || super::blocks::refedit::locked(d, h))
}
// ---------------- arc constructions ----------------
+49 -5
View File
@@ -271,7 +271,7 @@ fn run_spline(s: &mut Session, p: &Value) -> Result<Value> {
}
Spline::fit_with(&f, bool_or(p, "closed", false), super::spline_opts::json_options(s, p)?)
} else if let Some(c) = points_param(p, "control") {
if c.len() < 2 {
if c.len() < 2 || c.len() > super::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
@@ -378,7 +378,7 @@ fn run_mtext(s: &mut Session, p: &Value) -> Result<Value> {
attach,
rotation: f64_or(p, "rotation", 0.0).to_radians(),
style,
contents: super::mtext_opts::json_paragraphs(p, text.replace('\n', "\\P"))?,
contents: super::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: super::mtext_opts::json_columns(p, height, width)?,
@@ -428,7 +428,21 @@ impl Interactive for LineM {
&& let Some(first) = self.first
{
match i {
Input::Point(p) => return self.resolve_first(s, first, snap::LineEnd::Point(p)),
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)),
_ => {}
}
@@ -780,6 +794,9 @@ impl Interactive for CircleM {
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 => {
@@ -895,6 +912,9 @@ impl Interactive for PolygonM {
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))?;
@@ -917,11 +937,17 @@ impl Interactive for PolygonM {
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);
@@ -1412,11 +1438,22 @@ struct TextM {
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 {
fn new(_s: &Session) -> Self {
TextM { at: None, height: None, rotation: None, justify: (HAlign::Left, VAlign::Baseline), asking_justify: false, line: 0 }
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(),
}
}
}
@@ -1477,6 +1514,9 @@ impl Interactive for TextM {
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),
_ => {}
@@ -1562,6 +1602,10 @@ impl Interactive for MTextM {
}
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) => {
+162 -12
View File
@@ -43,12 +43,17 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("preview", "Plot Preview", run_preview)
.menu(&["File", "Plot Preview"])
.alias(&["pre"])
.params("{layout?: current|\"Model\", paper?, landscape?, fit?, scale?, lineweights?, width?: pixels (default 1200)} → {data: base64 PNG, width, height, layout, paper}")
.params("{layout?: current|\"Model\", paper?, landscape?, fit?, scale?, lineweights?, plotTransparency?, width?: pixels (default 1200)} → {data: base64 PNG, width, height, layout, paper}")
.noundo(),
CommandSpec::new("publish", "Batch Publish...", run_publish)
.menu(&["File", "Batch Publish..."])
.params("{path?: \"NAME.pdf\" (writes NAME-<sheet>.pdf per sheet; else returns base64 `data` per sheet), layouts?: [name | \"Model\"] (default: Model if it has objects, then every layout in tab order)} → {sheets: [{layout, bytes, path? | data}]}")
.noundo(),
CommandSpec::new("pdfbook", "Sheet Books to PDF...", run_pdfbook)
.menu(&["File", "Sheet Books to PDF..."])
.alias(&["printpdf", "pdfb"])
.params("{path?: \"DIR/NAME^NAME2.pdf\" (default: the drawing's file; a `^` splits the name into one book name per row of frames; else returns base64 `data` per book), names?: [book names], frameHeight?: mm (default 297), unit?: mm per drawing unit (default 1)} → {books: [{name, pages, bytes, path? | data}], skipped}: closed polylines of the frame height in model space are sheet frames; each row (same lower edge, bottom to top) is one PDF, its frames left to right one page each on the paper (ISO A-series, either orientation) they match")
.noundo(),
CommandSpec::new("attach", "Attach", |_, _| {
Err(EngineError::Other("ATTACH is not available yet: raster images and PDF/DWF/DGN underlays can't be shown yet".into()))
})
@@ -287,9 +292,9 @@ fn plot_scale(b: &Bounds2, sheet: &Sheet, fit: bool, scale: Option<f64>) -> f64
fn run_preview(s: &mut Session, p: &Value) -> Result<Value> {
let space = plot_space("preview", s, p)?;
let d = s.doc()?;
let mut page = match &space {
Space::Paper(n) => d.layout(n).map(|l| l.page.clone()).unwrap_or_default(),
Space::Model => {
let mut page = match space.layout() {
Some(n) => d.layout(n).map(|l| l.page.clone()).unwrap_or_default(),
None => {
// Model space plots on A4 (metric) or ANSI A, like the PDF plotter.
let mut page = PageSetup::default();
if paper::paper_unit_mm(d) == 1.0
@@ -312,10 +317,8 @@ fn run_preview(s: &mut Session, p: &Value) -> Result<Value> {
page.landscape = l;
}
let sheet = Sheet::from_page(&page, paper::paper_unit_mm(d));
let lineweights = p.get("lineweights").and_then(Value::as_bool).unwrap_or(match space {
Space::Paper(_) => page.lineweights,
Space::Model => true,
});
let lineweights = p.get("lineweights").and_then(Value::as_bool).unwrap_or(if space.is_paper() { page.lineweights } else { true });
let transparency = super::plotters::plot_transparency(s, p).unwrap_or(page.plot_transparency);
let fit = p.get("fit").and_then(Value::as_bool).unwrap_or(matches!(space, Space::Model));
let scale = p.get("scale").and_then(Value::as_f64).filter(|v| v.is_finite() && *v > 0.0);
let width = p.get("width").and_then(Value::as_u64).unwrap_or(PREVIEW_PX).clamp(16, MAX_PREVIEW_PX);
@@ -331,6 +334,7 @@ fn run_preview(s: &mut Session, p: &Value) -> Result<Value> {
text: true,
fill: true,
lineweights,
transparency,
..Default::default()
};
let list = cadcraft_render::build_plot(d, &space, &ropts);
@@ -355,10 +359,7 @@ fn run_preview(s: &mut Session, p: &Value) -> Result<Value> {
antialias: true,
};
let png = cadcraft_render::raster::render_png(&list, &view, &ro).ok_or_else(|| bad("preview", "rendering the preview failed"))?;
let layout = match &space {
Space::Model => "Model".to_string(),
Space::Paper(n) => n.clone(),
};
let layout = space.tab_name();
Ok(json!({ "data": base64_encode(&png), "width": w, "height": h, "layout": layout, "paper": page.paper, "landscape": page.landscape }))
}
@@ -421,6 +422,122 @@ fn run_publish(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "sheets": out, "message": message }))
}
// ---------- PDFBOOK ----------
/// Frame sizes match a paper to this many millimetres.
const FRAME_TOL_MM: f64 = 0.5;
/// Most frames one PDFBOOK looks at.
const MAX_FRAMES: usize = 100_000;
/// A sheet frame found in model space.
struct Frame {
window: Bounds2,
paper: &'static str,
landscape: bool,
}
/// The ISO/ANSI paper a frame of `w` x `h` mm fits (either orientation), and whether it is landscape.
fn paper_for(w: f64, h: f64) -> Option<(&'static str, bool)> {
cadcraft_render::PAPER_SIZES.iter().find_map(|p| {
let (pw, ph) = (p.width_mm, p.height_mm);
if (pw - w).abs() <= FRAME_TOL_MM && (ph - h).abs() <= FRAME_TOL_MM {
Some((p.name, false))
} else if (ph - w).abs() <= FRAME_TOL_MM && (pw - h).abs() <= FRAME_TOL_MM {
Some((p.name, true))
} else {
None
}
})
}
/// Sheet frames grouped into books: rows of equal lower edge, bottom to top, each left to right.
fn frame_books(d: &Drawing, frame_height: f64, unit: f64) -> (Vec<Vec<Frame>>, usize) {
let mut found: Vec<(i64, f64, Frame)> = Vec::new();
let mut skipped = 0;
for e in d.model.iter().take(MAX_FRAMES) {
let EntityKind::LwPolyline(pl) = &e.kind else { continue };
if !pl.closed {
continue;
}
let b = cadcraft_doc::entity_bounds(d, e, 0);
if b.is_empty() || !b.width().is_finite() || !b.height().is_finite() {
continue;
}
let (w, h) = (b.width() * unit, b.height() * unit);
if (h - frame_height).abs() > 0.0005 {
continue;
}
match paper_for(w, h) {
Some((paper, landscape)) => {
let row = (b.min.y * 1000.0).round();
if row.is_finite() && row.abs() < 9e15 {
found.push((row as i64, b.min.x, Frame { window: b, paper, landscape }));
}
}
None => skipped += 1,
}
}
found.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.total_cmp(&b.1)));
let mut books: Vec<Vec<Frame>> = Vec::new();
let mut last = None;
for (row, _, f) in found {
if last != Some(row) {
books.push(Vec::new());
last = Some(row);
}
if let Some(b) = books.last_mut() {
b.push(f);
}
}
(books, skipped)
}
fn run_pdfbook(s: &mut Session, p: &Value) -> Result<Value> {
if io().and_then(|h| h.plot).is_none() {
return Err(bad("pdfbook", "plotting is not available in this build"));
}
let num = |k: &str, default: f64| p.get(k).and_then(Value::as_f64).filter(|v| v.is_finite() && *v > 0.0).unwrap_or(default);
let (frame_height, unit) = (num("frameHeight", 297.0), num("unit", 1.0));
let (books, skipped) = frame_books(s.doc()?, frame_height, unit);
if books.is_empty() {
return Err(bad("pdfbook", format!("no closed polylines {frame_height} mm high (sheet frames) in model space")));
}
let target = str_param(p, "path").map(str::to_string).or_else(|| s.state().ok().and_then(|st| st.path.clone()));
let pb = target.as_deref().map(std::path::Path::new);
let dir = pb.and_then(std::path::Path::parent).map(std::path::Path::to_path_buf).unwrap_or_default();
let stem = pb
.and_then(std::path::Path::file_stem)
.map(|x| x.to_string_lossy().to_string())
.filter(|x| !x.is_empty())
.unwrap_or_else(|| "Drawing".into());
let names: Vec<String> = match p.get("names").and_then(Value::as_array) {
Some(a) => a.iter().filter_map(|n| n.as_str().map(str::to_string)).collect(),
None => stem.split('^').map(str::to_string).collect(),
};
let write = pb.is_some();
let mut out = Vec::new();
for (i, frames) in books.iter().enumerate() {
// A row without its own name is numbered after the drawing.
let name = names.get(i).map(|n| file_part(n)).filter(|n| !n.is_empty()).unwrap_or_else(|| format!("{}-{}", file_part(&stem), i + 1));
let pages: Vec<Value> = frames
.iter()
.map(|f| json!({ "window": [[f.window.min.x, f.window.min.y], [f.window.max.x, f.window.max.y]], "paper": f.paper, "landscape": f.landscape }))
.collect();
let mut q = json!({ "layout": "Model", "pages": pages, "margins": [0, 0, 0, 0] });
if write {
q["path"] = json!(dir.join(format!("{name}.pdf")).to_string_lossy());
}
let mut r = s.execute("plot", &q).map_err(|e| bad("pdfbook", format!("{name}: {e}")))?;
if let Some(o) = r.as_object_mut() {
o.insert("name".into(), json!(name));
o.insert("pages".into(), json!(frames.len()));
}
out.push(r);
}
let message = format!("Plotted {} book(s), {} sheet(s)", out.len(), books.iter().map(Vec::len).sum::<usize>());
Ok(json!({ "books": out, "skipped": skipped, "message": message }))
}
#[cfg(test)]
mod tests {
use cadcraft_doc::{Block, Circle, Common, Handle, Insert, Layer, Line};
@@ -587,6 +704,39 @@ mod tests {
assert_ne!(png(&mut s, json!({ "layout": "Layout1", "width": 200, "fit": false, "scale": 0.5 })), default, "scale 0.5 is not 1:1");
}
#[test]
fn pdfbook_collects_frames_into_one_pdf_per_row() {
our_hooks();
if io().and_then(|h| h.plot).is_none() {
return;
}
let mut s = Session::new();
// Row 1 (y = 0): A4, A3 landscape (drawn right to left); row 2 (y = 400): one A4; a stray rectangle.
for c in ["pline 210,0 420,0 420,297 210,297 c", "pline 0,0 210,0 210,297 0,297 c", "pline 420,0 840,0 840,297 420,297 c"] {
s.cmdline(c).unwrap();
s.cmdline("").unwrap();
}
s.cmdline("pline 0,400 210,400 210,697 0,697 c").unwrap();
s.cmdline("").unwrap();
s.cmdline("pline 0,900 50,900 50,950 0,950 c").unwrap();
s.cmdline("").unwrap();
let r = s.execute("pdfbook", &json!({ "path": temp("a^b.pdf").to_string_lossy() }));
let r = r.unwrap();
let books = r["books"].as_array().unwrap();
assert_eq!(books.len(), 2);
assert_eq!(books[0]["pages"], 3);
assert_eq!(books[1]["pages"], 1);
let first = temp("a.pdf");
let text = String::from_utf8(std::fs::read(&first).unwrap()).unwrap();
let (a4, a3) = (text.find("ISO A4").unwrap(), text.find("ISO A3").unwrap());
assert!(a4 < a3, "frames go left to right: {text}");
assert!(text.contains("\"landscape\":true"));
let _ = std::fs::remove_file(first);
let _ = std::fs::remove_file(temp("b.pdf"));
// No frames: an error, not a panic.
assert!(Session::new().execute("pdfbook", &json!({})).is_err());
}
#[test]
fn publish_plots_every_sheet() {
our_hooks();
+122 -17
View File
@@ -37,6 +37,24 @@ pub fn io() -> Option<&'static IoHooks> {
IO.get()
}
/// An entity as DXF groups and back, for AutoLISP's `entget`, `entmake` and `entmod`
/// (installed by the app with the DXF reader and writer, a lower layer).
pub struct EntityCodec {
/// The entity's type name and the groups of its record, without its `0` group.
pub groups: fn(&Drawing, &cadcraft_doc::Entity) -> (String, Vec<cadcraft_dxf::Tag>),
/// An entity from a type name and groups.
pub entity: fn(&str, &[cadcraft_dxf::Tag]) -> Option<(cadcraft_doc::Common, cadcraft_doc::EntityKind)>,
}
static CODEC: OnceLock<EntityCodec> = OnceLock::new();
pub fn set_entity_codec(c: EntityCodec) {
let _ = CODEC.set(c);
}
pub fn entity_codec() -> Option<&'static EntityCodec> {
CODEC.get()
}
/// COMPARE (drawing comparison).
pub mod compare;
@@ -117,11 +135,13 @@ fn write_bytes(s: &Session, cmd: &str, path: &str, opts: &SaveAs) -> Result<Vec<
if opts.binary && !is_dxf(path) {
return Err(bad(cmd, "binary applies to DXF files only"));
}
// A drawing file is the drawing outside the Block Editor; an image is what is shown.
let d: &cadcraft_doc::Drawing = if is_drawing_file(path) { s.state()?.drawing() } else { s.doc()? };
let r = if *opts == SaveAs::default() {
(hooks.write)(s.doc()?, path)
(hooks.write)(d, path)
} else {
let w = write_as().ok_or_else(|| bad(cmd, "saving at a chosen version or as binary DXF is not available in this build"))?;
w(s.doc()?, path, opts)
w(d, path, opts)
};
r.map_err(|e| bad(cmd, e))
}
@@ -138,7 +158,7 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("open", "Open...", run_open)
.menu(&["File", "Open..."])
.key("Cmd+O")
.params("{path} | {data: base64, name}")
.params("{path} | {data: base64, name} → {index, entities?, warnings: what reading left out}")
.enabled(always)
.noundo(),
CommandSpec::new("close", "Close", run_close).menu(&["File", "Close"]).key("Cmd+W").params("{index?}").noundo(),
@@ -228,18 +248,80 @@ fn file_name(path: &str) -> String {
std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string())
}
pub(crate) fn open_bytes(s: &mut Session, bytes: &[u8], name: &str, path: Option<String>) -> Result<usize> {
/// A file read and decoded but not yet added to a [`Session`]. Reading is the slow part of OPEN and
/// needs no session, so a host can do it on a worker thread and hand the result to
/// [`finish_open`] on the thread that owns the session.
pub struct ReadFile {
drawing: Drawing,
binary_dxf: bool,
name: String,
path: Option<String>,
}
/// Read and decode `path` (the slow half of `OPEN {path}`). Needs no session; runs anywhere.
pub fn read_file(path: &str) -> std::result::Result<ReadFile, String> {
#[cfg(not(target_arch = "wasm32"))]
{
let r = std::panic::catch_unwind(|| {
let bytes = std::fs::read(path).map_err(|e| bad("open", format!("{path}: {e}")))?;
read_bytes(&bytes, path, Some(path.to_string()))
});
match r {
Ok(r) => r.map_err(|e| e.to_string()),
Err(_) => Err(bad("open", format!("{path}: the file could not be read")).to_string()),
}
}
#[cfg(target_arch = "wasm32")]
{
let _ = path;
Err(bad("open", "paths are not available on the web; pass `data`").to_string())
}
}
/// Add a [`read_file`] result to the session as the active drawing: the fast half of OPEN.
pub fn finish_open(s: &mut Session, f: ReadFile) -> Result<Value> {
let ReadFile { drawing, binary_dxf, name, path } = f;
let i = add_read(s, drawing, binary_dxf, &name, path);
super::blocks::xref::after_open(s, i);
Ok(opened(s, i, true))
}
fn read_bytes(bytes: &[u8], name: &str, path: Option<String>) -> Result<ReadFile> {
let hooks = io().ok_or_else(|| bad("open", "file formats are not available in this build"))?;
let d = (hooks.read)(bytes, name).map_err(|e| bad("open", e))?;
let drawing = (hooks.read)(bytes, name).map_err(|e| bad("open", e))?;
// A file that isn't a drawing (an imported SVG) gives the drawing no file name: QSAVE then
// asks where to save instead of exporting over the original.
let path = path.filter(|p| is_drawing_file(p));
let i = s.open_drawing(d, &file_name(name), path);
Ok(ReadFile { drawing, binary_dxf: cadcraft_dxf::is_binary(bytes), name: name.to_string(), path })
}
fn add_read(s: &mut Session, drawing: Drawing, binary_dxf: bool, name: &str, path: Option<String>) -> usize {
let notes = drawing.read_notes.clone();
let i = s.open_drawing(drawing, &file_name(name), path);
if let Some(st) = s.docs.get_mut(i) {
// QSAVE keeps a binary DXF binary.
st.binary_dxf = cadcraft_dxf::is_binary(bytes);
st.binary_dxf = binary_dxf;
}
for note in notes {
s.echo(note);
}
i
}
pub(crate) fn open_bytes(s: &mut Session, bytes: &[u8], name: &str, path: Option<String>) -> Result<usize> {
let ReadFile { drawing, binary_dxf, name, path } = read_bytes(bytes, name, path)?;
Ok(add_read(s, drawing, binary_dxf, &name, path))
}
/// OPEN's reply: the drawing's index and what reading it left out.
fn opened(s: &Session, i: usize, entities: bool) -> Value {
let d = s.docs.get(i).map(|st| &st.doc);
let warnings = d.map(|d| d.read_notes.clone()).unwrap_or_default();
if entities {
json!({ "index": i, "entities": d.map(|d| d.entity_count()), "warnings": warnings })
} else {
json!({ "index": i, "warnings": warnings })
}
Ok(i)
}
fn run_open(s: &mut Session, p: &Value) -> Result<Value> {
@@ -251,7 +333,7 @@ fn run_open(s: &mut Session, p: &Value) -> Result<Value> {
let bytes = std::fs::read(path).map_err(|e| bad("open", format!("{path}: {e}")))?;
let i = open_bytes(s, &bytes, path, Some(path.to_string()))?;
super::blocks::xref::after_open(s, i);
return Ok(json!({ "index": i, "entities": s.docs.get(i).map(|d| d.doc.entity_count()) }));
return Ok(opened(s, i, true));
}
#[cfg(target_arch = "wasm32")]
return Err(bad("open", "paths are not available on the web; pass `data`"));
@@ -261,7 +343,7 @@ fn run_open(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "name").unwrap_or("Drawing.dxf");
let i = open_bytes(s, &bytes, name, None)?;
super::blocks::xref::after_open(s, i);
Ok(json!({ "index": i }))
Ok(opened(s, i, false))
}
fn run_close(s: &mut Session, p: &Value) -> Result<Value> {
@@ -296,16 +378,19 @@ pub(crate) fn save_to(s: &mut Session, path: &str) -> Result<usize> {
/// Save to `path` with `opts`. A drawing file then keeps the version and binary choice: the
/// version goes into the drawing's `$ACADVER`, so later saves (QSAVE) write it again.
fn save_as(s: &mut Session, cmd: &str, path: &str, opts: &SaveAs) -> Result<usize> {
if s.doc()?.refedit.is_some() {
return Err(bad(cmd, "A block reference is being edited in place: finish with REFCLOSE (Save or Discard) before saving."));
}
let bytes = write_bytes(s, cmd, path, opts)?;
let n = commit_save(s, path, &bytes, store)?;
if is_drawing_file(path) {
let st = s.state_mut()?;
st.binary_dxf = opts.binary;
if let Some(v) = opts.version
&& st.doc.header.get("ACADVER").and_then(cadcraft_doc::HVal::as_str) != Some(v.code())
&& st.drawing().header.get("ACADVER").and_then(cadcraft_doc::HVal::as_str) != Some(v.code())
{
std::sync::Arc::make_mut(&mut st.doc).header.set_str("ACADVER", v.code());
st.saved = st.doc.clone();
std::sync::Arc::make_mut(st.drawing_arc_mut()).header.set_str("ACADVER", v.code());
st.saved = st.drawing().clone();
}
}
Ok(n)
@@ -341,7 +426,7 @@ fn commit_save(s: &mut Session, path: &str, bytes: &[u8], store: impl FnOnce(&st
return Ok(bytes.len());
}
let st = s.state_mut()?;
st.saved = st.doc.clone();
st.saved = st.drawing().clone();
st.path = Some(path.to_string());
st.title = file_name(path);
// A BEgin group still open when the drawing is saved ends there.
@@ -400,7 +485,8 @@ fn run_switch(s: &mut Session, p: &Value) -> Result<Value> {
fn run_bytes(s: &mut Session, p: &Value) -> Result<Value> {
let fmt = str_param(p, "format").unwrap_or("dxf");
let opts = save_options("document.bytes", p)?;
let lost = not_saved(s.doc()?);
let version = opts.version.unwrap_or_else(|| drawing_version(s, &format!("drawing.{fmt}")));
let lost = not_saved_as(s.doc()?, version);
let bytes = write_bytes(s, "document.bytes", &format!("drawing.{fmt}"), &opts)?;
let mut r = json!({ "data": base64_encode(&bytes), "bytes": bytes.len() });
if drops_unknown(&format!("drawing.{fmt}")) {
@@ -411,12 +497,28 @@ fn run_bytes(s: &mut Session, p: &Value) -> Result<Value> {
/// Entities a DXF or DWG file doesn't keep: those of object types CADCraft doesn't model
/// (3DSOLID, MULTILEADER… read from other programs' files and kept in the drawing, but
/// not written), counted by DXF type across model space, layouts and block definitions.
/// not written), counted by DXF type across model space, layouts and block definitions. Saved
/// at the drawing's own version; see [`not_saved_as`].
pub fn not_saved(d: &Drawing) -> std::collections::BTreeMap<String, usize> {
let v = d.header.get("ACADVER").and_then(cadcraft_doc::HVal::as_str).and_then(Version::for_acadver).unwrap_or_default();
not_saved_as(d, v)
}
/// Solid modeler entities whose ACIS geometry a file of AutoCAD 2013 and later keeps in its data
/// store section, which is written back with the drawing (`Foreign::data_store`).
const MODELER_TYPES: [&str; 3] = ["3DSOLID", "REGION", "BODY"];
/// [`not_saved`] for a file saved as `version`. From 2013 on, a 3D solid, region or body the data
/// store has a record for keeps its geometry and is not counted.
pub fn not_saved_as(d: &Drawing, version: Version) -> std::collections::BTreeMap<String, usize> {
let mut out: std::collections::BTreeMap<String, usize> = std::collections::BTreeMap::new();
let stored = if version >= Version::R2013 { d.foreign.data_store_entities() } else { Default::default() };
let stores = std::iter::once(&d.model).chain(d.layouts.iter().map(|l| &l.entities)).chain(d.blocks.values().map(|b| &b.entities));
for e in stores.flat_map(|s| s.iter()) {
if let cadcraft_doc::EntityKind::Unknown(u) = &e.kind {
if MODELER_TYPES.iter().any(|t| u.dxf_type.eq_ignore_ascii_case(t)) && stored.contains(&e.handle) {
continue;
}
let n = out.entry(u.dxf_type.clone()).or_insert(0);
*n = n.saturating_add(1);
}
@@ -457,7 +559,10 @@ fn report_not_saved(s: &mut Session, r: &mut Value, lost: &std::collections::BTr
/// Save, then report the entities the file couldn't keep (nothing is dropped silently).
fn save_reporting(s: &mut Session, p: &Value, run: fn(&mut Session, &Value) -> Result<Value>) -> Result<Value> {
let lost = s.doc().map(not_saved).unwrap_or_default();
// What the file will hold depends on the version it is saved as (the data store from 2013).
let path = str_param(p, "path").map(str::to_string).or_else(|| s.state().ok().and_then(|st| st.path.clone())).unwrap_or_default();
let version = save_options("save", p).ok().and_then(|o| o.version).unwrap_or_else(|| drawing_version(s, &path));
let lost = s.doc().map(|d| not_saved_as(d, version)).unwrap_or_default();
let mut r = run(s, p)?;
if r.get("path").and_then(Value::as_str).is_some_and(drops_unknown) {
report_not_saved(s, &mut r, &lost);
+86 -10
View File
@@ -15,15 +15,22 @@ use crate::{Accept, EngineError, Input, Interactive, Picker, Prompt, Result, Ses
mod assoc;
mod edit;
mod pick;
pub(crate) use assoc::update as refill_associative;
pub use pick::{HatchPicks, PickMode};
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("hatch", "Hatch...", run_hatch).menu(&["Draw", "Hatch..."]).alias(&["h", "bh", "-hatch"]).params("{points?: [[x,y]] (internal points) | handles?: [hex] (closed objects), pattern?: \"ANSI31\", scale?, angle? (degrees), associative? (HPASSOC), separate? (HPSEPARATE: a hatch per point/object), origin?: [x,y] (HPORIGIN) | originMode?: \"current\"|\"bottomLeft\"|\"bottomRight\"|\"topLeft\"|\"topRight\"|\"center\" (HPORIGINMODE), style?: \"normal\"|\"outer\"|\"ignore\" (HPISLANDDETECTION)} — gaps up to HPGAPTOL close").interactive(|s| Ok(Box::new(HatchM::new(s, false)))),
CommandSpec::new("gradient", "Gradient...", run_gradient).menu(&["Draw", "Gradient..."]).alias(&["gd"]).params("{points? | handles?, color1?, color2?, angle?}").interactive(|s| Ok(Box::new(HatchM::new(s, true)))),
CommandSpec::new("hatch", "Hatch...", run_hatch).menu(&["Draw", "Hatch..."]).alias(&["h", "bh", "-hatch"]).params("{points?: [[x,y]] (internal points) and/or handles?: [hex] (closed objects), pattern?: \"ANSI31\", scale?, angle? (degrees), associative? (HPASSOC), separate? (HPSEPARATE: a hatch per point/object), origin?: [x,y] (HPORIGIN) | originMode?: \"current\"|\"bottomLeft\"|\"bottomRight\"|\"topLeft\"|\"topRight\"|\"center\" (HPORIGINMODE), style?: \"normal\"|\"outer\"|\"ignore\" (HPISLANDDETECTION)} — gaps up to HPGAPTOL close").interactive(|s| Ok(Box::new(HatchM::new(s, false)))),
CommandSpec::new("gradient", "Gradient...", run_gradient).menu(&["Draw", "Gradient..."]).alias(&["gd"]).params("{points? and/or handles?, name?: \"LINEAR\"|\"CYLINDER\"|\"CURVED\"|\"SPHERICAL\"|\"HEMISPHERICAL\" or INV… (colours swapped), color1?, color2?, angle? (degrees), centered? (default true)}").interactive(|s| Ok(Box::new(HatchM::new(s, true)))),
CommandSpec::new("boundary", "Boundary...", run_boundary).menu(&["Draw", "Boundary..."]).alias(&["bo", "bpoly"]).params("{points: [[x,y]]}").interactive(|_| Ok(Box::new(BoundaryM { region: false }))),
CommandSpec::new("region", "Region", run_region).menu(&["Draw", "Region"]).alias(&["reg"]).params("{handles?}"),
CommandSpec::new("hatchedit", "Hatch Edit", edit::run_hatchedit).menu(&["Modify", "Object", "Hatch..."]).alias(&["he", "-hatchedit"]).params("{handles?, pattern?, scale?, angle?, associative?: bool (false unlinks; true links each loop to a closed object of the same shape, or to `boundary` objects), boundary?: [hex], style?, origin? | originMode?, separate?: true, recreate?: \"polyline\"|\"region\", associate?: bool (link to the recreated boundary)}").interactive(|s| Ok(Box::new(edit::HatchEditM::new(s)))),
CommandSpec::new("hatchedit", "Hatch Edit", edit::run_hatchedit).menu(&["Modify", "Object", "Hatch..."]).alias(&["he", "-hatchedit"]).params("{handles?, pattern?, scale?, angle?, gradient?: {name?, color1?, color2?, angle? (degrees), centered?} (makes or changes a gradient fill; `pattern` turns it back), associative?: bool (false unlinks; true links each loop to a closed object of the same shape, or to `boundary` objects), boundary?: [hex], style?, origin? | originMode?, separate?: true, recreate?: \"polyline\"|\"region\", associate?: bool (link to the recreated boundary)}").interactive(|s| Ok(Box::new(edit::HatchEditM::new(s)))),
CommandSpec::new("hatchpick", "Hatch Pick", pick::run_hatchpick)
.params("{points?: [[x,y]] (internal points), handles?: [hex] (closed objects)} (typed: Points, Objects, oRigin or Hatch, then the picks): the Hatch and Gradient dialog's picks, kept in the session; changes nothing")
.enabled(has_doc)
.noundo()
.interactive(|_| Ok(Box::new(pick::HatchPickM::default()))),
]
}
@@ -250,6 +257,17 @@ pub(crate) fn loops_from_handles(s: &Session, hs: &[Handle]) -> Result<Vec<Hatch
Ok(loops)
}
/// Gradient fills by name: the shapes the renderer draws (`render/src/hatch.rs`); the INV forms
/// swap the two colours.
pub(crate) const GRADIENT_NAMES: [&str; 10] =
["LINEAR", "CYLINDER", "CURVED", "SPHERICAL", "HEMISPHERICAL", "INVLINEAR", "INVCYLINDER", "INVCURVED", "INVSPHERICAL", "INVHEMISPHERICAL"];
/// A gradient name as stored (upper case), or None when it isn't one of [`GRADIENT_NAMES`].
pub(crate) fn gradient_name(t: &str) -> Option<&'static str> {
let up = t.trim().to_ascii_uppercase();
GRADIENT_NAMES.iter().copied().find(|n| *n == up)
}
#[derive(Clone)]
struct HatchSettings {
pattern: String,
@@ -273,11 +291,11 @@ impl HatchSettings {
));
let pattern = str_param(p, "pattern").map(str::to_ascii_uppercase).unwrap_or(hdr.0);
let g = gradient.then(|| Gradient {
name: str_param(p, "name").unwrap_or("LINEAR").to_string(),
name: str_param(p, "name").and_then(gradient_name).unwrap_or("LINEAR").to_string(),
color1: str_param(p, "color1").and_then(Color::parse).unwrap_or(Color::Index(5)),
color2: str_param(p, "color2").and_then(Color::parse).unwrap_or(Color::Index(7)),
angle: f64_or(p, "angle", 0.0).to_radians(),
centered: true,
centered: bool_or(p, "centered", true),
});
HatchSettings {
pattern,
@@ -333,14 +351,21 @@ fn make(s: &mut Session, p: &Value, gradient: bool) -> Result<Value> {
if str_param(p, "originMode").is_some_and(|t| OriginMode::parse(t).is_none()) {
return Err(bad(cmd, "unknown `originMode`"));
}
if gradient && str_param(p, "name").is_some_and(|t| gradient_name(t).is_none()) {
return Err(bad(cmd, format!("unknown gradient `name`; use one of {}", GRADIENT_NAMES.join(", "))));
}
let st = HatchSettings::from(s, p, gradient);
// Loops by picked point or selected object: one hatch each when separate.
let mut groups: Vec<Vec<HatchLoop>> = Vec::new();
if let Some(pts) = points_param(p, "points") {
for q in pts {
groups.push(loops_at(s, q)?);
}
} else {
// Internal points and boundary objects can come together (the dialog's Pick points and
// Select objects); points alone don't fall back to the selection.
let pts = points_param(p, "points");
let given = |k: &str| p.get(k).is_some_and(|v| !v.is_null());
let objects = pts.is_none() || given("handles") || given("handle");
for q in pts.into_iter().flatten() {
groups.push(loops_at(s, q)?);
}
if objects {
let hs = targets(s, p)?;
if st.separate {
groups.extend(loops_from_handles(s, &hs)?.into_iter().map(|l| vec![HatchLoop { outer: true, ..l }]));
@@ -428,6 +453,13 @@ fn add_hatches(s: &mut Session, st: &HatchSettings, groups: Vec<Vec<HatchLoop>>)
fn run_hatch(s: &mut Session, p: &Value) -> Result<Value> {
patfile::load_param(p)?;
// Like the prompt: a name without a definition would be drawn as ANSI31.
if let Some(up) = str_param(p, "pattern").map(str::to_ascii_uppercase)
&& cadcraft_doc::library::pattern(&up).is_none()
&& patfile::user_pattern(&up).is_none()
{
return Err(bad("hatch", format!("unknown pattern {up}")));
}
make(s, p, false)
}
fn run_gradient(s: &mut Session, p: &Value) -> Result<Value> {
@@ -443,11 +475,27 @@ fn boundary_at(s: &mut Session, q: Vec2) -> Result<Vec<Handle>> {
Ok(out)
}
/// Most internal points one BOUNDARY call accepts (checked before the list is copied).
const MAX_BOUNDARY_POINTS: usize = 256;
/// Most polylines one BOUNDARY call may create. Each pick adds polylines the next pick traces
/// against, so this bounds the aggregate tracing work of the batch.
const MAX_BOUNDARY_OUTPUT: usize = 1024;
fn run_boundary(s: &mut Session, p: &Value) -> Result<Value> {
let raw = p.get("points").and_then(Value::as_array).ok_or_else(|| bad("boundary", "`points` is required"))?;
if raw.len() > MAX_BOUNDARY_POINTS {
return Err(bad("boundary", format!("at most {MAX_BOUNDARY_POINTS} points per call (got {})", raw.len())));
}
let pts = points_param(p, "points").ok_or_else(|| bad("boundary", "`points` is required"))?;
let before = s.state()?.doc.clone();
let mut out = Vec::new();
for q in pts {
out.extend(boundary_at(s, q)?.into_iter().map(|h| h.hex()));
if out.len() > MAX_BOUNDARY_OUTPUT {
// The refused call leaves no partial result behind.
s.state_mut()?.doc = before;
return Err(bad("boundary", format!("more than {MAX_BOUNDARY_OUTPUT} boundary polylines in one call; split the work")));
}
}
Ok(json!({ "handles": out, "message": format!("BOUNDARY created {} polyline(s)", out.len()) }))
}
@@ -750,6 +798,21 @@ mod tests {
assert!(hatch.gradient.is_none());
}
#[test]
fn hatchedit_solid_replaces_gradient() {
let mut s = Session::new();
s.execute("rectang", &json!({ "p1": [0, 0], "p2": [10, 10] })).unwrap();
s.execute("gradient", &json!({ "points": [[5, 5]], "color1": "1", "color2": "3" })).unwrap();
let (h, before) = only_hatch(&s);
assert!(before.gradient.is_some());
s.execute("hatchedit", &json!({ "handles": [h.clone()], "pattern": "SOLID" })).unwrap();
let (h2, hatch) = only_hatch(&s);
assert_eq!(h2, h);
assert!(hatch.solid);
assert!(hatch.gradient.is_none(), "SOLID is a plain fill, not the gradient");
assert_eq!(hatch.loops, before.loops);
}
#[test]
fn the_pattern_picker_is_offered_and_its_choice_sets_the_pattern() {
let mut s = Session::new();
@@ -777,6 +840,19 @@ mod tests {
assert!(s.current_prompt().unwrap().picker.is_none());
}
#[test]
fn json_hatch_refuses_an_undefined_pattern() {
let mut s = Session::new();
s.execute("rectang", &json!({ "p1": [0, 0], "p2": [10, 10] })).unwrap();
let e = s.execute("hatch", &json!({ "points": [[5, 5]], "pattern": "NOPE" })).unwrap_err();
assert!(e.to_string().contains("unknown pattern NOPE"), "{e}");
assert_eq!(s.doc().unwrap().model.len(), 1, "only the rectangle");
for name in ["ansi31", "SOLID"] {
s.execute("hatch", &json!({ "points": [[5, 5]], "pattern": name })).unwrap();
}
assert_eq!(s.doc().unwrap().model.len(), 3);
}
#[test]
fn pattern_choices_list_the_library_with_its_lines() {
let all = pattern_choices();
+107 -5
View File
@@ -2,14 +2,15 @@
//! associativity (unlink, or link to boundary objects), separate hatches and recreated
//! boundaries of existing hatches.
use cadcraft_doc::{Drawing, EntityKind, Handle, Hatch, HatchLoop, entity_bounds};
use cadcraft_color::Color;
use cadcraft_doc::{Drawing, EntityKind, Gradient, Handle, Hatch, HatchLoop, entity_bounds};
use cadcraft_geom::{Bounds2, Polyline, Vec2, point_in_polygon};
use serde_json::{Value, json};
use super::assoc::{detach, same_shape};
use super::{ORIGIN_KW, OriginMode, closed_loop, island_style};
use super::{GRADIENT_NAMES, ORIGIN_KW, OriginMode, closed_loop, gradient_name, island_style};
use crate::cmd::helpers::lwpoly;
use crate::cmd::{bad, bool_or, point_param, str_param, targets};
use crate::cmd::{bad, bool_or, point_param, size_param, str_param, targets};
use crate::{Accept, Input, Interactive, Picker, Prompt, Result, Session, Step};
/// Upper bound on hatches edited by one call.
@@ -145,9 +146,10 @@ fn properties(h: &mut Hatch, p: &Value) {
if let Some(v) = str_param(p, "pattern").map(str::trim).filter(|v| !v.is_empty()) {
h.pattern = v.to_ascii_uppercase();
h.solid = h.pattern == "SOLID";
// A named pattern, SOLID included, replaces the gradient fill; the renderer draws the
// gradient first when it is set.
h.gradient = None;
if !h.solid {
// A named pattern replaces the gradient fill; the renderer draws the gradient first when it is set.
h.gradient = None;
// A library pattern is drawn from the library; a loaded .pat pattern brings its lines;
// any other name is the hatch's own and keeps its definition.
if cadcraft_doc::library::pattern(&h.pattern).is_some() {
@@ -165,7 +167,100 @@ fn properties(h: &mut Hatch, p: &Value) {
}
}
/// HATCHEDIT's `gradient`: the fields to change, all checked before any hatch is touched.
struct GradientEdit {
name: Option<&'static str>,
color1: Option<Color>,
color2: Option<Color>,
/// Radians.
angle: Option<f64>,
centered: Option<bool>,
}
impl GradientEdit {
/// Make `h` a gradient fill (from the default LINEAR blue-to-white one when it has none) and
/// set the given fields.
fn apply(&self, h: &mut Hatch) {
let mut g = h.gradient.clone().unwrap_or(Gradient {
name: "LINEAR".into(),
color1: Color::Index(5),
color2: Color::Index(7),
angle: 0.0,
centered: true,
});
if let Some(n) = self.name {
g.name = n.into();
}
if let Some(c) = self.color1 {
g.color1 = c;
}
if let Some(c) = self.color2 {
g.color2 = c;
}
if let Some(a) = self.angle {
g.angle = a;
}
if let Some(c) = self.centered {
g.centered = c;
}
h.gradient = Some(g);
h.pattern = "SOLID".into();
h.solid = true;
h.pattern_lines.clear();
}
}
fn gradient_edit(p: &Value) -> Result<Option<GradientEdit>> {
let g = match p.get("gradient") {
None | Some(Value::Null) => return Ok(None),
Some(Value::Object(g)) => g,
Some(_) => return Err(bad("hatchedit", "`gradient` is an object {name?, color1?, color2?, angle?, centered?}")),
};
let field = |k: &str| g.get(k).filter(|v| !v.is_null());
let name = match field("name") {
None => None,
Some(v) => Some(
v.as_str()
.and_then(gradient_name)
.ok_or_else(|| bad("hatchedit", format!("unknown gradient `name`; use one of {}", GRADIENT_NAMES.join(", "))))?,
),
};
let color = |k: &str| -> Result<Option<Color>> {
match field(k) {
None => Ok(None),
Some(v) => v
.as_str()
.and_then(Color::parse)
.map(Some)
.ok_or_else(|| bad("hatchedit", format!("`gradient.{k}` is a colour such as \"5\", \"red\" or \"255,128,0\""))),
}
};
let angle = match field("angle") {
None => None,
Some(v) => Some(
v.as_f64().map(f64::to_radians).filter(|a| a.is_finite()).ok_or_else(|| bad("hatchedit", "`gradient.angle` is a number of degrees"))?,
),
};
let centered = match field("centered") {
None => None,
Some(v) => Some(v.as_bool().ok_or_else(|| bad("hatchedit", "`gradient.centered` is true or false"))?),
};
Ok(Some(GradientEdit { name, color1: color("color1")?, color2: color("color2")?, angle, centered }))
}
/// An explicit `scale` must be positive and an explicit `angle` a number, checked before anything
/// is edited so a refused request changes nothing (absent and null leave them as they are).
fn check_numbers(p: &Value) -> Result<()> {
size_param("hatchedit", p, "scale", false)?;
match p.get("angle") {
None | Some(Value::Null) => Ok(()),
Some(v) if v.as_f64().is_some_and(f64::is_finite) => Ok(()),
Some(_) => Err(bad("hatchedit", "`angle` must be a number of degrees")),
}
}
pub(super) fn run_hatchedit(s: &mut Session, p: &Value) -> Result<Value> {
check_numbers(p)?;
let hs = hatches(s, p)?;
let style = match str_param(p, "style") {
Some(t) => Some(island_style(t).ok_or_else(|| bad("hatchedit", "`style` is \"normal\", \"outer\" or \"ignore\""))?),
@@ -185,6 +280,10 @@ pub(super) fn run_hatchedit(s: &mut Session, p: &Value) -> Result<Value> {
}
Some(_) => return Err(bad("hatchedit", "`recreate` is \"polyline\"")),
};
let grad = gradient_edit(p)?;
if grad.is_some() && str_param(p, "pattern").map(|v| v.trim().to_ascii_uppercase()).is_some_and(|v| !v.is_empty() && v != "SOLID") {
return Err(bad("hatchedit", "give `pattern` or `gradient`, not both"));
}
// Like the prompt: a pattern must be one CADCraft defines, SOLID, or the hatches' own (a
// pattern read from a file keeps its definition).
if let Some(up) = str_param(p, "pattern").map(|v| v.trim().to_ascii_uppercase()).filter(|v| !v.is_empty())
@@ -210,6 +309,9 @@ pub(super) fn run_hatchedit(s: &mut Session, p: &Value) -> Result<Value> {
for handle in &hs {
let Some(EntityKind::Hatch(mut h)) = s.doc()?.entity(*handle).map(|e| e.kind.clone()) else { continue };
properties(&mut h, p);
if let Some(g) = &grad {
g.apply(&mut h);
}
if let Some(st) = style {
h.style = st;
}
+224
View File
@@ -0,0 +1,224 @@
//! HATCHPICK: the Hatch and Gradient dialog's picks (internal points, boundary objects, a pattern
//! origin, or the hatches to edit), kept in the session for the dialog to take. It never changes
//! the drawing.
use cadcraft_doc::{EntityKind, Handle, HatchLoop};
use cadcraft_geom::Vec2;
use serde_json::{Value, json};
use super::{HatchSettings, closed_loop, loops_at, loops_from_handles};
use crate::cmd::{bad, points_param, targets};
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
/// Upper bound on the points and objects one HATCHPICK keeps.
pub(crate) const MAX_PICKS: usize = 1000;
/// The first prompt's keywords: what to pick.
const MODES: [&str; 4] = ["Points", "Objects", "oRigin", "Hatch"];
/// What HATCHPICK picks.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum PickMode {
/// Internal points, each inside a closed boundary.
#[default]
Points,
/// Closed boundary objects.
Objects,
/// One point: the pattern origin.
Origin,
/// Hatches to edit.
Hatch,
}
/// What the last HATCHPICK kept ([`Session::hatch_picks`]).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct HatchPicks {
pub mode: PickMode,
pub points: Vec<Vec2>,
/// Closed boundary objects (Objects) or hatches (Hatch).
pub handles: Vec<Handle>,
/// The pattern origin (Origin).
pub origin: Option<Vec2>,
}
fn mode_named(t: &str) -> Option<PickMode> {
Some(match t.trim().to_ascii_lowercase().as_str() {
"points" | "p" => PickMode::Points,
"objects" | "o" => PickMode::Objects,
"origin" | "r" => PickMode::Origin,
"hatch" | "h" => PickMode::Hatch,
_ => return None,
})
}
/// JSON form: `{points?, handles?}`, checked as the prompts check them.
pub(super) fn run_hatchpick(s: &mut Session, p: &Value) -> Result<Value> {
let given = |k: &str| p.get(k).is_some_and(|v| !v.is_null());
if !given("points") && !given("handles") {
return Err(bad("hatchpick", "give internal `points` or boundary `handles`"));
}
let mut picks = HatchPicks::default();
if given("points") {
let pts = points_param(p, "points").ok_or_else(|| bad("hatchpick", "`points` is an array of [x, y]"))?;
if pts.len() > MAX_PICKS {
return Err(bad("hatchpick", format!("at most {MAX_PICKS} points")));
}
for q in pts {
loops_at(s, q).map_err(|_| bad("hatchpick", format!("no closed boundary around [{}, {}]", q.x, q.y)))?;
picks.points.push(q);
}
}
if given("handles") {
let hs = targets(s, p)?;
if hs.len() > MAX_PICKS {
return Err(bad("hatchpick", format!("at most {MAX_PICKS} objects")));
}
let d = s.doc()?;
for h in hs {
match d.entity(h) {
Some(e) if closed_loop(d, e).is_some() => picks.handles.push(h),
_ => return Err(bad("hatchpick", format!("{} is not a closed object", h.hex()))),
}
}
if picks.points.is_empty() {
picks.mode = PickMode::Objects;
}
}
let out = json!({
"points": picks.points.iter().map(|q| [q.x, q.y]).collect::<Vec<_>>(),
"handles": picks.handles.iter().map(|h| h.hex()).collect::<Vec<_>>(),
});
s.hatch_picks = Some(picks);
Ok(out)
}
/// One pick, so Undo can drop it.
enum Pick {
Point(Vec2, Vec<HatchLoop>),
Objects(Vec<Handle>, Vec<HatchLoop>),
}
#[derive(Default)]
pub(super) struct HatchPickM {
mode: Option<PickMode>,
picks: Vec<Pick>,
}
impl HatchPickM {
fn count(&self) -> usize {
self.picks
.iter()
.map(|p| match p {
Pick::Point(..) => 1,
Pick::Objects(hs, _) => hs.len(),
})
.sum()
}
fn keep(&self, s: &mut Session, mode: PickMode) {
let mut out = HatchPicks { mode, ..HatchPicks::default() };
for p in &self.picks {
match p {
Pick::Point(q, _) => out.points.push(*q),
Pick::Objects(hs, _) => out.handles.extend(hs.iter().copied()),
}
}
s.hatch_picks = Some(out);
}
}
impl Interactive for HatchPickM {
fn name(&self) -> &'static str {
"HATCHPICK"
}
fn prompt(&self, _s: &Session) -> Prompt {
match self.mode {
None => Prompt::new("Enter what to pick", Accept::TEXT).kw(&MODES),
Some(PickMode::Points) => Prompt::new("Pick internal point", Accept::POINT).kw(&["Undo"]),
Some(PickMode::Objects) => Prompt::new("Select objects", Accept::SELECT).kw(&["Undo"]),
Some(PickMode::Origin) => Prompt::new("Specify the pattern origin", Accept::POINT),
Some(PickMode::Hatch) => Prompt::new("Select hatch object", Accept::SELECT),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
let Some(mode) = self.mode else {
match &i {
Input::Keyword(t) | Input::Text(t) => self.mode = mode_named(t),
Input::Enter | Input::Cancel => return Ok(Step::Cancel),
_ => {}
}
return Ok(Step::Continue);
};
match (mode, i) {
(_, Input::Cancel) => Ok(Step::Cancel),
(PickMode::Points | PickMode::Objects, Input::Keyword(k)) if k == "Undo" => {
if self.picks.pop().is_none() {
s.echo("Nothing to undo.");
}
Ok(Step::Continue)
}
(PickMode::Points | PickMode::Objects, Input::Enter) => {
self.keep(s, mode);
Ok(Step::Done)
}
(PickMode::Points, Input::Point(q)) => {
if self.count() >= MAX_PICKS {
s.echo(format!("At most {MAX_PICKS} picks."));
return Ok(Step::Continue);
}
match loops_at(s, q) {
Ok(l) => {
s.echo(format!("Analyzing the selected data... {} loop(s)", l.len()));
self.picks.push(Pick::Point(q, l));
}
Err(e) => s.echo(e.to_string()),
}
Ok(Step::Continue)
}
(PickMode::Objects, Input::Pick(hs)) => {
let room = MAX_PICKS.saturating_sub(self.count());
let closed: Vec<Handle> = {
let d = s.doc()?;
hs.iter().copied().filter(|h| d.entity(*h).is_some_and(|e| closed_loop(d, e).is_some())).take(room).collect()
};
let others = hs.len().saturating_sub(closed.len());
if !closed.is_empty() {
let loops = loops_from_handles(s, &closed)?;
self.picks.push(Pick::Objects(closed.clone(), loops));
}
s.echo(if others == 0 { format!("{} found", closed.len()) } else { format!("{} found, {others} not closed", closed.len()) });
Ok(Step::Continue)
}
(PickMode::Origin, Input::Point(q)) => {
s.hatch_picks = Some(HatchPicks { mode, origin: Some(q), ..HatchPicks::default() });
Ok(Step::Done)
}
(PickMode::Hatch, Input::Pick(hs)) => {
let hatches: Vec<Handle> = {
let d = s.doc()?;
hs.into_iter().filter(|h| matches!(d.entity(*h).map(|e| &e.kind), Some(EntityKind::Hatch(_)))).take(MAX_PICKS).collect()
};
if hatches.is_empty() {
s.echo("No hatch selected.");
return Ok(Step::Continue);
}
s.hatch_picks = Some(HatchPicks { mode, handles: hatches, ..HatchPicks::default() });
Ok(Step::Done)
}
(PickMode::Origin | PickMode::Hatch, Input::Enter) => Ok(Step::Done),
_ => Ok(Step::Continue),
}
}
fn preview(&self, s: &Session, _c: Vec2) -> Vec<EntityKind> {
let loops: Vec<HatchLoop> = self
.picks
.iter()
.flat_map(|p| match p {
Pick::Point(_, l) | Pick::Objects(_, l) => l.iter().cloned(),
})
.collect();
if loops.is_empty() {
return Vec::new();
}
vec![HatchSettings::from(s, &Value::Null, false).hatch(loops)]
}
}
+79 -1
View File
@@ -43,6 +43,9 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("entities", "Query Entities", run_entities)
.params("{type?, layer?, window?: [[x,y],[x,y]], crossing?: bool (default true; false = entirely inside), limit?, offset?} → {count (all matches), returned, offset, entities}")
.noundo(),
CommandSpec::new("texts", "Query Texts", run_texts)
.params("{contains?: text (any case), layer?, window?: [[x,y],[x,y]] (by insertion point), limit? (default 200), offset?} → {count (all matches), returned, offset, texts: [{handle, type, layer, text, at, height, rotation (degrees), style, tag? (attributes)}]}: the readable text of TEXT, MTEXT (formatting removed), block attributes and multileaders")
.noundo(),
CommandSpec::new("countlist", "Count List", count::run_countlist)
.params("{space?: all (default)|current|model|<layout>, nested?: bool} → per-block counts, as COUNT")
.noundo(),
@@ -283,7 +286,8 @@ fn run_inspect(s: &mut Session, p: &Value) -> Result<Value> {
"title": st.title,
"path": st.path,
"dirty": st.is_dirty(),
"space": match &space { cadcraft_doc::Space::Model => "Model".to_string(), cadcraft_doc::Space::Paper(n) => n.clone() },
"blockEditor": st.block_editor(),
"space": space.tab_name(),
"entityCount": d.entity_count(),
"counts": counts,
"extents": if ext.is_empty() { Value::Null } else { json!([[ext.min.x, ext.min.y], [ext.max.x, ext.max.y]]) },
@@ -297,6 +301,8 @@ fn run_inspect(s: &mut Session, p: &Value) -> Result<Value> {
"selection": st.selection.iter().map(|h| h.hex()).collect::<Vec<_>>(),
"undo": st.undo.iter().rev().take(20).map(|u| u.label.clone()).collect::<Vec<_>>(),
"view": { "center": [st.view().center.x, st.view().center.y], "height": st.view().height },
// Records of the file the drawing came from that the reader could not place.
"skipped": { "count": d.skipped_records.values().fold(0usize, |a, n| a.saturating_add(*n)), "sections": d.skipped_records },
});
if bool_or(p, "entities", true)
&& let (Some(o), Some(store)) = (out.as_object_mut(), d.space(&space))
@@ -346,6 +352,78 @@ fn run_entities(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "count": matches.len(), "returned": page.len(), "offset": offset, "entities": page }))
}
/// TEXTS: the readable text of the space's texts, multiline texts, block attributes and
/// multileaders, with where they are, filtered by content, layer and a window around their
/// insertion points. Read-only (FIND selects and can replace; this only reports).
fn run_texts(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "texts";
let given = |k: &str| p.get(k).filter(|v| !v.is_null());
let text = |k: &str| -> Result<Option<String>> {
given(k).map(|v| v.as_str().map(str::to_lowercase).ok_or_else(|| bad(CMD, format!("`{k}` must be a string")))).transpose()
};
let count = |k: &str, default: usize| -> Result<usize> {
given(k).map_or(Ok(default), |v| {
v.as_u64().map(|n| usize::try_from(n).unwrap_or(usize::MAX)).ok_or_else(|| bad(CMD, format!("`{k}` must be a whole number, 0 or more")))
})
};
let contains = text("contains")?;
let layer = text("layer")?;
let window = given("window")
.map(|w| {
let corners: Option<Vec<Vec2>> = w.as_array().filter(|a| a.len() == 2).and_then(|a| a.iter().map(point_value).collect());
match corners.as_deref() {
Some([a, b]) => Ok(cadcraft_geom::Bounds2::new(*a, *b)),
_ => Err(bad(CMD, "`window` must be two corner points [[x0, y0], [x1, y1]]")),
}
})
.transpose()?;
let limit = count("limit", 200)?;
let offset = count("offset", 0)?;
let st = s.state()?;
let Some(store) = st.doc.space(&st.space) else { return Ok(json!({ "count": 0, "returned": 0, "offset": offset, "texts": [] })) };
let mut found: Vec<Value> = Vec::new();
for e in store.iter() {
if layer.as_ref().is_some_and(|l| e.common.layer.to_lowercase() != *l) {
continue;
}
let mut push = |ty: &str, value: String, at: cadcraft_geom::Vec3, height: f64, rotation: f64, style: &str, tag: Option<&str>| {
let value = value.trim().to_string();
if value.is_empty()
|| contains.as_ref().is_some_and(|c| !value.to_lowercase().contains(c.as_str()))
|| window.as_ref().is_some_and(|w| !w.contains(Vec2::new(at.x, at.y)))
{
return;
}
let mut v = json!({
"handle": e.handle.hex(), "type": ty, "layer": e.common.layer, "text": value,
"at": [at.x, at.y], "height": height, "rotation": rotation.to_degrees(), "style": style,
});
if let (Some(t), Some(o)) = (tag, v.as_object_mut()) {
o.insert("tag".into(), json!(t));
}
found.push(v);
};
match &e.kind {
EntityKind::Text(t) => push("Text", t.value.clone(), t.insert, t.height, t.rotation, &t.style, None),
EntityKind::MText(t) => push("MText", cadcraft_fonts::plain_mtext(&t.contents), t.insert, t.height, t.rotation, &t.style, None),
EntityKind::Insert(i) => {
for a in i.attribs.iter().filter(|a| !a.invisible) {
push("Attribute", a.text.value.clone(), a.text.insert, a.text.height, a.text.rotation, &a.text.style, Some(&a.tag));
}
}
EntityKind::MLeader(m) => {
if let Some(t) = &m.text {
push("MLeader", cadcraft_fonts::plain_mtext(&t.contents), t.insert, t.height, t.rotation, &t.style, None);
}
}
_ => {}
}
}
let n = found.len();
let page: Vec<Value> = found.into_iter().skip(offset).take(limit).collect();
Ok(json!({ "count": n, "returned": page.len(), "offset": offset, "texts": page }))
}
mod count;
#[derive(Default)]
+1 -4
View File
@@ -74,10 +74,7 @@ impl Scope {
}
/// The top-level objects in scope, each with the name of its space.
fn objects<'a>(&self, d: &'a Drawing) -> Vec<(String, &'a std::sync::Arc<Entity>)> {
let name = |sp: &Space| match sp {
Space::Model => "Model".to_string(),
Space::Paper(n) => n.clone(),
};
let name = |sp: &Space| sp.tab_name().to_string();
let mut out = Vec::new();
let mut add = |sp: &Space| {
if let Some(store) = d.space(sp) {
+53 -14
View File
@@ -74,17 +74,17 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("pagesetup", "Page Setup Manager...", run_pagesetup)
.menu(&["File", "Page Setup Manager..."])
.params(
"{layout?: current, device?: plotter name (PLOTTERMANAGER) | \"None\", paper?: the device's paper | \"A4\"|\"A3\"|\"Letter\"|\"ANSI B\"|…, width?, height? (mm), landscape?, margins?: [l,b,r,t] mm, lineweights?, plotArea?, scale?, scaleToFit?, center?, plotStyleTable?: \"monochrome.ctb\"|\"grayscale.ctb\"|\"default.ctb\"|\"None\"|…, displayPlotStyles?: bool}",
"{layout?: current, device?: plotter name (PLOTTERMANAGER) | \"None\", paper?: the device's paper | \"A4\"|\"A3\"|\"Letter\"|\"ANSI B\"|…, width?, height? (mm), landscape?, margins?: [l,b,r,t] mm, lineweights?, plotArea?, scale?, scaleToFit?, center?, plotStyleTable?: \"monochrome.ctb\"|\"grayscale.ctb\"|\"default.ctb\"|\"None\"|…, displayPlotStyles?: bool, plotTransparency?: bool}",
),
CommandSpec::new("plot", "Print...", |s, p| run_plot(s, &super::plotters::plot_options(s, p, "plot")?, "plot"))
.menu(&["File", "Print..."])
.key("Cmd+P")
.alias(&["print"])
.params("{path? (else returns base64 `data`), layout?: current|\"Model\", paper?, landscape?, fit?: bool, scale?: number | \"1:50\", lineweights?: bool, plotArea?: extents|display|limits|window (Model) | layout, window?: [[x1,y1],[x2,y2]]} (typed or from the menu: the Plot dialog)")
.params("{path? (else returns base64 `data`), layout?: current|\"Model\", paper?, landscape?, fit?: bool, scale?: number | \"1:50\", center?: bool (Model, default true), offset?: [x,y] mm (Model, when center is false; each within ±100000), lineweights?: bool, plotTransparency?: bool (default: the page setup's; PLOTTRANSPARENCYOVERRIDE 0/2 overrides it), plotArea?: extents|display|limits|window (Model) | layout, window?: [[x1,y1],[x2,y2]]} (typed or from the menu: the Plot dialog)")
.noundo(),
CommandSpec::new("exportpdf", "Export to PDF...", |s, p| run_plot(s, &super::plotters::plot_options(s, p, "exportpdf")?, "exportpdf"))
.menu(&["File", "Export to PDF..."])
.params("{path? (else returns base64 `data`), layout?, paper?, landscape?, fit?, scale?, lineweights?, plotArea?, window?} (as PLOT)")
.params("{path? (else returns base64 `data`), layout?, paper?, landscape?, fit?, scale?, center?: bool (Model, default true), offset?: [x,y] mm (Model, when center is false; each within ±100000), lineweights?, plotTransparency?, plotArea?, window?} (as PLOT)")
.noundo(),
CommandSpec::new("mspace", "Model Space (in viewport)", run_mspace)
.alias(&["ms"])
@@ -136,6 +136,7 @@ fn run_mspace(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.state_mut()?;
st.mspace = Some(pick);
st.selection.clear();
s.pending_window = None;
s.echo("MSPACE");
Ok(json!({ "viewport": pick.hex() }))
}
@@ -147,6 +148,7 @@ fn run_pspace(s: &mut Session, _p: &Value) -> Result<Value> {
let st = s.state_mut()?;
st.mspace = None;
st.selection.clear();
s.pending_window = None;
Ok(json!({ "space": "paper" }))
}
@@ -260,10 +262,10 @@ fn run_delete(s: &mut Session, p: &Value) -> Result<Value> {
}
fn run_rename(s: &mut Session, p: &Value) -> Result<Value> {
let from = match (str_param(p, "from"), s.layout_space()) {
let from = match (str_param(p, "from"), s.layout_space().layout()) {
(Some(f), _) => f.to_string(),
(None, Space::Paper(n)) => n,
(None, Space::Model) => return Err(bad("layout.rename", "`from` is required in the Model tab")),
(None, Some(n)) => n.to_string(),
(None, None) => return Err(bad("layout.rename", "`from` is required in the Model tab")),
};
let from = layout_name("layout.rename", s.doc()?, &from)?;
let to = str_param(p, "to").ok_or_else(|| bad("layout.rename", "`to` is required"))?;
@@ -634,12 +636,12 @@ fn run_pagesetup(s: &mut Session, p: &Value) -> Result<Value> {
"center",
"plotStyleTable",
"displayPlotStyles",
"plotTransparency",
];
let changes = keys.iter().any(|k| p.get(k).is_some());
let name = match (str_param(p, "layout"), s.space()) {
(Some(n), _) => Some(layout_name(cmd, s.doc()?, n)?),
(None, Space::Paper(n)) => Some(n),
(None, Space::Model) => None,
let name = match str_param(p, "layout") {
Some(n) => Some(layout_name(cmd, s.doc()?, n)?),
None => s.space().layout().map(str::to_string),
};
let Some(name) = name else {
if changes {
@@ -716,6 +718,9 @@ fn run_pagesetup(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(v) = p.get("displayPlotStyles").and_then(Value::as_bool) {
page.show_plot_styles = v;
}
if let Some(v) = p.get("plotTransparency").and_then(Value::as_bool) {
page.plot_transparency = v;
}
if changes {
if let Some(l) = s.doc_mut()?.layouts.iter_mut().find(|l| l.name == name) {
l.page = page.clone();
@@ -774,6 +779,28 @@ fn run_plot(s: &mut Session, p: &Value, cmd: &str) -> Result<Value> {
None => s.space(),
};
let mut opts = if p.is_object() { p.clone() } else { json!({}) };
if let Some(raw) = p.get("offset") {
let values = raw
.as_array()
.filter(|values| values.len() == 2)
.ok_or_else(|| bad(cmd, "`offset` must contain exactly two finite millimetre values within ±100000"))?;
let x = values.first().and_then(Value::as_f64);
let y = values.get(1).and_then(Value::as_f64);
if !x.zip(y).is_some_and(|(x, y)| {
x.is_finite()
&& y.is_finite()
&& x.abs() <= cadcraft_render::paper::MAX_PLOT_OFFSET_MM
&& y.abs() <= cadcraft_render::paper::MAX_PLOT_OFFSET_MM
}) {
return Err(bad(cmd, "`offset` must contain exactly two finite millimetre values within ±100000"));
}
}
if matches!(space, Space::Model)
&& opts.get("output").and_then(Value::as_str).is_some_and(|output| output.eq_ignore_ascii_case("svg"))
&& opts.get("center").and_then(Value::as_bool) == Some(false)
{
return Err(bad(cmd, "model-space plot offsets are not supported for SVG; choose PDF/PNG or center the plot"));
}
if let Some(o) = opts.as_object_mut() {
if let Some(v) = p.get("scale") {
let k = parse_scale(v).ok_or_else(|| bad(cmd, "`scale` must be positive: paper units per drawing unit, or a ratio such as \"1:50\""))?;
@@ -782,12 +809,16 @@ fn run_plot(s: &mut Session, p: &Value, cmd: &str) -> Result<Value> {
if let Some(w) = plot_window(s, p, cmd, &space)? {
o.insert("window".into(), json!([[w.min.x, w.min.y], [w.max.x, w.max.y]]));
}
// Without one, the plotter uses each sheet's page setup.
match super::plotters::plot_transparency(s, p) {
Some(t) => o.insert("plotTransparency".into(), json!(t)),
None => o.remove("plotTransparency"),
};
}
let bytes = hook(s.doc()?, &space, &opts).map_err(|e| bad(cmd, e))?;
let layout = match (&sheets, &space) {
(Some(names), _) => names.first().cloned().unwrap_or_default(),
(None, Space::Model) => "Model".to_string(),
(None, Space::Paper(n)) => n.clone(),
let layout = match &sheets {
Some(names) => names.first().cloned().unwrap_or_default(),
None => space.tab_name().to_string(),
};
let pages = sheets.as_ref().map_or(1, Vec::len);
match str_param(p, "path") {
@@ -1055,12 +1086,20 @@ mod tests {
assert!(text.starts_with("%PDF") && text.contains("Paper(\"Layout1\")") && text.contains("A4"));
let r = s.execute("exportpdf", &json!({})).unwrap();
assert_eq!(r["layout"], "Model");
let baseline = String::from_utf8(super::super::file::base64_decode(r["data"].as_str().unwrap()).unwrap()).unwrap();
assert!(!baseline.contains("\"center\"") && !baseline.contains("\"offset\""), "omitted options remain omitted: {baseline}");
assert!(s.execute("plot", &json!({"layout": "Nope"})).is_err());
// Plot area and a ratio scale reach the plotter as a model-space window and a number.
let opts = |r: Value| String::from_utf8(super::super::file::base64_decode(r["data"].as_str().unwrap()).unwrap()).unwrap();
let p = json!({"layout": "Model", "plotArea": "window", "window": [[10, 0], [0, 5]], "fit": false, "scale": "1:2"});
let text = opts(s.execute("plot", &p).unwrap());
assert!(text.contains(r#""window":[[0.0,0.0],[10.0,5.0]]"#) && text.contains(r#""scale":0.5"#), "{text}");
let text = opts(s.execute("plot", &json!({"layout": "Model", "center": false, "offset": [12.5, -7.0]})).unwrap());
assert!(text.contains(r#""center":false"#) && text.contains(r#""offset":[12.5,-7.0]"#), "{text}");
for offset in [json!([1]), json!([1, 2, 3]), json!([f64::NAN, 0]), json!([100_001, 0])] {
assert!(s.execute("plot", &json!({"layout": "Model", "center": false, "offset": offset})).is_err());
}
assert!(s.execute("plot", &json!({"layout": "Model", "output": "svg", "center": false, "offset": [0, 0]})).is_err());
assert!(opts(s.execute("plot", &json!({"layout": "Model", "plotArea": "limits"})).unwrap()).contains(r#""window":[[0.0,0.0],"#));
assert!(s.execute("plot", &json!({"layout": "Model", "plotArea": "window"})).is_err(), "window needs corners");
assert!(s.execute("plot", &json!({"layout": "Layout1", "plotArea": "display"})).is_err(), "layouts plot their sheet");
+3 -12
View File
@@ -87,10 +87,7 @@ impl LayoutM {
let pats: Vec<&str> = pat.split(',').map(str::trim).filter(|p| !p.is_empty()).take(1000).collect();
let pats = if pats.is_empty() { vec!["*"] } else { pats };
let names: Vec<String> = ls.iter().filter(|l| pats.iter().any(|p| wildcard(p, &l.name))).map(|l| l.name.clone()).collect();
let current = match s.layout_space() {
Space::Paper(n) => n,
Space::Model => "Model".into(),
};
let current = s.layout_space().tab_name().to_string();
s.echo(format!("Active Layout: {current}"));
if names.is_empty() {
s.echo(format!("No layout matches \"{pat}\"."));
@@ -131,10 +128,7 @@ impl Interactive for LayoutM {
Stage::RenameFrom => with_cur(Prompt::new("Enter layout to rename", Accept::TEXT)),
Stage::RenameTo(_) => Prompt::new("Enter new layout name", Accept::TEXT),
Stage::Set => {
let tab = match s.layout_space() {
Space::Paper(n) => n,
Space::Model => "Model".into(),
};
let tab = s.layout_space().tab_name().to_string();
Prompt::new("Enter layout to make current", Accept::TEXT).default(tab)
}
Stage::List => Prompt::new("Enter layout name(s) to list", Accept::TEXT).default("*"),
@@ -199,10 +193,7 @@ impl Interactive for LayoutM {
Stage::Set => {
let n = match name {
Some(n) => n,
None => match s.layout_space() {
Space::Paper(n) => n,
Space::Model => "Model".into(),
},
None => s.layout_space().tab_name().to_string(),
};
let set = find_command("layout.set").ok_or_else(|| EngineError::UnknownCommand("layout.set".into()))?;
(set.run)(s, &json!({ "name": n })).map_err(retry)?;
+110
View File
@@ -0,0 +1,110 @@
//! AutoLISP commands: APPLOAD loads a `.lsp` file; `lisp` evaluates LISP for programmatic
//! callers (scripts, the control channel, MCP, the CLI).
use serde_json::{Value, json};
use super::{bad, str_param};
use crate::lisp::value::{Value as Lv, to_text};
use crate::{Accept, CommandSpec, EngineError, Input, Interactive, Prompt, Result, Session, Step};
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("appload", "Load Application...", run_appload)
.menu(&["Tools", "Load Application..."])
.alias(&["ap"])
.params("{path: .lsp file} | {name, data: base64 | text} → {value?, error?}")
.noundo()
.interactive(|_| Ok(Box::new(ApploadM))),
CommandSpec::new("lisp", "Evaluate LISP", run_lisp)
.params(
"{code: LISP source, inputs?: [answers to get… calls: numbers, strings, [x,y] points]} → {value (printed as prin1), output: lines printed, error?}",
)
.noundo(),
]
}
/// The text of a LISP file from `{path}` or `{name, data | text}`, and its name.
fn source(p: &Value) -> Result<(String, String)> {
if let Some(text) = str_param(p, "text") {
return Ok((str_param(p, "name").unwrap_or("untitled.lsp").to_string(), text.to_string()));
}
if let Some(data) = str_param(p, "data") {
let bytes = super::file::base64_decode(data).ok_or_else(|| bad("appload", "invalid base64"))?;
return Ok((str_param(p, "name").unwrap_or("untitled.lsp").to_string(), String::from_utf8_lossy(&bytes).into_owned()));
}
let path = str_param(p, "path").ok_or_else(|| bad("appload", "`path`, or `name` with `data` (base64) or `text`, is required"))?;
let text = crate::lisp::builtins::read_text_file(path).ok_or_else(|| bad("appload", format!("{path}: can't read the file")))?;
Ok((path.to_string(), text))
}
/// The result of a blocking LISP run as JSON, with the lines it printed.
fn report(s: &Session, from_log: usize, r: Option<std::result::Result<Lv, String>>) -> Value {
let output: Vec<String> = s.log.get(from_log..).map(|l| l.to_vec()).unwrap_or_default();
match r {
Some(Ok(v)) => json!({ "value": to_text(&v, true), "output": output }),
Some(Err(e)) => json!({ "error": e, "output": output }),
None => json!({ "output": output }),
}
}
fn run_appload(s: &mut Session, p: &Value) -> Result<Value> {
let (name, text) = source(p)?;
let from = s.log.len();
let r = s.lisp_load(&name, &text, true)?;
Ok(report(s, from, r))
}
/// A JSON input as a LISP value: numbers, strings, `[x, y(, z)]` points, null for Enter.
fn input_value(v: &Value) -> Lv {
match v {
Value::Array(a) if a.len() >= 2 && a.iter().all(Value::is_number) => Lv::point(&a.iter().filter_map(Value::as_f64).collect::<Vec<_>>()),
other => crate::lisp::drawing_from_json(other),
}
}
fn run_lisp(s: &mut Session, p: &Value) -> Result<Value> {
let code = str_param(p, "code").ok_or_else(|| bad("lisp", "`code` is required"))?;
let inputs = p.get("inputs").and_then(Value::as_array).map(|a| a.iter().map(input_value).collect()).unwrap_or_default();
let from = s.log.len();
let r = s.lisp_eval(code, false, true, inputs).map_err(|e| match e {
EngineError::Other(m) => bad("lisp", m),
e => e,
})?;
Ok(report(s, from, r))
}
/// APPLOAD typed or chosen from the menu: asks for the file by name.
struct ApploadM;
impl Interactive for ApploadM {
fn name(&self) -> &'static str {
"APPLOAD"
}
fn prompt(&self, _: &Session) -> Prompt {
Prompt::new("Enter name of LISP file to load", Accept::TEXT)
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
if matches!(i, Input::Cancel | Input::Enter) {
return Ok(Step::Cancel);
}
let Input::Text(t) = i else { return Ok(Step::Continue) };
let path = t.trim().trim_matches('"').to_string();
if path.is_empty() {
return Ok(Step::Cancel);
}
match source(&json!({ "path": path })) {
Ok((name, text)) => {
// The routine may wait for the user: it carries on after this command ends.
if let Err(e) = s.lisp_load(&name, &text, false) {
s.echo(e.to_string());
}
Ok(Step::Done)
}
Err(EngineError::BadParams { msg, .. }) => {
s.echo(msg);
Ok(Step::Done)
}
Err(e) => Err(e),
}
}
}
+780
View File
@@ -0,0 +1,780 @@
//! MLEADEREDIT (add / remove leaders), MLEADERALIGN and MLEADERCOLLECT.
//!
//! The model has one landing per multileader: its leaders are polylines from an arrowhead to that
//! landing, so "add leader" pushes one more arrowhead polyline and "remove leader" drops one.
use cadcraft_doc::{Entity, EntityKind, Handle, Insert, MLeader};
use cadcraft_geom::{Vec2, Vec3};
use serde_json::{Value, json};
use super::helpers::v3;
use super::machines::{SelOutcome, SelectPhase};
use super::*;
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
/// Most arrowheads added by one call, and most multileaders aligned / collected by one call.
const MAX_ITEMS: usize = 10_000;
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("mleaderedit", "Edit Multileader", run_edit)
.alias(&["mledit"])
.params("{handle, mode?: add|remove (default add), points?: [[x,y]...] (arrowheads to add; with remove, points picking the leaders to remove), index?: number|[number] (remove by position)}")
.interactive(|_| Ok(Box::new(EditM::new(None)))),
CommandSpec::new("mleaderedit.add", "Add Leader", |s, p| run_edit(s, &with_mode(p, "add")))
.menu(&["Modify", "Object", "Multileader", "Add Leader"])
.params("{handle, points: [[x,y]...] (arrowhead locations)}")
.interactive(|_| Ok(Box::new(EditM::new(Some(false))))),
CommandSpec::new("mleaderedit.remove", "Remove Leader", |s, p| run_edit(s, &with_mode(p, "remove")))
.menu(&["Modify", "Object", "Multileader", "Remove Leader"])
.params("{handle, points?: [[x,y]...] (pick the leaders), index?: number|[number]}")
.interactive(|_| Ok(Box::new(EditM::new(Some(true))))),
CommandSpec::new("mleaderalign", "Align Multileaders", run_align)
.menu(&["Modify", "Object", "Multileader", "Align"])
.alias(&["mleadera"])
.params("{handles?, option?: current|distribute|parallel|spacing (default current), spacing?: number, to?: hex (multileader to align to), direction?: [[x,y],[x,y]] | angle?: degrees, points?: [[x,y],[x,y]] (distribute: first and last position)}")
.interactive(|_| Ok(Box::new(AlignM::default()))),
CommandSpec::new("mleadercollect", "Collect Multileaders", run_collect)
.menu(&["Modify", "Object", "Multileader", "Collect"])
.alias(&["mleaderc"])
.params("{handles?, at: [x,y] (top left of the collected blocks), mode?: horizontal|vertical|wrap (default horizontal), width?: number (wrap), spacing?: number}")
.interactive(|_| Ok(Box::new(CollectM::default()))),
]
}
fn with_mode(p: &Value, mode: &str) -> Value {
let mut o = p.as_object().cloned().unwrap_or_default();
o.insert("mode".into(), json!(mode));
Value::Object(o)
}
fn handle_of(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)))
}
fn get_mleader(s: &Session, h: Handle) -> Option<MLeader> {
match s.doc().ok()?.entity(h).map(|e| &e.kind) {
Some(EntityKind::MLeader(m)) => Some(m.clone()),
_ => None,
}
}
fn locked(s: &Session, h: Handle) -> bool {
s.doc().ok().is_some_and(|d| d.entity(h).and_then(|e| d.layer(&e.common.layer)).is_some_and(|l| l.locked))
}
/// Keyword abbreviation test: "p" / "par" / "parallel" all match "parallel".
fn kw_is(input: &str, full: &str) -> bool {
let t = input.trim().to_ascii_lowercase();
!t.is_empty() && full.to_ascii_lowercase().starts_with(&t)
}
fn seg_dist(p: Vec2, a: Vec2, b: Vec2) -> f64 {
let ab = b - a;
let l2 = ab.dot(ab);
let t = if l2 > 0.0 { ((p - a).dot(ab) / l2).clamp(0.0, 1.0) } else { 0.0 };
(p - (a + ab * t)).len()
}
/// The leader of `m` nearest `p`.
fn nearest_leader(m: &MLeader, p: Vec2) -> Option<usize> {
let mut best: Option<(usize, f64)> = None;
for (i, l) in m.leaders.iter().enumerate() {
let pts: Vec<Vec2> = l.iter().map(|v| v.xy()).chain(std::iter::once(m.landing.xy())).collect();
let d = pts.windows(2).filter_map(|w| Some(seg_dist(p, *w.first()?, *w.get(1)?))).fold(f64::INFINITY, f64::min);
if d.is_finite() && best.is_none_or(|(_, bd)| d < bd) {
best = Some((i, d));
}
}
best.map(|(i, _)| i)
}
// ---------------------------------------------------------------- MLEADEREDIT
fn add_leaders(s: &mut Session, h: Handle, pts: &[Vec2]) -> Result<()> {
get_mleader(s, h).ok_or_else(|| bad("mleaderedit", "`handle` is not a multileader"))?;
if locked(s, h) {
return Err(bad("mleaderedit", "the multileader is on a locked layer"));
}
s.doc_mut()?.modify_entity(h, |e| {
if let EntityKind::MLeader(m) = &mut e.kind {
m.leaders.extend(pts.iter().map(|p| vec![v3(*p)]));
}
})?;
Ok(())
}
fn remove_leaders(s: &mut Session, h: Handle, idx: &[usize]) -> Result<()> {
let m = get_mleader(s, h).ok_or_else(|| bad("mleaderedit", "`handle` is not a multileader"))?;
if locked(s, h) {
return Err(bad("mleaderedit", "the multileader is on a locked layer"));
}
if let Some(i) = idx.iter().find(|i| **i >= m.leaders.len()) {
return Err(bad("mleaderedit", format!("no leader {i} (the multileader has {})", m.leaders.len())));
}
s.doc_mut()?.modify_entity(h, |e| {
if let EntityKind::MLeader(m) = &mut e.kind {
let mut sorted: Vec<usize> = idx.to_vec();
sorted.sort_unstable();
sorted.dedup();
for i in sorted.into_iter().rev() {
if i < m.leaders.len() {
m.leaders.remove(i);
}
}
}
})?;
Ok(())
}
fn run_edit(s: &mut Session, p: &Value) -> Result<Value> {
let h = match handle_of(p, "handle") {
Some(h) => h,
None => targets(s, p)?.into_iter().next().ok_or_else(|| bad("mleaderedit", "`handle` (a multileader) is required"))?,
};
let pts = match p.get("points") {
None | Some(Value::Null) => Vec::new(),
Some(_) => points_param(p, "points").ok_or_else(|| bad("mleaderedit", "`points` must be [[x,y]...]"))?,
};
if pts.len() > MAX_ITEMS {
return Err(bad("mleaderedit", "too many points"));
}
match str_param(p, "mode").map(str::to_ascii_lowercase).as_deref() {
None | Some("add") => {
if pts.is_empty() {
return Err(bad("mleaderedit", "`points` (arrowhead locations) is required"));
}
add_leaders(s, h, &pts)?;
Ok(json!({ "added": pts.len() }))
}
Some("remove") => {
let m = get_mleader(s, h).ok_or_else(|| bad("mleaderedit", "`handle` is not a multileader"))?;
let mut idx: Vec<usize> = Vec::new();
match p.get("index") {
None | Some(Value::Null) => {}
Some(Value::Array(a)) => {
for v in a {
idx.push(
v.as_u64()
.and_then(|n| usize::try_from(n).ok())
.ok_or_else(|| bad("mleaderedit", "`index` must be non-negative whole numbers"))?,
);
}
}
Some(v) => idx.push(
v.as_u64()
.and_then(|n| usize::try_from(n).ok())
.ok_or_else(|| bad("mleaderedit", "`index` must be a non-negative whole number"))?,
),
}
idx.extend(pts.iter().filter_map(|q| nearest_leader(&m, *q)));
if idx.is_empty() {
return Err(bad("mleaderedit", "`points` or `index` (the leaders to remove) is required"));
}
let n = {
let mut u = idx.clone();
u.sort_unstable();
u.dedup();
u.len()
};
remove_leaders(s, h, &idx)?;
Ok(json!({ "removed": n }))
}
Some(_) => Err(bad("mleaderedit", "`mode` must be \"add\" or \"remove\"")),
}
}
/// MLEADEREDIT at the command line: select a multileader, then arrowhead locations to add (or
/// leaders to remove by picking them), Enter to finish.
struct EditM {
remove: bool,
fixed: bool,
target: Option<Handle>,
sel: SelectPhase,
changed: usize,
}
impl EditM {
fn new(mode: Option<bool>) -> Self {
EditM { remove: mode.unwrap_or(false), fixed: mode.is_some(), target: None, sel: SelectPhase::single(), changed: 0 }
}
}
impl Interactive for EditM {
fn name(&self) -> &'static str {
"MLEADEREDIT"
}
fn prompt(&self, _s: &Session) -> Prompt {
match (self.target, self.remove) {
(None, _) => Prompt::new("Select a multileader", Accept::SELECT),
(Some(_), false) if self.fixed => Prompt::new("Specify leader arrowhead location", Accept::POINT),
(Some(_), false) => Prompt::new("Specify leader arrowhead location or [Remove leaders]", Accept::POINT).kw(&["Remove leaders"]),
(Some(_), true) if self.fixed => Prompt::new("Specify leaders to remove", Accept::POINT),
(Some(_), true) => Prompt::new("Specify leaders to remove or [Add leaders]", Accept::POINT).kw(&["Add leaders"]),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
let Some(h) = self.target else {
return match self.sel.feed(s, &i)? {
SelOutcome::More => Ok(Step::Continue),
SelOutcome::Empty => Ok(Step::Done),
SelOutcome::Done(hs) => {
s.set_selection(Vec::new());
match hs.into_iter().find(|h| get_mleader(s, *h).is_some()) {
Some(h) => {
self.target = Some(h);
Ok(Step::Continue)
}
None => {
s.echo("Select a multileader.");
self.sel = SelectPhase::single();
Ok(Step::Continue)
}
}
}
};
};
match i {
Input::Enter => {
s.echo(format!("{} leader(s) {}.", self.changed, if self.remove { "removed" } else { "added" }));
Ok(Step::Done)
}
Input::Keyword(k) | Input::Text(k) if !self.fixed && (kw_is(&k, "Remove leaders") || kw_is(&k, "Add leaders")) => {
self.remove = kw_is(&k, "Remove leaders");
Ok(Step::Continue)
}
Input::Point(pt) => {
if self.remove {
let Some(m) = get_mleader(s, h) else { return Ok(Step::Cancel) };
match nearest_leader(&m, pt) {
Some(ix) => {
remove_leaders(s, h, &[ix])?;
self.changed += 1;
}
None => s.echo("No leader to remove."),
}
} else {
add_leaders(s, h, &[pt])?;
self.changed += 1;
}
Ok(Step::Continue)
}
_ => Ok(Step::Continue),
}
}
}
// ---------------------------------------------------------------- shared geometry
/// Move a multileader's content (landing, text, block) by `d`; the arrowheads stay.
fn shift_content(m: &mut MLeader, d: Vec2) {
let mv = |v: &mut Vec3| *v = Vec3::new(v.x + d.x, v.y + d.y, v.z);
mv(&mut m.landing);
if let Some(t) = &mut m.text {
mv(&mut t.insert);
}
if let Some(b) = &mut m.block {
mv(&mut b.insert);
}
}
fn move_content(s: &mut Session, moves: &[(Handle, Vec2)]) -> Result<()> {
let d = s.doc_mut()?;
for (h, delta) in moves {
if !delta.is_finite() {
continue;
}
d.modify_entity(*h, |e| {
if let EntityKind::MLeader(m) = &mut e.kind {
shift_content(m, *delta);
}
})?;
}
Ok(())
}
// ---------------------------------------------------------------- MLEADERALIGN
#[derive(Clone, Copy, Debug, PartialEq)]
enum Spacing {
Current,
Distribute,
Parallel,
Value(f64),
}
/// The direction of the last leader segment (arrowhead side to landing).
fn segment_dir(m: &MLeader) -> Option<Vec2> {
let last = m.leaders.first()?.last()?;
let v = m.landing.xy() - last.xy();
(v.len() > 1e-12).then(|| v.normalized())
}
fn align(s: &mut Session, hs: &[Handle], to: Option<Handle>, spacing: Spacing, line: Option<(Vec2, Vec2)>) -> Result<usize> {
let mut items: Vec<(Handle, MLeader)> = Vec::new();
for h in hs {
if Some(*h) == to || items.iter().any(|(x, _)| x == h) || locked(s, *h) {
continue;
}
if let Some(m) = get_mleader(s, *h) {
items.push((*h, m));
}
}
if items.is_empty() {
return Err(bad("mleaderalign", "no multileaders to align"));
}
if items.len() > MAX_ITEMS {
return Err(bad("mleaderalign", "too many multileaders"));
}
let reference = match (spacing, to) {
(Spacing::Distribute, _) => None,
(_, Some(t)) => Some(get_mleader(s, t).ok_or_else(|| bad("mleaderalign", "`to` is not a multileader"))?),
(_, None) => return Err(bad("mleaderalign", "`to` (the multileader to align to) is required")),
};
let mut moves: Vec<(Handle, Vec2)> = Vec::new();
match spacing {
Spacing::Parallel => {
let rdir = reference.as_ref().and_then(segment_dir).ok_or_else(|| bad("mleaderalign", "the reference multileader has no leader"))?;
for (h, m) in &items {
let Some(arrow) = m.leaders.first().and_then(|l| l.last()) else { continue };
let len = (m.landing.xy() - arrow.xy()).len();
moves.push((*h, arrow.xy() + rdir * len - m.landing.xy()));
}
}
Spacing::Distribute => {
let (a, b) = line.ok_or_else(|| bad("mleaderalign", "`points` (first and last position) are required"))?;
let d = b - a;
if d.len() < 1e-12 {
return Err(bad("mleaderalign", "the two points coincide"));
}
let u = d.normalized();
items.sort_by(|x, y| (x.1.landing.xy() - a).dot(u).total_cmp(&(y.1.landing.xy() - a).dot(u)));
let n = items.len();
for (i, (h, m)) in items.iter().enumerate() {
let f = if n > 1 { i as f64 / (n - 1) as f64 } else { 0.0 };
moves.push((*h, a + d * f - m.landing.xy()));
}
}
Spacing::Current | Spacing::Value(_) => {
let (a, b) = line.ok_or_else(|| bad("mleaderalign", "`direction` (two points or an angle) is required"))?;
let d = b - a;
if d.len() < 1e-12 {
return Err(bad("mleaderalign", "the direction is zero"));
}
let u = d.normalized();
let origin = reference.as_ref().map(|r| r.landing.xy()).unwrap_or(a);
let t_of = |m: &MLeader| (m.landing.xy() - origin).dot(u);
if let Spacing::Value(sp) = spacing {
let (mut pos, mut neg): (Vec<_>, Vec<_>) = items.iter().partition(|(_, m)| t_of(m) >= 0.0);
pos.sort_by(|x, y| t_of(&x.1).total_cmp(&t_of(&y.1)));
neg.sort_by(|x, y| t_of(&y.1).total_cmp(&t_of(&x.1)));
for (k, (h, m)) in pos.iter().enumerate() {
moves.push((*h, origin + u * (sp * (k + 1) as f64) - m.landing.xy()));
}
for (k, (h, m)) in neg.iter().enumerate() {
moves.push((*h, origin - u * (sp * (k + 1) as f64) - m.landing.xy()));
}
} else {
for (h, m) in &items {
moves.push((*h, origin + u * t_of(m) - m.landing.xy()));
}
}
}
}
move_content(s, &moves)?;
Ok(moves.len())
}
fn parse_line(p: &Value, cmd: &str) -> Result<Option<(Vec2, Vec2)>> {
if let Some(a) = p.get("angle").and_then(Value::as_f64) {
if !a.is_finite() {
return Err(bad(cmd, "`angle` must be a finite number"));
}
return Ok(Some((Vec2::new(0.0, 0.0), Vec2::from_angle(a.to_radians()))));
}
for key in ["direction", "points"] {
if p.get(key).is_some_and(|v| !v.is_null()) {
let pts = points_param(p, key).ok_or_else(|| bad(cmd, format!("`{key}` must be [[x,y],[x,y]]")))?;
if let (Some(a), Some(b)) = (pts.first(), pts.get(1)) {
return Ok(Some((*a, *b)));
}
return Err(bad(cmd, format!("`{key}` needs two points")));
}
}
Ok(None)
}
fn run_align(s: &mut Session, p: &Value) -> Result<Value> {
let hs = targets(s, p)?;
let to = match p.get("to") {
None | Some(Value::Null) => None,
Some(_) => Some(handle_of(p, "to").ok_or_else(|| bad("mleaderalign", "`to` must be an object handle"))?),
};
let spacing = match (str_param(p, "option").map(str::to_ascii_lowercase).as_deref(), p.get("spacing")) {
(_, Some(v)) if !v.is_null() => match v.as_f64().filter(|x| x.is_finite() && *x >= 0.0) {
Some(x) => Spacing::Value(x),
None => return Err(bad("mleaderalign", "`spacing` must be a non-negative number")),
},
(None | Some("current"), _) => Spacing::Current,
(Some("distribute"), _) => Spacing::Distribute,
(Some("parallel"), _) => Spacing::Parallel,
(Some("spacing"), _) => return Err(bad("mleaderalign", "`spacing` (a number) is required")),
(Some(_), _) => return Err(bad("mleaderalign", "`option` must be current, distribute, parallel or spacing")),
};
let line = parse_line(p, "mleaderalign")?;
let n = align(s, &hs, to, spacing, line)?;
Ok(json!({ "aligned": n }))
}
/// MLEADERALIGN at the command line: select, Options (Distribute / make segments Parallel /
/// Specify spacing / Use current spacing), the multileader to align to, then the direction.
#[derive(Default)]
struct AlignM {
sel: SelectPhase,
picked: Vec<Handle>,
selecting: bool,
spacing: Option<Spacing>,
asking_spacing: bool,
to: Option<Handle>,
first: Option<Vec2>,
pick_to: SelectPhase,
}
impl AlignM {
fn mode(&self) -> Spacing {
self.spacing.unwrap_or(Spacing::Current)
}
fn finish(&self, s: &mut Session, line: Option<(Vec2, Vec2)>) -> Result<Step> {
let r = align(s, &self.picked, self.to, self.mode(), line)?;
s.set_selection(Vec::new());
s.echo(format!("{r} multileader(s) aligned."));
Ok(Step::Done)
}
fn option(&mut self, s: &mut Session, k: &str) -> bool {
if kw_is(k, "Distribute") {
self.spacing = Some(Spacing::Distribute);
} else if kw_is(k, "make segments Parallel") || kw_is(k, "Parallel") {
self.spacing = Some(Spacing::Parallel);
} else if kw_is(k, "Specify spacing") {
self.asking_spacing = true;
} else if kw_is(k, "Use current spacing") {
self.spacing = Some(Spacing::Current);
} else {
return false;
}
let _ = s;
true
}
}
impl Interactive for AlignM {
fn name(&self) -> &'static str {
"MLEADERALIGN"
}
fn begin(&mut self, s: &mut Session) -> Result<Step> {
self.sel = SelectPhase::begin(s);
self.pick_to = SelectPhase::single();
if self.sel.done {
self.picked = self.sel.picked.clone();
self.selecting = true;
}
Ok(Step::Continue)
}
fn prompt(&self, _s: &Session) -> Prompt {
const OPTS: &[&str] = &["Options"];
if !self.selecting {
return self.sel.prompt();
}
if self.asking_spacing {
return Prompt::new("Specify spacing", Accept::NUMBER);
}
match (self.mode(), self.to, self.first) {
(Spacing::Distribute, _, None) => Prompt::new("Specify first point or [Options]", Accept::POINT).kw(OPTS),
(Spacing::Distribute, _, Some(a)) => {
let mut p = Prompt::new("Specify second point", Accept::POINT);
p.base = Some(a);
p
}
(_, None, _) => Prompt::new("Select multileader to align to or [Options]", Accept::SELECT).kw(OPTS),
(Spacing::Parallel, Some(_), _) => Prompt::new("Specify direction", Accept::POINT),
(_, Some(_), None) => Prompt::new("Specify direction (first point)", Accept::POINT),
(_, Some(_), Some(a)) => {
let mut p = Prompt::new("Specify direction (second point)", Accept::POINT);
p.base = Some(a);
p
}
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
if !self.selecting {
return match self.sel.feed(s, &i)? {
SelOutcome::More => Ok(Step::Continue),
SelOutcome::Empty => Ok(Step::Done),
SelOutcome::Done(hs) => {
self.picked = hs;
self.selecting = true;
Ok(Step::Continue)
}
};
}
if self.asking_spacing {
return match &i {
Input::Text(t) => match super::machines::number(t).filter(|v| v.is_finite() && *v >= 0.0) {
Some(v) => {
self.spacing = Some(Spacing::Value(v));
self.asking_spacing = false;
Ok(Step::Continue)
}
None => {
s.echo("Requires a non-negative number.");
Ok(Step::Continue)
}
},
_ => Ok(Step::Continue),
};
}
if let Input::Keyword(k) | Input::Text(k) = &i {
if kw_is(k, "Options") {
s.echo("Enter an option [Distribute/make segments Parallel/Specify spacing/Use current spacing]: type D, P, S or U.");
return Ok(Step::Continue);
}
let k = k.clone();
if self.option(s, &k) {
return Ok(Step::Continue);
}
}
let distribute = self.mode() == Spacing::Distribute;
if !distribute && self.to.is_none() {
if let SelOutcome::Done(hs) = self.pick_to.feed(s, &i)? {
s.set_selection(Vec::new());
match hs.into_iter().find(|h| get_mleader(s, *h).is_some()) {
Some(h) => self.to = Some(h),
None => {
s.echo("Select a multileader.");
self.pick_to = SelectPhase::single();
}
}
}
return Ok(Step::Continue);
}
let Input::Point(pt) = i else { return Ok(Step::Continue) };
if self.mode() == Spacing::Parallel {
return self.finish(s, None);
}
match self.first.take() {
None => {
self.first = Some(pt);
Ok(Step::Continue)
}
Some(a) => self.finish(s, Some((a, pt))),
}
}
}
// ---------------------------------------------------------------- MLEADERCOLLECT
#[derive(Clone, Copy, Debug, PartialEq)]
enum Layout {
Horizontal,
Vertical,
Wrap(f64),
}
/// Size and centre of a block reference's extents (the insertion point when it has none).
fn block_box(s: &Session, ins: &Insert) -> Result<(Vec2, Vec2)> {
let d = s.doc()?;
let probe = Entity { handle: Handle(0), common: Default::default(), kind: EntityKind::Insert(ins.clone()) };
let b = cadcraft_doc::entity_bounds(d, &probe, 0);
if b.is_empty() || !b.center().is_finite() {
return Ok((Vec2::new(0.0, 0.0), ins.insert.xy()));
}
Ok((b.max - b.min, b.center()))
}
fn collect(s: &mut Session, hs: &[Handle], at: Vec2, layout: Layout, spacing: Option<f64>) -> Result<Value> {
let mut items: Vec<(Handle, MLeader)> = Vec::new();
for h in hs {
if items.iter().any(|(x, _)| x == h) || locked(s, *h) {
continue;
}
if let Some(m) = get_mleader(s, *h).filter(|m| m.block.is_some()) {
items.push((*h, m));
}
}
if items.len() > MAX_ITEMS {
return Err(bad("mleadercollect", "too many multileaders"));
}
if items.len() < 2 {
return Err(bad("mleadercollect", "need two or more multileaders with block content"));
}
let gap = spacing.unwrap_or_else(|| {
let d = s.doc().ok();
d.and_then(|d| d.mleader_styles.iter().find(|m| m.name.eq_ignore_ascii_case(&items[0].1.style)).map(|m| m.landing_gap)).unwrap_or(0.09)
});
// Lay the blocks out from the top left corner at `at`.
let (mut x, mut y, mut row_h) = (0.0_f64, 0.0_f64, 0.0_f64);
let mut placed: Vec<Insert> = Vec::new();
for (_, m) in &items {
let Some(b) = &m.block else { continue };
let (size, centre) = block_box(s, b)?;
match layout {
Layout::Horizontal => {}
Layout::Vertical => {
if x > 0.0 || row_h > 0.0 {
y -= row_h + gap;
x = 0.0;
row_h = 0.0;
}
}
Layout::Wrap(w) => {
if x > 0.0 && x + size.x > w {
y -= row_h + gap;
x = 0.0;
row_h = 0.0;
}
}
}
let target = Vec2::new(at.x + x + size.x / 2.0, at.y + y - size.y / 2.0);
let mut ins = b.clone();
ins.insert = Vec3::new(b.insert.x + target.x - centre.x, b.insert.y + target.y - centre.y, b.insert.z);
placed.push(ins);
x += size.x + gap;
row_h = row_h.max(size.y);
}
let (first_h, first) = items[0].clone();
let mut merged = first;
merged.leaders = items.iter().flat_map(|(_, m)| m.leaders.iter().cloned()).collect();
merged.landing = Vec3::new(at.x, at.y, merged.landing.z);
merged.dogleg = 0.0;
merged.text = None;
let mut rest = placed.split_off(1.min(placed.len()));
merged.block = placed.pop();
let common = s.doc()?.entity(first_h).map(|e| e.common.clone());
{
let d = s.doc_mut()?;
d.modify_entity(first_h, |e| e.kind = EntityKind::MLeader(merged))?;
for (h, _) in items.iter().skip(1) {
d.remove_entity(*h);
}
}
// The other blocks stay block references next to the first (the model has one block per multileader).
let mut extra = Vec::new();
for ins in rest.drain(..) {
let h = s.add_entity(EntityKind::Insert(ins))?;
if let Some(c) = &common {
s.doc_mut()?.modify_entity(h, |e| e.common = c.clone())?;
}
extra.push(h.hex());
}
Ok(json!({ "handle": first_h.hex(), "blocks": extra, "collected": items.len() }))
}
fn run_collect(s: &mut Session, p: &Value) -> Result<Value> {
let hs = targets(s, p)?;
let at = point_req("mleadercollect", p, "at")?;
let spacing = match p.get("spacing") {
None | Some(Value::Null) => None,
Some(v) => {
Some(v.as_f64().filter(|x| x.is_finite() && *x >= 0.0).ok_or_else(|| bad("mleadercollect", "`spacing` must be a non-negative number"))?)
}
};
let layout = match str_param(p, "mode").map(str::to_ascii_lowercase).as_deref() {
None | Some("horizontal") => Layout::Horizontal,
Some("vertical") => Layout::Vertical,
Some("wrap") => {
let w = p.get("width").and_then(Value::as_f64).filter(|w| w.is_finite() && *w > 0.0);
Layout::Wrap(w.ok_or_else(|| bad("mleadercollect", "`width` (a positive number) is required for wrap"))?)
}
Some(_) => return Err(bad("mleadercollect", "`mode` must be horizontal, vertical or wrap")),
};
collect(s, &hs, at, layout, spacing)
}
/// MLEADERCOLLECT at the command line: select multileaders, then the location (Vertical /
/// Horizontal / Wrap to change the arrangement; Wrap asks for the width).
struct CollectM {
sel: SelectPhase,
picked: Vec<Handle>,
selecting: bool,
layout: Layout,
asking_width: bool,
}
impl Default for CollectM {
fn default() -> Self {
CollectM { sel: SelectPhase::default(), picked: Vec::new(), selecting: false, layout: Layout::Horizontal, asking_width: false }
}
}
impl Interactive for CollectM {
fn name(&self) -> &'static str {
"MLEADERCOLLECT"
}
fn begin(&mut self, s: &mut Session) -> Result<Step> {
self.sel = SelectPhase::begin(s);
if self.sel.done {
self.picked = self.sel.picked.clone();
self.selecting = true;
}
Ok(Step::Continue)
}
fn prompt(&self, _s: &Session) -> Prompt {
if !self.selecting {
return self.sel.prompt();
}
if self.asking_width {
return Prompt::new("Specify width", Accept::NUMBER);
}
Prompt::new("Specify collected multileader location or [Vertical/Horizontal/Wrap]", Accept::POINT)
.kw(&["Vertical", "Horizontal", "Wrap"])
.default("Horizontal")
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
if !self.selecting {
return match self.sel.feed(s, &i)? {
SelOutcome::More => Ok(Step::Continue),
SelOutcome::Empty => Ok(Step::Done),
SelOutcome::Done(hs) => {
self.picked = hs;
self.selecting = true;
Ok(Step::Continue)
}
};
}
if self.asking_width {
if let Input::Text(t) = &i {
match super::machines::number(t).filter(|v| v.is_finite() && *v > 0.0) {
Some(w) => {
self.layout = Layout::Wrap(w);
self.asking_width = false;
}
None => s.echo("Requires a positive number."),
}
}
return Ok(Step::Continue);
}
match i {
Input::Keyword(k) | Input::Text(k) => {
if kw_is(&k, "Vertical") {
self.layout = Layout::Vertical;
} else if kw_is(&k, "Horizontal") {
self.layout = Layout::Horizontal;
} else if kw_is(&k, "Wrap") {
self.asking_width = true;
}
Ok(Step::Continue)
}
Input::Point(at) => {
let r = collect(s, &self.picked, at, self.layout, None)?;
s.set_selection(Vec::new());
s.echo(format!("{} multileader(s) collected.", r.get("collected").and_then(Value::as_u64).unwrap_or(0)));
Ok(Step::Done)
}
_ => Ok(Step::Continue),
}
}
}
#[cfg(test)]
#[path = "mleader_edit_tests.rs"]
mod tests;
+122
View File
@@ -0,0 +1,122 @@
use cadcraft_doc::{EntityKind, Handle, MLeader};
use cadcraft_geom::Vec2;
use serde_json::{Value, json};
use crate::{Input, Session};
fn hd(v: &Value) -> Handle {
Handle::parse_hex(v["handle"].as_str().unwrap_or("0")).unwrap_or(Handle(0))
}
fn ml(s: &Session, h: Handle) -> MLeader {
match s.doc().unwrap().entity(h).map(|e| e.kind.clone()) {
Some(EntityKind::MLeader(m)) => m,
other => panic!("not a multileader: {other:?}"),
}
}
fn text_ml(s: &mut Session, arrow: [f64; 2], land: [f64; 2]) -> Handle {
hd(&s.execute("mleader", &json!({ "points": [arrow, land], "text": "N" })).unwrap())
}
#[test]
fn edit_adds_and_removes_leaders() {
let mut s = Session::new();
let h = text_ml(&mut s, [0.0, 0.0], [5.0, 5.0]);
s.execute("mleaderedit", &json!({ "handle": h.hex(), "points": [[1, 9], [8, 0]] })).unwrap();
assert_eq!(ml(&s, h).leaders.len(), 3);
// Pick the leader near (8, 0): it is leader 2.
s.execute("mleaderedit", &json!({ "handle": h.hex(), "mode": "remove", "points": [[7.5, 0.5]] })).unwrap();
let m = ml(&s, h);
assert_eq!(m.leaders.len(), 2);
assert!(m.leaders.iter().all(|l| l[0].x != 8.0));
assert!(s.execute("mleaderedit", &json!({ "handle": h.hex(), "mode": "remove", "index": 7 })).is_err());
// Interactive: Add Leader then Remove Leader, one undo step each.
s.start("mleaderedit.add").unwrap();
s.input(Input::Pick(vec![h])).unwrap();
s.input(Input::Point(Vec2::new(-3.0, 2.0))).unwrap();
s.input(Input::Enter).unwrap();
assert!(s.running.is_none());
assert_eq!(ml(&s, h).leaders.len(), 3);
s.start("mleaderedit.remove").unwrap();
s.input(Input::Pick(vec![h])).unwrap();
s.input(Input::Point(Vec2::new(-3.0, 2.0))).unwrap();
s.input(Input::Enter).unwrap();
assert_eq!(ml(&s, h).leaders.len(), 2);
}
#[test]
fn align_distribute_parallel_and_spacing() {
let mut s = Session::new();
let a = text_ml(&mut s, [0.0, 0.0], [0.0, 4.0]);
let b = text_ml(&mut s, [3.0, 0.0], [3.0, 7.0]);
let c = text_ml(&mut s, [9.0, 0.0], [9.0, 2.0]);
// Current spacing: all landings on the horizontal line through a's landing.
s.execute("mleaderalign", &json!({ "handles": [b.hex(), c.hex()], "to": a.hex(), "direction": [[0, 0], [1, 0]] })).unwrap();
assert!((ml(&s, b).landing.y - 4.0).abs() < 1e-9 && (ml(&s, c).landing.y - 4.0).abs() < 1e-9);
assert!((ml(&s, b).text.unwrap().insert.y - 4.0).abs() < 1e-6, "text moves with the landing");
assert_eq!(ml(&s, b).leaders[0][0].y, 0.0, "arrowheads stay");
// Specified spacing along +x.
s.execute("mleaderalign", &json!({ "handles": [b.hex(), c.hex()], "to": a.hex(), "direction": [[0, 0], [1, 0]], "spacing": 2 })).unwrap();
assert!((ml(&s, b).landing.x - 2.0).abs() < 1e-9 && (ml(&s, c).landing.x - 4.0).abs() < 1e-9);
// Distribute between two points.
s.execute("mleaderalign", &json!({ "handles": [a.hex(), b.hex(), c.hex()], "option": "distribute", "points": [[0, 10], [10, 10]] })).unwrap();
let xs: Vec<f64> = [a, b, c].iter().map(|h| ml(&s, *h).landing.x).collect();
assert!(xs.iter().any(|x| (x - 5.0).abs() < 1e-9) && xs.iter().any(|x| (x - 10.0).abs() < 1e-9));
// Parallel: segments take the reference's direction.
s.execute("mleaderalign", &json!({ "handles": [b.hex()], "to": a.hex(), "option": "parallel" })).unwrap();
let (ra, rb) = (ml(&s, a), ml(&s, b));
let da = (ra.landing.xy() - ra.leaders[0][0].xy()).normalized();
let db = (rb.landing.xy() - rb.leaders[0][0].xy()).normalized();
assert!((da.x * db.y - da.y * db.x).abs() < 1e-9);
// Interactive.
s.set_selection(Vec::new());
s.start("mleaderalign").unwrap();
s.input(Input::Pick(vec![b, c])).unwrap();
s.input(Input::Enter).unwrap();
s.input(Input::Pick(vec![a])).unwrap();
s.input(Input::Point(Vec2::new(0.0, 0.0))).unwrap();
s.input(Input::Point(Vec2::new(0.0, 1.0))).unwrap();
assert!(s.running.is_none());
assert!((ml(&s, b).landing.x - ml(&s, a).landing.x).abs() < 1e-9);
}
#[test]
fn collect_blocks_into_one_multileader() {
let mut s = Session::new();
s.execute("line", &json!({ "points": [[0, 0], [2, 2]] })).unwrap();
let l = s.doc().unwrap().model.iter().next().map(|e| e.handle).unwrap();
s.execute("block", &json!({ "name": "B", "base": [0, 0], "handles": [l.hex()], "keep": "delete" })).unwrap();
let mk = |s: &mut Session, x: f64| hd(&s.execute("mleader", &json!({ "points": [[x, 0], [x, 5]], "block": "B" })).unwrap());
let (a, b, c) = (mk(&mut s, 10.0), mk(&mut s, 20.0), mk(&mut s, 30.0));
let r =
s.execute("mleadercollect", &json!({ "handles": [a.hex(), b.hex(), c.hex()], "at": [0, 20], "mode": "horizontal", "spacing": 1 })).unwrap();
assert_eq!(r["collected"], 3);
let m = ml(&s, a);
assert_eq!(m.leaders.len(), 3, "all arrowheads on the collected multileader");
assert!(s.doc().unwrap().entity(b).is_none() && s.doc().unwrap().entity(c).is_none());
assert_eq!(r["blocks"].as_array().map(Vec::len), Some(2));
// Wrap needs a width.
assert!(s.execute("mleadercollect", &json!({ "handles": [a.hex()], "at": [0, 0], "mode": "wrap" })).is_err());
}
#[test]
fn hostile_mleader_edit_params_never_panic() {
let mut s = Session::new();
let h = text_ml(&mut s, [0.0, 0.0], [5.0, 5.0]);
for (id, p) in [
("mleaderedit", json!({ "handle": "zz", "points": [[1, 1]] })),
("mleaderedit", json!({ "handle": h.hex(), "mode": "remove", "index": -1 })),
("mleaderedit", json!({ "handle": h.hex(), "mode": "remove", "index": 1e300 })),
("mleaderedit", json!({ "handle": h.hex(), "points": [[1e308, f64::MAX], "x"] })),
("mleaderalign", json!({ "handles": [h.hex()], "to": h.hex(), "direction": [[0, 0], [0, 0]] })),
("mleaderalign", json!({ "handles": [], "option": "distribute", "points": [[0, 0], [1, 1]] })),
("mleaderalign", json!({ "handles": [h.hex()], "spacing": -1, "to": "7", "angle": 1e308 })),
("mleaderalign", json!({ "handles": [h.hex(), "q"], "option": "nonsense" })),
("mleadercollect", json!({ "handles": [h.hex()], "at": [0, 0] })),
("mleadercollect", json!({ "handles": [h.hex(), h.hex()], "at": [1e308, 1e308], "mode": "wrap", "width": 0 })),
] {
let _ = s.execute(id, &p);
}
let _ = cadcraft_render::build(s.doc().unwrap(), &cadcraft_doc::Space::Model, &cadcraft_render::Options::default());
}
+5 -1
View File
@@ -5,7 +5,7 @@ pub(crate) mod annoscale;
mod annotate;
mod array;
mod audit;
mod blocks;
pub(crate) mod blocks;
pub mod clipboard;
pub mod constraints;
mod draw;
@@ -21,7 +21,9 @@ pub(crate) mod hatch;
mod inquiry;
mod layer;
mod layout;
mod lisp;
mod mleader_content;
mod mleader_edit;
mod modify;
mod modify2;
mod mtext_opts;
@@ -176,6 +178,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(draw::specs());
v.extend(draw2::specs());
v.extend(annotate::specs());
v.extend(mleader_edit::specs());
v.extend(annoscale::specs());
v.extend(hatch::specs());
v.extend(overkill::specs());
@@ -198,6 +201,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(table::specs());
v.extend(tolerance::specs());
v.extend(constraints::specs());
v.extend(lisp::specs());
v
})
}
+170 -48
View File
@@ -11,6 +11,7 @@ use super::helpers::*;
use super::machines::{SelOutcome, SelectPhase, number};
use super::trimextend::{self, TrimM};
use super::*;
use crate::select::{SelectKind, SelectMode};
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
pub fn specs() -> Vec<CommandSpec> {
@@ -48,7 +49,7 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("stretch", "Stretch", run_stretch)
.menu(&["Modify", "Stretch"])
.alias(&["s"])
.params("{window: [[x,y],[x,y]], delta: [dx,dy]}")
.params("{window: [[x,y],[x,y]] | polygon: [[x,y],...] | fence: [[x,y],...], delta: [dx,dy]}")
.interactive(|_| Ok(Box::new(StretchM::default()))),
CommandSpec::new("offset", "Offset", run_offset)
.menu(&["Modify", "Offset"])
@@ -201,9 +202,26 @@ fn run_mirror(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "handles": r.iter().map(|h| h.hex()).collect::<Vec<_>>() }))
}
/// Move the vertices of `e` that lie inside any of the windows `wins` by `d` (STRETCH).
fn stretch_entity(e: &mut Entity, wins: &[cadcraft_geom::Bounds2], d: Vec2) {
let inside = |p: Vec2| wins.iter().any(|bx| bx.contains(p));
/// An area whose vertices STRETCH moves: a window's box, or a polygon closed back to its first
/// point.
#[derive(Clone)]
enum StretchArea {
Box(cadcraft_geom::Bounds2),
Polygon(Vec<Vec2>),
}
impl StretchArea {
fn contains(&self, p: Vec2) -> bool {
match self {
StretchArea::Box(bx) => bx.contains(p),
StretchArea::Polygon(poly) => cadcraft_geom::point_in_polygon(poly, p),
}
}
}
/// Move the vertices of `e` that lie inside any of the areas `wins` by `d` (STRETCH).
fn stretch_entity(e: &mut Entity, wins: &[StretchArea], d: Vec2) {
let inside = |p: Vec2| wins.iter().any(|a| a.contains(p));
let mv = |p: &mut cadcraft_geom::Vec3| {
if inside(p.xy()) {
p.x += d.x;
@@ -246,16 +264,34 @@ fn stretch_entity(e: &mut Entity, wins: &[cadcraft_geom::Bounds2], d: Vec2) {
}
fn run_stretch(s: &mut Session, p: &Value) -> Result<Value> {
let w = points_param(p, "window").ok_or_else(|| bad("stretch", "`window` [[x,y],[x,y]] is required"))?;
let (Some(a), Some(b)) = (w.first(), w.get(1)) else { return Err(bad("stretch", "window needs 2 corners")) };
let bx = cadcraft_geom::Bounds2::new(*a, *b);
let d = delta_of("stretch", p)?;
let space = s.space();
let mut hs = crate::select::select_window(s.doc()?, &space, bx, true);
let listed = |key: &str, least: usize| -> Result<Option<Vec<Vec2>>> {
let Some(pts) = points_param(p, key) else { return Ok(None) };
if pts.len() < least || pts.len() > crate::select::MAX_MODE_POINTS || pts.iter().any(|q| !q.is_finite()) {
return Err(bad("stretch", format!("`{key}` needs {least} to {} finite points", crate::select::MAX_MODE_POINTS)));
}
Ok(Some(pts))
};
// A fence marks no area: the objects it crosses move whole.
if let Some(fence) = listed("fence", 2)? {
let hs = crate::select::select_fence(s.doc()?, &space, &fence);
let moved = transform_entities(s, &hs, &Mat3::translate(d), false)?;
return Ok(json!({ "stretched": moved.len() }));
}
let (mut hs, area) = if let Some(poly) = listed("polygon", 3)? {
(crate::select::select_polygon(s.doc()?, &space, &poly, true), StretchArea::Polygon(poly))
} else {
let w = points_param(p, "window").ok_or_else(|| bad("stretch", "`window` [[x,y],[x,y]], `polygon` or `fence` is required"))?;
let (Some(a), Some(b)) = (w.first(), w.get(1)) else { return Err(bad("stretch", "window needs 2 corners")) };
let bx = cadcraft_geom::Bounds2::new(*a, *b);
(crate::select::select_window(s.doc()?, &space, bx, true), StretchArea::Box(bx))
};
hs.retain(|h| !super::curves::is_locked(s, *h));
let doc = s.doc_mut()?;
let areas = [area];
for h in &hs {
doc.modify_entity(*h, |e| stretch_entity(e, &[bx], d))?;
doc.modify_entity(*h, |e| stretch_entity(e, &areas, d))?;
}
Ok(json!({ "stretched": hs.len() }))
}
@@ -270,20 +306,20 @@ enum StretchPhase {
}
/// STRETCH: "Select objects" repeats until Enter. Crossing windows (implied right to left, or after
/// `C`) and windows (left to right, or after `W`) select objects and mark the area whose vertices
/// move; objects picked one at a time (or by All/Last/Previous) move whole. Then the base point or
/// Displacement, and the second point.
/// `C`), windows (left to right, or after `W`) and polygons (`CP`, `WP`) select objects and mark
/// the area whose vertices move; objects picked one at a time (or by a fence, `F`, or
/// All/Last/Previous) move whole. Then the base point or Displacement, and the second point.
#[derive(Default)]
struct StretchM {
phase: StretchPhase,
objs: Vec<Handle>,
/// Objects selected without a window: they move whole.
whole: Vec<Handle>,
/// The selection windows: vertices inside any of them move.
windows: Vec<cadcraft_geom::Bounds2>,
/// The selection windows and polygons: vertices inside any of them move.
windows: Vec<StretchArea>,
removing: bool,
/// `C` (true) or `W` (false) was typed: the next two points are that kind of window.
mode: Option<bool>,
/// `C`, `W`, `BOX`, `CP`, `WP` or `F` was typed: the next points are that kind of selection.
mode: Option<SelectMode>,
/// First corner of the window being drawn.
corner: Option<Vec2>,
base: Option<Vec2>,
@@ -313,20 +349,63 @@ impl StretchM {
s.set_selection(self.objs.clone());
}
/// Select with a typed mode that has its points. With too few (a fence of one point, a polygon
/// of two) the mode is dropped.
fn finish_mode(&mut self, s: &mut Session, m: SelectMode) -> Result<()> {
let space = s.space();
let Some(hs) = m.select(s.doc()?, &space) else { return Ok(()) };
let area = match (m.kind, m.pts.first().zip(m.pts.get(1))) {
(SelectKind::WPolygon | SelectKind::CPolygon, _) => Some(StretchArea::Polygon(m.pts)),
(SelectKind::Window | SelectKind::Crossing | SelectKind::Box, Some((a, b))) => {
Some(StretchArea::Box(cadcraft_geom::Bounds2::new(*a, *b)))
}
// A fence marks no area: what it crosses moves whole.
_ => None,
};
let whole = area.is_none();
if let (Some(a), false) = (area, self.removing) {
self.windows.push(a);
}
self.add(s, hs, whole);
Ok(())
}
fn select(&mut self, s: &mut Session, i: Input) -> Result<Step> {
if let Some(mut m) = self.mode.take() {
match &i {
Input::Point(p) => {
if m.push(*p) {
self.finish_mode(s, m)?;
} else {
self.mode = Some(m);
}
return Ok(Step::Continue);
}
Input::Text(t) | Input::Keyword(t) if matches!(t.trim().to_ascii_lowercase().as_str(), "u" | "undo") => {
m.pts.pop();
self.mode = Some(m);
return Ok(Step::Continue);
}
// Enter ends a fence or closes a polygon.
Input::Enter => {
self.finish_mode(s, m)?;
return Ok(Step::Continue);
}
// Anything else leaves the mode and is read as usual.
_ => {}
}
}
match i {
Input::Point(p) => match self.corner.take() {
Some(c) => {
let bx = cadcraft_geom::Bounds2::new(c, p);
let crossing = self.mode.take().unwrap_or(p.x < c.x);
let space = s.space();
let hs = crate::select::select_window(s.doc()?, &space, bx, crossing);
let hs = crate::select::select_window(s.doc()?, &space, bx, p.x < c.x);
if !self.removing {
self.windows.push(bx);
self.windows.push(StretchArea::Box(bx));
}
self.add(s, hs, false);
}
None if self.mode.is_some() => self.corner = Some(p),
None => {
let aperture = s.pixel_size() * s.settings.pickbox.max(1.0) * 1.5;
let space = s.space();
@@ -341,15 +420,12 @@ impl StretchM {
self.corner = None;
let d = s.doc()?;
let store = d.space(&s.space());
let picked = match t.trim().to_ascii_lowercase().as_str() {
"c" | "crossing" => {
self.mode = Some(true);
None
}
"w" | "window" => {
self.mode = Some(false);
None
}
let word = t.trim().to_ascii_lowercase();
if let Some(m) = SelectMode::from_keyword(&word).filter(|m| m.kind != SelectKind::Group) {
self.mode = Some(m);
return Ok(Step::Continue);
}
let picked = match word.as_str() {
"r" | "remove" => {
self.removing = true;
None
@@ -361,10 +437,6 @@ impl StretchM {
"all" => Some(store.map(|st| st.iter().filter(|e| d.is_visible(e)).map(|e| e.handle).collect()).unwrap_or_default()),
"l" | "last" => Some(store.and_then(|st| st.last()).map(|e| vec![e.handle]).unwrap_or_default()),
"p" | "previous" => Some(s.state()?.previous_selection.clone()),
"cp" | "wp" | "f" | "fence" => {
s.echo("Polygon and fence selection are not available yet in STRETCH; use a crossing window.");
None
}
_ => {
s.echo("*Invalid selection*");
None
@@ -374,10 +446,7 @@ impl StretchM {
self.add(s, hs, true);
}
}
Input::Enter if self.corner.is_some() || self.mode.is_some() => {
self.corner = None;
self.mode = None;
}
Input::Enter if self.corner.is_some() => self.corner = None,
Input::Enter if self.objs.is_empty() => return Ok(Step::Done),
Input::Enter => {
s.remember_selection(&self.objs);
@@ -413,9 +482,11 @@ impl Interactive for StretchM {
Ok(Step::Continue)
}
fn prompt(&self, _s: &Session) -> Prompt {
if let (StretchPhase::Select, Some(m)) = (self.phase, &self.mode) {
return m.prompt();
}
match (self.phase, self.corner) {
(StretchPhase::Select, Some(c)) => Prompt::new("Specify opposite corner", Accept::POINT).base(c),
(StretchPhase::Select, None) if self.mode.is_some() => Prompt::new("Specify first corner", Accept::POINT),
(StretchPhase::Select, None) => Prompt::new(if self.removing { "Remove objects" } else { "Select objects" }, Accept::POINT),
(StretchPhase::Base, _) => Prompt::new("Specify base point", Accept::POINT).kw(&["Displacement"]).default("Displacement"),
(StretchPhase::Displacement, _) => Prompt::new("Specify displacement", Accept::POINT).default("0,0"),
@@ -446,11 +517,25 @@ impl Interactive for StretchM {
}
}
fn preview(&self, s: &Session, c: Vec2) -> Vec<EntityKind> {
match (self.phase, self.corner, self.base) {
(StretchPhase::Select, Some(a), _) => {
let (b, d) = (Vec2::new(c.x, a.y), Vec2::new(a.x, c.y));
vec![line(a, b), line(b, c), line(c, d), line(d, a)]
let rect = |a: Vec2| {
let (b, d) = (Vec2::new(c.x, a.y), Vec2::new(a.x, c.y));
vec![line(a, b), line(b, c), line(c, d), line(d, a)]
};
if let (StretchPhase::Select, Some(m)) = (self.phase, &self.mode) {
let Some(first) = m.pts.first().copied() else { return Vec::new() };
if !matches!(m.kind, SelectKind::Fence | SelectKind::WPolygon | SelectKind::CPolygon) {
return rect(first);
}
// The fence or polygon so far, on to the cursor; a polygon closes back to its start.
let mut out: Vec<EntityKind> = m.pts.windows(2).filter_map(|w| Some(line(*w.first()?, *w.get(1)?))).collect();
out.extend(m.pts.last().map(|l| line(*l, c)));
if m.kind != SelectKind::Fence && m.pts.len() >= 2 {
out.push(line(c, first));
}
return out;
}
match (self.phase, self.corner, self.base) {
(StretchPhase::Select, Some(a), _) => rect(a),
(StretchPhase::Second, _, Some(base)) => {
let Ok(doc) = s.doc() else { return Vec::new() };
let m = Mat3::translate(c - base);
@@ -597,20 +682,40 @@ pub(crate) fn offset_polyline(pl: &Polyline, d: f64) -> Option<Vec<PolyVertex>>
return None;
}
let n = segs.len();
let mut verts: Vec<PolyVertex> = Vec::new();
// Where each offset segment starts after joining it to the previous one.
let mut starts: Vec<Vec2> = Vec::with_capacity(n);
for i in 0..n {
let s = segs.get(i)?;
let start = if i == 0 && !pl.closed {
starts.push(if i == 0 && !pl.closed {
s.start()
} else {
let prev = segs.get((i + n - 1) % n)?;
join_point(prev, s).unwrap_or(s.start())
});
}
let mut verts: Vec<PolyVertex> = Vec::new();
for i in 0..n {
let s = segs.get(i)?;
let start = *starts.get(i)?;
let end = if i + 1 < n {
*starts.get(i + 1)?
} else if pl.closed {
*starts.first()?
} else {
s.end()
};
let bulge = match s {
Segment::Line(_) => 0.0,
Segment::Arc { arc, ccw } => {
let sw = arc.sweep();
cadcraft_geom::arc_to_bulge(if *ccw { sw } else { -sw })
// The joined endpoints may have moved along the circle: take the sweep between them.
let sweep = if start.near(end, 1e-9) {
arc.sweep()
} else if *ccw {
ccw_sweep(arc.center.angle_to(start), arc.center.angle_to(end))
} else {
ccw_sweep(arc.center.angle_to(end), arc.center.angle_to(start))
};
cadcraft_geom::arc_to_bulge(if *ccw { sweep } else { -sweep })
}
};
verts.push(PolyVertex { p: start, bulge, start_width: 0.0, end_width: 0.0 });
@@ -618,7 +723,6 @@ pub(crate) fn offset_polyline(pl: &Polyline, d: f64) -> Option<Vec<PolyVertex>>
if !pl.closed {
verts.push(PolyVertex::new(segs.last()?.end()));
}
// Recompute arc bulges where endpoints moved (approximation: keep sweep sign).
Some(verts)
}
@@ -910,12 +1014,18 @@ fn trim_polyline(p: &cadcraft_doc::LwPolyline, cut: &[Segment], pick: Vec2) -> R
let mut out = Vec::new();
if p.closed {
// Keep the complementary run from hi around to lo.
// The pick lies before the first cut or after the last: the piece wraps past the start
// vertex, so the run to keep is the stretch between the first and the last cut.
let (lo, hi) = (
if lo.is_finite() { lo } else { ts.last().copied().unwrap_or(0.0) - total },
if hi.is_finite() { hi } else { ts.first().copied().unwrap_or(total) + total },
);
let doubled: Vec<Segment> = segs.iter().chain(segs.iter()).copied().collect();
let (a, b) = if hi > total { (hi - total, lo + total) } else { (hi, lo + total) };
let (a, mut b) = if hi > total { (hi - total, lo) } else { (hi, lo + total) };
// A single cut leaves the whole outline, opened there.
if b <= a + 1e-9 {
b += total;
}
out.push(mk(sub_polyline(&doubled, a, b)));
} else {
if lo.is_finite() {
@@ -1295,9 +1405,15 @@ fn explode(s: &mut Session, hs: &[Handle]) -> Result<Vec<Handle>> {
}
let Some(e) = s.doc()?.entity(*h).map(|e| (**e).clone()) else { continue };
let Some(parts) = explode_kind(s.doc()?, &e) else { continue };
// The pieces of an object are new objects: its xdata goes with it. A block reference's
// pieces are copies of the block's objects and keep theirs.
let own_pieces = !matches!(e.kind, EntityKind::Insert(_));
let d = s.doc_mut()?;
d.remove_entity(*h);
for mut p in parts {
if own_pieces {
p.common.xdata.clear();
}
p.handle = d.new_handle();
out.push(p.handle);
if let Some(st) = d.space_mut(&space) {
@@ -1317,6 +1433,9 @@ fn run_explode(s: &mut Session, p: &Value) -> Result<Value> {
/// Break `h` between two points (or at one point when p1 == p2).
pub(crate) fn break_entity(s: &mut Session, h: Handle, p1: Vec2, p2: Vec2) -> Result<Vec<Handle>> {
if super::curves::is_locked(s, h) {
return Err(EngineError::Other("The object is on a locked layer.".into()));
}
let e = s.doc()?.entity(h).map(|e| (**e).clone()).ok_or_else(|| EngineError::Other("no such object".into()))?;
let pieces: Vec<EntityKind> = match &e.kind {
EntityKind::Line(l) => {
@@ -1426,6 +1545,9 @@ pub(crate) fn join(s: &mut Session, hs: &[Handle]) -> Result<Option<Handle>> {
if let Some(h) = super::modify2::join_curves(s, hs)? {
return Ok(Some(h));
}
if hs.iter().any(|h| super::curves::is_locked(s, *h)) {
return Err(EngineError::Other("An object is on a locked layer.".into()));
}
let d = s.doc()?;
let mut segs: Vec<Segment> = Vec::new();
// Source entity of each segment in `segs`, so only entities that end up in the chain are removed.
+18 -5
View File
@@ -454,10 +454,17 @@ impl Interactive for PeditM {
let sel = s.selection();
if let [h] = sel.as_slice() {
let k = curves::entity(s, *h)?.kind;
if matches!(k, EntityKind::LwPolyline(_)) {
self.h = Some(*h);
self.original = self.poly(s).map(|p| p.vertices);
self.phase = PPhase::Main;
match k {
EntityKind::LwPolyline(_) => {
self.h = Some(*h);
self.original = self.poly(s).map(|p| p.vertices);
self.phase = PPhase::Main;
}
EntityKind::Line(_) | EntityKind::Arc(_) => {
self.h = Some(*h);
self.phase = PPhase::Convert;
}
_ => {}
}
}
Ok(Step::Continue)
@@ -1308,6 +1315,9 @@ pub(crate) fn flatten_kind(k: &mut EntityKind) {
EntityKind::AttDef(a) => {
z(&mut a.text.insert);
a.text.align_pt.iter_mut().for_each(z);
if let Some(ml) = &mut a.multiline {
z(&mut ml.mtext.insert);
}
}
EntityKind::MText(t) => z(&mut t.insert),
EntityKind::Insert(i) => {
@@ -1315,6 +1325,9 @@ pub(crate) fn flatten_kind(k: &mut EntityKind) {
for a in &mut i.attribs {
z(&mut a.text.insert);
a.text.align_pt.iter_mut().for_each(z);
if let Some(ml) = &mut a.multiline {
z(&mut ml.mtext.insert);
}
}
}
EntityKind::Dimension(d) => {
@@ -1445,7 +1458,7 @@ fn chspace(s: &mut Session, hs: &[Handle], to: Option<&str>, vp: Option<Handle>)
fn run_chspace(s: &mut Session, p: &Value) -> Result<Value> {
let hs = targets(s, p)?;
let (n, _, target) = chspace(s, &hs, str_param(p, "to"), curves::handle_param(p, "viewport"))?;
Ok(json!({ "moved": n, "to": match target { Space::Model => "model".to_string(), Space::Paper(n) => n } }))
Ok(json!({ "moved": n, "to": target.layout().unwrap_or("model") }))
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -228,6 +228,9 @@ fn ends(sp: &Spline) -> (Vec2, Vec2) {
/// larger arc or the full ellipse (gaps between them close). Otherwise the objects joined end
/// to end with the first one become one spline. The first joined object keeps its handle.
pub(crate) fn join_curves(s: &mut Session, hs: &[Handle]) -> Result<Option<Handle>> {
// A handle named twice is one object: a repeat would join it with itself or remove the result.
let mut seen = std::collections::HashSet::new();
let hs: Vec<Handle> = hs.iter().copied().filter(|h| seen.insert(*h)).collect();
let d = s.doc()?;
let ents: Vec<Entity> = hs.iter().filter_map(|h| d.entity(*h).map(|e| (**e).clone())).collect();
if !ents.iter().any(|e| matches!(e.kind, EntityKind::Ellipse(_) | EntityKind::Spline(_))) {
+38
View File
@@ -683,3 +683,41 @@ fn editing_commands_leave_locked_layer_objects_alone() {
s.execute("explode", &json!({"handles": [r]})).unwrap();
assert_ne!(model_dump(&s), before);
}
#[test]
fn pedit_preselected_line_asks_to_convert() {
// Yes converts the pre-selected line and continues with the option prompt.
let mut s = Session::new();
let l = h(&s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap());
s.set_selection(vec![Handle::parse_hex(&l).unwrap()]);
s.cmdline("pedit").unwrap();
assert!(s.running.is_some());
assert!(matches!(kind(&s, &l), EntityKind::Line(_)), "nothing changes before the answer");
s.cmdline("y").unwrap();
assert!(matches!(kind(&s, &l), EntityKind::LwPolyline(_)));
s.cmdline("c").unwrap();
s.cmdline("").unwrap();
assert!(s.running.is_none());
// No ends the command and leaves the line alone.
let m = h(&s.execute("line", &json!({"points": [[0, 5], [10, 5]]})).unwrap());
s.set_selection(vec![Handle::parse_hex(&m).unwrap()]);
s.cmdline("pedit").unwrap();
s.cmdline("n").unwrap();
assert!(s.running.is_none());
assert!(matches!(kind(&s, &m), EntityKind::Line(_)));
}
#[test]
fn offset_polyline_arc_bulge_follows_joined_endpoints() {
let mut s = Session::new();
// A diagonal line into a CCW semicircle centred on (10, 5); offsetting inward moves the arc start.
let pl = pline(&mut s, json!([[0, -5], {"p": [10, 0], "bulge": 1}, [10, 10]]), false);
s.execute("offset", &json!({"handle": pl, "distance": 1, "side": [12, 5]})).unwrap();
let p = poly(&last(&s));
let (a, b) = (p.vertices[1], p.vertices[2]);
let (arc, ccw) = cadcraft_geom::bulge_to_arc(a.p, b.p, a.bulge).unwrap();
assert!(ccw);
assert!(near(arc.center, 10.0, 5.0), "arc centre drifted to {:?}", arc.center);
assert!((arc.radius - 4.0).abs() < 1e-6, "radius {}", arc.radius);
}
+3 -3
View File
@@ -364,9 +364,9 @@ fn trim(v: f64) -> String {
/// The JSON form's paragraph formatting: `list` (`bullet`, `number`, `lower`, `upper`: each
/// paragraph labelled, with a hanging `indent`), or `indent` / `firstIndent` / `rightIndent`
/// (text heights), `tabs` (tab stops in text heights) and `align` (`left`, `center`, `right`,
/// (drawing units; a list's default is 3 text heights), `tabs` (tab stops, drawing units) and `align` (`left`, `center`, `right`,
/// `justify`, `distributed`) as a `\p` code in front of the text.
pub(super) fn json_paragraphs(p: &serde_json::Value, text: String) -> Result<String> {
pub(super) fn json_paragraphs(p: &serde_json::Value, text: String, height: f64) -> Result<String> {
use cadcraft_fonts::{ListStyle, ParaAlign, ParaProps, TabKind};
let bad = |m: &str| super::bad("mtext", m);
let num = |k: &str| -> Result<Option<f64>> {
@@ -384,7 +384,7 @@ pub(super) fn json_paragraphs(p: &serde_json::Value, text: String) -> Result<Str
_ => return Err(bad("`list` must be bullet, number, lower or upper")),
};
let paras: Vec<&str> = text.split("\\P").collect();
return Ok(cadcraft_fonts::list_contents(&paras, style, num("indent")?.unwrap_or(3.0)));
return Ok(cadcraft_fonts::list_contents(&paras, style, num("indent")?.unwrap_or(3.0 * height)));
}
let mut props = ParaProps {
left: num("indent")?.unwrap_or(0.0).max(0.0),
+11
View File
@@ -33,6 +33,17 @@ pub const PROFILE_VARS: &[&str] = &[
"OSNAPHATCH",
"LWDISPLAY",
"QPMODE",
"SELECTIONEFFECT",
"SELECTIONPREVIEW",
"PREVIEWEFFECT",
"PREVIEWFILTER",
"GRIPS",
"GRIPOBJLIMIT",
"SELECTIONAREA",
"WINDOWAREACOLOR",
"CROSSINGAREACOLOR",
"SELECTIONAREAOPACITY",
"PICKSTYLE",
"PICKBOX",
"APERTURE",
"GRIPSIZE",
+13 -3
View File
@@ -286,9 +286,9 @@ pub(crate) fn plot_options(s: &Session, p: &Value, cmd: &str) -> Result<Value> {
let layout = match str_param(p, "layout") {
Some(n) if n.eq_ignore_ascii_case("model") => None,
Some(n) => s.doc()?.layout(n).map(|l| l.page.clone()),
None => match s.space() {
cadcraft_doc::Space::Paper(n) => s.doc()?.layout(&n).map(|l| l.page.clone()),
cadcraft_doc::Space::Model => None,
None => match s.space().layout() {
Some(n) => s.doc()?.layout(n).map(|l| l.page.clone()),
None => None,
},
};
let dev = match given {
@@ -326,6 +326,16 @@ pub(crate) fn plot_options(s: &Session, p: &Value, cmd: &str) -> Result<Value> {
Ok(out)
}
/// Whether a plot draws transparency, as PLOTTRANSPARENCYOVERRIDE decides: 0 never, 2 always,
/// otherwise the plot's `plotTransparency` (`None` when it has none: the page setup decides).
pub(crate) fn plot_transparency(s: &Session, p: &Value) -> Option<bool> {
match s.settings.plot_transparency_override {
0 => Some(false),
2 => Some(true),
_ => p.get("plotTransparency").and_then(Value::as_bool),
}
}
// ---------- PLOTWINDOW ----------
fn run_plotwindow(s: &mut Session, p: &Value) -> Result<Value> {
+7
View File
@@ -341,6 +341,12 @@ fn run_set(s: &mut Session, p: &Value) -> Result<Value> {
q.y = r.y;
}
t.rotation = cadcraft_geom::norm_angle(t.rotation + turn);
if let Some(ml) = &mut a.multiline {
let r = ml.mtext.insert.xy().rotate_about(base, turn);
ml.mtext.insert.x = r.x;
ml.mtext.insert.y = r.y;
ml.mtext.rotation = cadcraft_geom::norm_angle(ml.mtext.rotation + turn);
}
}
}
if let Some(x) = num("scale").filter(|x| *x != 0.0) {
@@ -353,6 +359,7 @@ fn run_set(s: &mut Session, p: &Value) -> Result<Value> {
if let EntityKind::Text(t) = k {
a.text = t;
}
a.transform_multiline(&m);
}
}
i.scale = Vec3::new(x, x, x);
+8 -5
View File
@@ -178,7 +178,9 @@ pub(crate) fn zoom(s: &mut Session, p: &Value) -> Result<Value> {
}
_ => return Err(bad("zoom", "unknown mode")),
}
s.touch();
// No `touch`: a zoom changes the view, not the drawing. The canvas follows the view by
// itself and regenerates in the background (AutoCAD redraws instead of regenerating);
// zooming an unlocked viewport changes the drawing, which shows on its own.
Ok(Value::Null)
}
@@ -265,7 +267,9 @@ fn run_view_set(s: &mut Session, p: &Value) -> Result<Value> {
if h <= 0.0 {
return Err(bad("view.set", "height must be positive"));
}
s.state_mut()?.set_view(View { center: c, height: h });
if !s.state_mut()?.set_view(View { center: c, height: h }) {
return Err(bad("view.set", "the view is too large to show. Nothing was changed"));
}
ok()
}
@@ -277,6 +281,7 @@ fn run_layout_set(s: &mut Session, p: &Value) -> Result<Value> {
let l = s.doc()?.layout(name).ok_or_else(|| bad("layout.set", format!("no layout `{name}`")))?;
Space::Paper(l.name.clone())
};
s.pending_window = None;
let st = s.state_mut()?;
st.space = space.clone();
st.mspace = None;
@@ -382,7 +387,6 @@ impl ZoomM {
let extents = if ext.is_empty() { s.zoom_limits()? } else { ext };
let all = extents.union(&DynBox::rect(frame.center, frame.height, aspect));
fit(s, all.expand(all.height().max(all.width()) * 0.05))?;
s.touch();
self.stage = ZoomStage::Dynamic(DynBox { screen, frame, extents, center: frame.center, height: frame.height, aspect, sizing: false });
Ok(Step::Continue)
}
@@ -420,7 +424,7 @@ impl Interactive for ZoomM {
.kw(&["All", "Center", "Dynamic", "Extents", "Previous", "Scale", "Window", "Object"])
.default("real time"),
(true, None) => Prompt::new("Specify first corner", Accept::POINT),
(_, Some(a)) => Prompt::new("Specify opposite corner", Accept::POINT).base(a),
(_, Some(a)) => Prompt::new("Specify opposite corner", Accept::POINT).corner(a),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
@@ -437,7 +441,6 @@ impl Interactive for ZoomM {
st.view_history.pop();
}
st.set_zoom_frame(View { center: b.center, height: b.height });
s.touch();
return Ok(Step::Done);
}
(ZoomStage::Dynamic(mut b), Input::Point(p)) => {
+3 -3
View File
@@ -152,10 +152,10 @@ fn restore(s: &mut Session, name: &str) -> Result<Value> {
match &v.layout {
Some(l) => {
// A paper space view: its layout (an unknown one: the current or first layout).
let target = match (l.is_empty(), s.layout_space()) {
let target = match (l.is_empty(), s.layout_space().layout()) {
(false, _) => l.clone(),
(true, Space::Paper(cur)) => cur,
(true, Space::Model) => s.doc()?.layouts.iter().min_by_key(|x| x.tab_order).map(|x| x.name.clone()).unwrap_or_default(),
(true, Some(cur)) => cur.to_string(),
(true, None) => s.doc()?.layouts.iter().min_by_key(|x| x.tab_order).map(|x| x.name.clone()).unwrap_or_default(),
};
if s.doc()?.layout(&target).is_none() {
return Err(EngineError::Other(format!("View \"{}\" belongs to layout \"{target}\", which does not exist.", v.name)));
+10 -1
View File
@@ -22,7 +22,7 @@ pub(crate) struct Guarded {
broken: Cell<Option<&'static str>>,
}
fn message(p: &(dyn Any + Send)) -> String {
pub(crate) fn message(p: &(dyn Any + Send)) -> String {
p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_else(|| "panic".into())
}
@@ -84,6 +84,15 @@ impl Interactive for Guarded {
None
})
}
fn editing_mtext(&self) -> Option<cadcraft_doc::MText> {
if self.broken.get().is_some() {
return None;
}
catch_unwind(AssertUnwindSafe(|| self.inner.editing_mtext())).unwrap_or_else(|_| {
self.broken.set(Some("panic in prompt"));
None
})
}
}
#[cfg(test)]
+328 -31
View File
@@ -11,11 +11,14 @@
pub mod assoc;
pub mod audit;
pub mod autosave;
pub mod blockedit;
pub mod cmd;
mod finite;
pub mod grip_dims;
pub mod grips;
mod guard;
pub mod lisp;
pub mod mtext_edit;
pub mod pointmod;
pub mod prompt;
mod recent;
@@ -37,7 +40,7 @@ use serde_json::Value;
pub use cadcraft_doc as doc;
pub use cadcraft_geom as geom;
pub use cadcraft_render as render;
pub use cmd::hatch::{PatternChoice, pattern_choices};
pub use cmd::hatch::{HatchPicks, PatternChoice, PickMode, pattern_choices};
pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command};
pub use prompt::{Accept, Drag, Input, Interactive, Picker, Prompt, Step};
@@ -79,6 +82,10 @@ impl Default for View {
}
}
fn usable_view(v: &View) -> bool {
v.center.is_finite() && v.height.is_finite() && v.height > 1e-12
}
/// Tiled model viewports (VPORTS): the session side of the drawing's `*Active` configuration.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ModelTiles {
@@ -132,6 +139,9 @@ pub struct DocState {
pub uid: u64,
/// The drawing is saved as binary DXF (it was opened from one, or last saved as one).
pub binary_dxf: bool,
/// The Block Editor (BEDIT), when it is open: `doc` is then the editor's drawing, and the
/// drawing itself waits in it ([`DocState::drawing`]).
pub block_edit: Option<blockedit::BlockEdit>,
}
static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
@@ -157,10 +167,13 @@ impl DocState {
view_history: Vec::new(),
uid: NEXT_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
binary_dxf: false,
block_edit: None,
}
}
/// The drawing has changes not saved to its file, or the Block Editor has changes not yet
/// saved into its block.
pub fn is_dirty(&self) -> bool {
!Arc::ptr_eq(&self.doc, &self.saved)
!Arc::ptr_eq(self.drawing(), &self.saved) || self.block_unsaved()
}
/// After undo/redo replaced the drawing (`before` is the one shown until then): if the current
/// layout no longer exists, follow it to its other name when it was renamed (same tab order,
@@ -220,7 +233,9 @@ impl DocState {
}
}
/// Map a requested screen view to the paper view, or (in MSPACE) to the viewport's model view.
fn store_view(&mut self, v: View) {
/// A finite request can still map to a view too large to represent: then nothing changes and
/// this returns false.
fn store_view(&mut self, v: View) -> bool {
if let Some((h, vp)) = self.active_viewport() {
let pv = self.paper_view();
let k = v.height / pv.height.max(1e-12);
@@ -228,10 +243,17 @@ impl DocState {
// A locked viewport keeps its scale: pan/zoom the sheet instead.
let k0 = vp.view_height / vp.height;
let pc = vp.center.xy() + (v.center - vp.view_center) / k0;
self.put_paper_view(View { center: pc, height: v.height / k0 });
let paper = View { center: pc, height: v.height / k0 };
if !usable_view(&paper) {
return false;
}
self.put_paper_view(paper);
} else {
let center = v.center - (pv.center - vp.center.xy()) * k;
let height = vp.height * k;
if !usable_view(&View { center, height }) {
return false;
}
let doc = Arc::make_mut(&mut self.doc);
if let Some(store) = doc.space_mut(&self.space.clone()) {
store.modify(h, |e| {
@@ -242,9 +264,10 @@ impl DocState {
});
}
}
return;
return true;
}
self.put_paper_view(v);
true
}
fn put_paper_view(&mut self, v: View) {
match self.views.iter_mut().find(|(s, _)| *s == self.space) {
@@ -252,18 +275,23 @@ impl DocState {
None => self.views.push((self.space.clone(), v)),
}
}
pub fn set_view(&mut self, v: View) {
if !(v.center.is_finite() && v.height.is_finite() && v.height > 1e-12) {
return;
/// Set the view, recording view history. Returns false, changing nothing, when the view (or,
/// in MSPACE, the viewport view it maps to) isn't finite with a positive height.
pub fn set_view(&mut self, v: View) -> bool {
if !usable_view(&v) {
return false;
}
let old = self.view();
if !self.store_view(v) {
return false;
}
if old != v {
self.view_history.push(old);
if self.view_history.len() > 50 {
self.view_history.remove(0);
}
}
self.store_view(v);
true
}
/// The active MSPACE viewport when ZOOM works on its own view, as in AutoCAD: extents and
/// windows fit the viewport, and zooms keep its centre. A locked viewport keeps its scale, so
@@ -345,7 +373,11 @@ pub struct Settings {
/// prompt's options there (on by default).
pub dynprompt: bool,
pub lwdisplay: bool,
/// TRANSPARENCYDISPLAY: draw objects with their transparency on screen (on by default).
pub transparency_display: bool,
/// PLOTTRANSPARENCYOVERRIDE: 0 never plots transparency, 1 (default) uses the page setup's or
/// the Plot dialog's "Plot transparency", 2 always plots it.
pub plot_transparency_override: u8,
pub selection_cycling: bool,
/// SELECTSIMILARMODE: what SELECTSIMILAR matches besides the object type (bits: 1 colour,
/// 2 layer, 4 linetype, 8 linetype scale, 16 lineweight, 32 plot style, 64 object style,
@@ -357,6 +389,8 @@ pub struct Settings {
/// APERTURE in pixels.
pub aperture: f64,
pub pickfirst: bool,
/// ACADLSPASDOC: acad.lsp loads into every drawing, not once per session.
pub acadlspasdoc: bool,
pub pickadd: bool,
/// PICKSTYLE: bit 1 picks whole selectable groups ([`cmd::groups`]), bit 2 associative hatches
/// with their boundaries (stored, not used yet).
@@ -373,6 +407,32 @@ pub struct Settings {
pub annoautoscale: bool,
/// QPMODE: show the Quick Properties palette when objects are selected.
pub qpmode: bool,
/// HIGHLIGHT: selected objects are highlighted (their grips show either way). Not saved:
/// every session starts with it on, as in AutoCAD, so it can't stay off unnoticed.
pub highlight: bool,
/// SELECTIONEFFECT: how selected objects are highlighted: 0 dashed, 1 redrawn in the
/// selection colour.
pub selectioneffect: u8,
/// SELECTIONPREVIEW bits: the object under the cursor is highlighted (1) when no command is
/// active, (2) when a command asks for objects.
pub selectionpreview: u8,
/// PREVIEWEFFECT: how that object is highlighted: 0 dashed, 1 thicker, 2 dashed and thicker.
pub previeweffect: u8,
/// PREVIEWFILTER bits: objects the cursor never highlights: 1 on locked layers, 2 xrefs,
/// 4 tables, 8 multiline text, 16 hatches, 32 in groups.
pub previewfilter: u8,
/// GRIPS: selected objects show their grips (0 off; AutoCAD's 2, more grips on polylines,
/// shows them as 1 does).
pub grips: u8,
/// GRIPOBJLIMIT: no grips while more objects than this are selected (0: no limit).
pub gripobjlimit: u32,
/// SELECTIONAREA: the selection window and crossing areas are filled.
pub selectionarea: bool,
/// WINDOWAREACOLOR, CROSSINGAREACOLOR: their colours (ACI 1-255).
pub windowareacolor: u8,
pub crossingareacolor: u8,
/// SELECTIONAREAOPACITY: their opacity, 0-100.
pub selectionareaopacity: u8,
/// POLARMODE bits ([`snap::tracking::polarmode`]); polar tracking itself is on while
/// `polarmode` is (AUTOSNAP bit 8, F10).
pub polar_flags: u32,
@@ -407,13 +467,15 @@ impl Default for Settings {
dynpi_absolute: false,
dynprompt: true,
lwdisplay: false,
transparency_display: false,
transparency_display: true,
plot_transparency_override: 1,
selection_cycling: false,
selectsimilarmode: 130,
otrack: true,
pickbox: 3.0,
aperture: 10.0,
pickfirst: true,
acadlspasdoc: false,
pickadd: true,
pickstyle: 1,
gripsize: 5.0,
@@ -424,6 +486,17 @@ impl Default for Settings {
annoallvisible: true,
annoautoscale: false,
qpmode: false,
highlight: true,
selectioneffect: 1,
selectionpreview: 3,
previeweffect: 2,
previewfilter: 3,
grips: 1,
gripobjlimit: 100,
selectionarea: true,
windowareacolor: 150,
crossingareacolor: 100,
selectionareaopacity: 25,
polar_flags: 0,
polaraddang: Vec::new(),
polarsnap: false,
@@ -555,6 +628,11 @@ pub struct Session {
/// The last segment given at a point prompt (its base point and the point): polar angles
/// relative to the last segment (POLARMODE bit 1) are measured from it.
pub last_segment: Option<(Vec2, Vec2)>,
/// The last typed `@distance<angle` input, as `(distance, angle in radians)`.
/// Set by [`Session::typed`] and consumed by commands that resolve a deferred
/// tangent first point (e.g. [`cmd::draw::LineM`]) when the next point is typed
/// rather than clicked. Cleared at [`Session::start`].
pub last_polar: Option<(f64, f64)>,
/// Point modifiers pending at the current point prompt (`FROM`, `M2P`, `.x`, `<a`…).
pub point_mods: Vec<pointmod::Frame>,
/// Cursor position in world coordinates (from the UI; used for direct distance entry).
@@ -587,6 +665,12 @@ pub struct Session {
pub clipboard_source: Option<cmd::clipboard::ClipSource>,
/// The window last picked with PLOTWINDOW (the Plot dialog's "Window <").
pub plot_window: Option<cadcraft_geom::Bounds2>,
/// AutoLISP: each drawing's symbols and the routine running ([`lisp`]).
lisp: lisp::LispState,
/// How the last LISP run ended (its value or error message).
lisp_last: Option<std::result::Result<lisp::value::Value, String>>,
/// What the last HATCHPICK kept (the Hatch and Gradient dialog's picks).
pub hatch_picks: Option<cmd::hatch::HatchPicks>,
}
impl Default for Session {
@@ -614,6 +698,7 @@ impl Session {
last_command: None,
last_point: Vec2::ZERO,
last_segment: None,
last_polar: None,
point_mods: Vec::new(),
cursor: Vec2::ZERO,
cursor_deferred: None,
@@ -631,6 +716,9 @@ impl Session {
last_used: LastUsed::default(),
clipboard_source: None,
plot_window: None,
lisp: lisp::LispState::default(),
lisp_last: None,
hatch_picks: None,
}
}
pub fn new_drawing(&mut self, metric: bool) -> usize {
@@ -638,6 +726,7 @@ impl Session {
let d = if metric { Drawing::new_metric() } else { Drawing::new_imperial() };
let mut st = DocState::new(d, &format!("Drawing{}", self.untitled_counter), None);
st.set_view_quiet(if metric { View { center: Vec2::new(210.0, 148.5), height: 297.0 } } else { View::default() });
self.end_running();
self.docs.push(st);
self.active = self.docs.len() - 1;
self.active
@@ -651,10 +740,32 @@ impl Session {
height: (ext.height().max(ext.width() * self.viewport_px.1 / self.viewport_px.0.max(1.0)) * 1.1).max(1e-6),
});
}
self.end_running();
self.docs.push(st);
self.active = self.docs.len() - 1;
self.active
}
/// A command still waiting for input belongs to the drawing it started in: end it there
/// before another drawing becomes current, so its undo step lands in that drawing.
fn end_running(&mut self) {
if self.running.is_some() {
self.cancel();
}
}
/// Add a file read off-thread with [`cmd::file::read_file`] as the active drawing (OPEN's fast
/// half). A panic while adding it is reported as an error, never propagated.
pub fn finish_open(&mut self, f: cmd::file::ReadFile) -> Result<Value> {
let n = self.docs.len();
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| cmd::file::finish_open(self, f))) {
Ok(r) => r,
Err(_) => {
self.docs.truncate(n);
self.active = self.active.min(self.docs.len().saturating_sub(1));
Err(EngineError::Internal("open".into(), "panic".into()))
}
}
}
pub fn state(&self) -> Result<&DocState> {
self.docs.get(self.active).ok_or(EngineError::NoDocument)
@@ -752,7 +863,11 @@ impl Session {
pub fn execute(&mut self, id: &str, params: &Value) -> Result<Value> {
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?;
(spec.enabled)(self).map_err(|m| EngineError::Disabled(spec.id.into(), m))?;
if let Some(why) = cmd::blocks::bedit::refused_in_editor(self, spec.id) {
return Err(EngineError::Disabled(spec.id.into(), why));
}
let before = self.state().ok().map(|s| (s.doc.clone(), s.selection.clone(), s.uid));
let editing = self.state().is_ok_and(|s| s.block_edit.is_some());
let run = spec.run;
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run(self, params)));
let result = match result {
@@ -770,19 +885,29 @@ impl Session {
return Err(EngineError::Internal(id.into(), msg));
}
};
cmd::groups::after_command(self, before.as_ref().map(|b| &b.0));
cmd::constraints::after_command(self, before.as_ref().map(|b| &b.0), spec.undoable && result.is_ok());
assoc::after_command(self, before.as_ref().map(|b| &b.0), before.as_ref().map(|b| b.2));
let violation = before.as_ref().and_then(|b| cmd::blocks::refedit::after_command(self, &b.0, spec.id));
let result = match (violation, result) {
(Some(m), Ok(_)) => Err(EngineError::Other(m)),
(_, r) => r,
};
// Opening or closing the Block Editor swaps the whole drawing: nothing in it changed.
if self.state().is_ok_and(|s| s.block_edit.is_some()) == editing {
self.after_command(before.as_ref().map(|b| &b.0), before.as_ref().map(|b| b.2), spec.undoable && result.is_ok());
}
let result = match (result, &before) {
(Ok(v), Some((doc, sel, uid))) if spec.undoable => self.refuse_non_finite(Some(*uid), doc, sel, spec.id).map(|()| v),
(r, _) => r,
};
let grouped = self.lisp_groups_undo();
if spec.undoable
&& let Some((doc, sel, uid)) = before
&& let Some(st) = self.docs.iter_mut().find(|d| d.uid == uid)
&& !Arc::ptr_eq(&doc, &st.doc)
{
st.push_undo(Snapshot { label: spec.label.to_string(), doc, selection: sel });
// Inside a LISP routine the routine's single undo step records it.
if !grouped {
st.push_undo(Snapshot { label: spec.label.to_string(), doc, selection: sel });
}
st.revision += 1;
}
result
@@ -799,6 +924,7 @@ impl Session {
/// Start a command by name or alias as if typed at the command line.
pub fn start(&mut self, name: &str) -> Result<()> {
self.last_polar = None;
let name = name.trim();
let (transparent, name) = match name.strip_prefix('\'') {
Some(n) => (true, n),
@@ -808,7 +934,24 @@ impl Session {
// A command-line form with an id of its own (-ATTEDIT) is not the command it prefixes.
let dashed = cmd::resolve_alias(&name.trim_start_matches(['_', '.']).to_ascii_lowercase());
let id = if find_command(&dashed).is_some() { dashed } else { cmd::resolve_alias(&lower) };
let spec = find_command(&id).ok_or_else(|| EngineError::UnknownCommand(name.to_string()))?;
let Some(spec) = find_command(&id) else {
// A command defined in LISP: (defun c:NAME …).
if !transparent && let Some(f) = self.lisp_command(&lower) {
if self.running.is_some() {
self.cancel();
}
self.last_command = Some(lower.clone());
self.echo(format!("Command: {}", lower.to_ascii_uppercase()));
return self.lisp_run_command(&lower, f);
}
// A bare system variable name (ORTHOMODE, GRIDMODE…) acts as SETVAR on that variable.
let var = lower.to_ascii_uppercase();
if !name.starts_with(['_', '.', '-']) && sysvars::get(self, &var).is_some() {
self.start(if transparent { "'setvar" } else { "setvar" })?;
return self.input(Input::Text(var));
}
return Err(EngineError::UnknownCommand(name.to_string()));
};
if transparent && spec.transparent && self.running.is_some() {
// Transparent commands (zoom, pan…) run without cancelling the active one.
if spec.interactive.is_none() {
@@ -821,10 +964,19 @@ impl Session {
}
}
let nested = transparent && spec.transparent && self.running.is_some() && spec.interactive.is_some();
// Ctrl+Z / U / UNDO while a command's prompt offers its own Undo keyword (LINE, PLINE…)
// takes back the last segment and keeps the command running, as the keyword does.
if !nested && matches!(spec.id, "u" | "undo") && self.current_prompt().is_some_and(|p| p.keywords.iter().any(|k| k == "Undo")) {
self.echo(format!("Command: {}", spec.id.to_ascii_uppercase()));
return self.input(Input::Keyword("Undo".into()));
}
if !nested && self.running.is_some() {
self.cancel();
}
(spec.enabled)(self).map_err(|m| EngineError::Disabled(spec.id.into(), m))?;
if let Some(why) = cmd::blocks::bedit::refused_in_editor(self, spec.id) {
return Err(EngineError::Disabled(spec.id.into(), why));
}
if nested {
// The outer command stays the one Enter repeats.
self.echo(format!(">>{}", spec.id.to_ascii_uppercase()));
@@ -874,8 +1026,18 @@ impl Session {
}
/// Give an input to the running command. With no command running, `Enter` repeats the last
/// command (as in AutoCAD) and points do nothing.
/// command (as in AutoCAD) and points do nothing. A LISP routine waiting in a `get…`
/// function takes the input instead; one paused in `command` carries on after it.
pub fn input(&mut self, input: Input) -> Result<()> {
if self.lisp_input(input.clone()) {
return Ok(());
}
let r = self.input_to_command(input);
self.lisp_after_command_input();
r
}
fn input_to_command(&mut self, input: Input) -> Result<()> {
if self.running.is_none() {
if input == Input::Enter
&& let Some(last) = self.last_command.clone()
@@ -1108,11 +1270,13 @@ impl Session {
self.select_mode = None;
let label = find_command(&run.id).map(|c| c.label).unwrap_or("Command");
let _ = cancelled;
cmd::groups::after_command(self, Some(&run.before));
cmd::constraints::after_command(self, Some(&run.before), true);
assoc::after_command(self, Some(&run.before), None);
if self.suspended.is_some() {
// A transparent command's changes belong to the undo step of the command it ran inside.
if let Some(m) = cmd::blocks::refedit::after_command(self, &run.before, &run.id) {
self.echo(m);
}
self.after_command(Some(&run.before), None, true);
// A transparent command's changes belong to the command it ran inside, and a command a
// LISP routine ran belongs to the routine's single undo step.
if self.suspended.is_some() || self.lisp_groups_undo() {
if let Ok(st) = self.state_mut()
&& !Arc::ptr_eq(&run.before, &st.doc)
{
@@ -1128,7 +1292,20 @@ impl Session {
}
}
/// After a transparent command ends, the command it interrupted prompts again.
/// The post-command passes, in order, run after every command — by [`Session::execute`] and
/// when an interactive command finishes — so their edits land in the command's own undo step:
/// group membership is pruned, constraints are inferred, purged and re-solved, and
/// associative dimensions and hatches follow their sources. A new pass is added here, once.
///
/// `before` is the drawing the command started from; `uid` (when known) is the document it
/// started in, so associativity is not applied across a document switch; `ok` is false for a
/// failed or non-undoable programmatic command, which skips the constraint pass.
fn after_command(&mut self, before: Option<&Arc<Drawing>>, uid: Option<u64>, ok: bool) {
cmd::groups::after_command(self, before);
cmd::constraints::after_command(self, before, ok);
assoc::after_command(self, before, uid);
}
fn resume_suspended(&mut self) {
if let Some(outer) = self.suspended.take() {
self.echo(format!("Resuming {} command.", outer.id.to_ascii_uppercase()));
@@ -1140,6 +1317,15 @@ impl Session {
/// transparent command, cancels it and the command it interrupted.
pub fn cancel(&mut self) {
self.point_mods.clear();
// Esc stops a LISP routine waiting for input, and the command it paused in.
if self.lisp_waiting() {
if self.running.is_some() {
let _ = self.feed(Some(Input::Cancel));
}
self.echo("*Cancel*");
self.lisp_abort("Function cancelled");
return;
}
if self.running.is_some() {
if self.suspended.is_some() {
if let Some(nested) = self.running.take() {
@@ -1154,7 +1340,16 @@ impl Session {
}
}
/// The multiline text the running command is collecting, for the in-place editor
/// ([`Interactive::editing_mtext`]).
pub fn editing_mtext(&self) -> Option<cadcraft_doc::MText> {
self.running.as_ref().and_then(|r| r.machine.editing_mtext())
}
pub fn current_prompt(&self) -> Option<Prompt> {
if let Some(p) = self.lisp_prompt() {
return Some(p);
}
self.running.as_ref().map(|r| {
let p = r.machine.prompt(self);
match &self.select_mode {
@@ -1174,8 +1369,26 @@ impl Session {
}
}
/// Whether the object under the cursor is highlighted (SELECTIONPREVIEW): the cursor picks
/// objects ([`Self::picking_objects`]) and the bit for a command asking for them (2) or for
/// no command (1) is on.
pub fn previews_selection(&self) -> bool {
let bit = if self.current_prompt().is_some() { 2 } else { 1 };
self.picking_objects() && self.settings.selectionpreview & bit != 0
}
/// Whether selected objects show grips while `selected` objects are selected (GRIPS,
/// GRIPOBJLIMIT).
pub fn shows_grips(&self, selected: usize) -> bool {
let limit = self.settings.gripobjlimit;
self.settings.grips > 0 && (limit == 0 || selected <= limit as usize)
}
/// The text shown on the command line: the active prompt, or "Command:".
pub fn prompt_text(&self) -> String {
if let Some(p) = self.lisp_prompt() {
return p.display();
}
match &self.running {
Some(r) if self.suspended.is_some() => format!(">>{} {}", r.id.to_ascii_uppercase(), r.machine.prompt(self).display()),
Some(r) => format!("{} {}", r.id.to_ascii_uppercase(), r.machine.prompt(self).display()),
@@ -1185,9 +1398,13 @@ impl Session {
/// Rubber-band preview entities for the cursor.
pub fn preview(&self, cursor: Vec2) -> Vec<Entity> {
let Some(r) = &self.running else { return Vec::new() };
let common = self.current_common();
r.machine.preview(self, cursor).into_iter().map(|k| Entity { handle: Handle(0), common: common.clone(), kind: k }).collect()
let kinds = match (self.lisp_preview(cursor), &self.running) {
(Some(k), _) => k,
(None, Some(r)) => r.machine.preview(self, cursor),
(None, None) => Vec::new(),
};
kinds.into_iter().map(|k| Entity { handle: Handle(0), common: common.clone(), kind: k }).collect()
}
/// The active command's preview as a transform of existing objects (see
@@ -1245,14 +1462,17 @@ impl Session {
return self.typed(rest);
}
first = false;
let (tok, more) = match rest.split_once([' ', '\t']) {
Some((tok, more)) => (tok, Some(more)),
None => (rest, None),
};
if self.running.is_some() {
let (tok, more) = next_token(rest);
if self.running.is_some() || self.lisp_prompt().is_some() {
self.typed(tok)?;
} else if tok.trim().is_empty() {
self.input(Input::Enter)?;
} else if lisp_expression(tok) {
// `(expression)` or `!variable` at Command: evaluates LISP and shows the value.
let src = tok.trim().strip_prefix('!').unwrap_or(tok.trim()).to_string();
if let Err(e) = self.lisp_eval(&src, true, false, Vec::new()) {
self.echo(e.to_string());
}
} else {
self.start(tok)?;
}
@@ -1278,17 +1498,47 @@ impl Session {
if tt.starts_with('\'') {
return self.start(tt);
}
// A LISP expression typed at a command's prompt: its value is the answer.
// Not at a free-text prompt: there the text is the answer (CAL takes "(3+4)*2").
if lisp_expression(tt) && !prompt.accept.text && self.lisp_prompt().is_none() && !self.lisp_groups_undo() {
let src = tt.strip_prefix('!').unwrap_or(tt).to_string();
let v = match self.lisp_eval(&src, false, true, Vec::new()) {
Ok(Some(Ok(v))) => v,
Ok(_) => return Ok(()),
Err(e) => {
self.echo(e.to_string());
return Ok(());
}
};
return match v {
lisp::value::Value::Nil => Ok(()),
lisp::value::Value::Int(i) => self.typed(&i.to_string()),
lisp::value::Value::Real(r) => self.typed(&format!("{r}")),
lisp::value::Value::Str(s) => self.typed(&s),
other => match other.as_point() {
Some(p) => self.input(Input::Point(Vec2::new(p[0], p[1]))),
None => {
self.echo(format!("Invalid input: {}", lisp::value::to_text(&other, true)));
Ok(())
}
},
};
}
// At "Select objects" prompts the selection modes (Previous, Last, ALL) win over a command
// keyword sharing the letter (HATCH's "picK internal point" against `P`).
let selection_mode = prompt.accept.select && matches!(tt.to_ascii_lowercase().as_str(), "all" | "l" | "last" | "p" | "previous");
// A leading underscore asks for a keyword by its English name, whatever the interface
// language (`_M`, `_Yes`): scripts and LISP routines write them that way. (Snap
// overrides such as `_cen` were taken above.)
if !selection_mode
&& let Some(k) = prompt.match_keyword(tt)
&& let Some(k) = prompt.match_keyword(tt).or_else(|| tt.strip_prefix('_').and_then(|r| prompt.match_keyword(r)))
&& !(prompt.accept.number && tt.parse::<f64>().is_ok())
{
return self.input(Input::Keyword(k));
}
if prompt.accept.point {
if let Some(p) = prompt::parse_point_with(tt, self.last_point, &self.angle_settings()) {
self.last_polar = prompt::parse_polar_text(tt, &self.angle_settings());
return self.input(Input::Point(p));
}
// Direct distance entry along the tracking path the cursor is on, from the point it
@@ -1303,7 +1553,7 @@ impl Session {
&& !prompt.accept.number
{
let mut c = self.cursor;
if self.settings.orthomode {
if self.settings.orthomode && !prompt.corner {
c = snap::iso::constrain(&self.drafting_settings(), base, c);
}
let dir = (c - base).normalized();
@@ -1479,6 +1729,7 @@ impl Session {
serde_json::json!({
"index": i, "title": d.title, "path": d.path, "dirty": d.is_dirty(),
"entities": d.doc.entity_count(), "active": i == self.active, "revision": d.revision,
"blockEditor": d.block_editor(),
})
})
.collect();
@@ -1492,5 +1743,51 @@ impl Session {
}
}
/// Typed text that is LISP: `(expression)` or `!variable`.
fn lisp_expression(t: &str) -> bool {
let t = t.trim();
t.starts_with('(') || (t.starts_with('!') && t.len() > 1)
}
/// The next command-line input in `rest` and what follows it: up to a space or tab, except that
/// a LISP expression runs to its closing parenthesis (spaces and quoted strings inside it).
fn next_token(rest: &str) -> (&str, Option<&str>) {
let lead = rest.len() - rest.trim_start_matches('!').len();
if rest.get(lead..).is_some_and(|r| r.starts_with('(')) {
let (mut depth, mut in_str, mut escaped) = (0i32, false, false);
for (i, c) in rest.char_indices().skip(lead) {
if in_str {
match (escaped, c) {
(true, _) => escaped = false,
(false, '\\') => escaped = true,
(false, '"') => in_str = false,
_ => {}
}
continue;
}
match c {
'"' => in_str = true,
'(' => depth += 1,
')' => {
depth -= 1;
if depth == 0 {
let end = i + 1;
// As after any input, a space ends it and a trailing one is Enter.
let more = rest.get(end..).filter(|m| !m.is_empty()).map(|m| m.strip_prefix([' ', '\t']).unwrap_or(m));
return (&rest[..end], more);
}
}
_ => {}
}
}
// Unbalanced: all of it (the reader reports the error).
return (rest, None);
}
match rest.split_once([' ', '\t']) {
Some((tok, more)) => (tok, Some(more)),
None => (rest, None),
}
}
#[cfg(test)]
mod tests;
File diff suppressed because it is too large. Load diff
+962
View File
@@ -0,0 +1,962 @@
//! Drawing access from LISP: `command`, system variables, entity lists (`entget`, `entmake`,
//! `entmod`, `entdel`, `entlast`, `entnext`, `handent`), selection sets (`ssget` and friends),
//! symbol tables (`tblsearch`, `tblnext`) and unit conversions (`rtos`, `angtos`, `distof`,
//! `angtof`).
//!
//! Entity lists use the DXF groups the file reader and writer use (through
//! [`crate::cmd::file::entity_codec`]), so what `entget` shows is what a DXF file holds.
use std::sync::{Arc, Mutex};
use cadcraft_doc::{Entity, Handle, Space};
use cadcraft_dxf::Tag;
use cadcraft_geom::Vec2;
use super::LispError;
use super::builtins::args_err;
use super::eval::{Ctx, Flow, Need};
use super::value::{PickSet, Value, to_text};
use crate::{Input, Session};
fn arity(a: &[Value], min: usize, max: usize) -> Result<(), LispError> {
if a.len() < min {
return Err(args_err(true));
}
if a.len() > max {
return Err(args_err(false));
}
Ok(())
}
fn ename(v: &Value) -> Result<Handle, LispError> {
match v {
Value::Ename(h) => Ok(*h),
other => Err(LispError::bad_arg("lentityp", other)),
}
}
fn string(v: &Value) -> Result<String, LispError> {
match v {
Value::Str(s) => Ok(s.to_string()),
other => Err(LispError::bad_arg("stringp", other)),
}
}
fn pickset(v: &Value) -> Result<PickSet, LispError> {
match v {
Value::PickSet(p) => Ok(p.clone()),
other => Err(LispError::bad_arg("lselsetp", other)),
}
}
fn new_pickset(ctx: &mut Ctx<'_>, items: Vec<Handle>) -> Value {
let id = ctx.env.next_pickset;
ctx.env.next_pickset = ctx.env.next_pickset.wrapping_add(1);
Value::PickSet(PickSet { id, items: Arc::new(Mutex::new(items)) })
}
fn items(p: &PickSet) -> Vec<Handle> {
p.items.lock().map(|v| v.clone()).unwrap_or_default()
}
// ---------------- command ----------------
/// `(command arg…)`: arguments go to the command line as typed input: a string starts a command
/// or answers a prompt ("" is Enter), a number or a point is entered, an entity name or a
/// selection set picks objects, and `pause` waits for the user. A command not finished by the
/// arguments stays active for the next `command` call. No arguments: cancel the active command.
pub fn command(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
if a.is_empty() {
if ctx.s.running.is_some() {
ctx.s.cancel();
}
return Ok(Flow::Value(Value::Nil));
}
for (i, arg) in a.iter().enumerate() {
match arg {
Value::Nil => {}
Value::Str(s) if &**s == "\\" => {
// `pause`: the user answers this prompt, then the rest goes in.
if ctx.s.running.is_some() {
let rest = a[i + 1..].to_vec();
let f = super::eval::lookup(ctx.env, &super::value::Sym::new("COMMAND"));
return Ok(if rest.is_empty() { Flow::Wait(Need::Pause) } else { Flow::WaitThen(Need::Pause, f, rest) });
}
}
Value::Str(s) => {
let r = if ctx.s.running.is_none() {
if s.trim().is_empty() { Ok(()) } else { ctx.s.start(s) }
} else if s.is_empty() {
ctx.s.input(Input::Enter)
} else {
ctx.s.typed(s)
};
if let Err(e) = r {
ctx.s.echo(e.to_string());
}
}
Value::Int(_) | Value::Real(_) => {
if ctx.s.running.is_some() {
let text = match arg {
Value::Int(n) => n.to_string(),
Value::Real(r) => format!("{r}"),
_ => String::new(),
};
if let Err(e) = ctx.s.typed(&text) {
ctx.s.echo(e.to_string());
}
}
}
Value::Ename(h) => feed(ctx.s, Input::Pick(vec![*h])),
Value::PickSet(p) => feed(ctx.s, Input::Pick(items(p))),
Value::Cons(_) => {
if let Some(p) = arg.as_point() {
feed(ctx.s, Input::Point(Vec2::new(p[0], p[1])));
} else if let Value::Ename(h) = arg.car() {
// An `entsel` result: (ename point).
feed(ctx.s, Input::Pick(vec![h]));
} else {
return Err(LispError::bad_arg("command argument", arg));
}
}
other => return Err(LispError::bad_arg("command argument", other)),
}
}
Ok(Flow::Value(Value::Nil))
}
fn feed(s: &mut Session, input: Input) {
if s.running.is_some()
&& let Err(e) = s.input(input)
{
s.echo(e.to_string());
}
}
// ---------------- system variables ----------------
/// A JSON value as LISP: integers, reals, strings, lists (points).
pub fn from_json(v: &serde_json::Value) -> Value {
match v {
serde_json::Value::Null => Value::Nil,
serde_json::Value::Bool(b) => Value::Int(i32::from(*b)),
serde_json::Value::Number(n) => match n.as_i64().and_then(|i| i32::try_from(i).ok()) {
Some(i) => Value::Int(i),
None => Value::Real(n.as_f64().unwrap_or(0.0)),
},
serde_json::Value::String(s) => Value::str(s),
serde_json::Value::Array(items) => Value::list(items.iter().map(from_json).collect::<Vec<_>>()),
serde_json::Value::Object(_) => Value::str(&v.to_string()),
}
}
/// A LISP value as JSON (for `setvar`).
pub fn to_json(v: &Value) -> serde_json::Value {
match v {
Value::Nil => serde_json::Value::Null,
Value::Int(i) => serde_json::json!(i),
Value::Real(r) => serde_json::json!(r),
Value::Str(s) => serde_json::json!(&**s),
Value::Cons(_) => serde_json::Value::Array(v.iter().map(|x| to_json(&x)).collect()),
other => serde_json::json!(to_text(other, false)),
}
}
pub fn getvar(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
arity(&a, 1, 1)?;
let name = string(&a[0])?;
Ok(Flow::Value(crate::sysvars::get(ctx.s, &name).map(|v| from_json(&v)).unwrap_or_default()))
}
pub fn setvar(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
arity(&a, 2, 2)?;
let name = string(&a[0])?;
match crate::sysvars::set(ctx.s, &name, &to_json(&a[1])) {
Ok(()) => Ok(Flow::Value(a[1].clone())),
Err(_) => Err(LispError::new(format!("AutoCAD variable setting rejected: {} {}", to_text(&a[0], true), to_text(&a[1], true)))),
}
}
// ---------------- entity lists ----------------
/// Groups whose value is a point: 10–18, 110–112, 210, 1010–1013 (x), with y and z 10 and 20
/// higher.
fn point_group(code: i32) -> bool {
(10..=18).contains(&code) || (110..=112).contains(&code) || code == 210 || (1010..=1013).contains(&code)
}
fn tag_value(t: &Tag) -> Value {
match &t.value {
cadcraft_dxf::Value::Str(s) | cadcraft_dxf::Value::Hex(s) => Value::str(s),
cadcraft_dxf::Value::Real(r) => Value::Real(*r),
cadcraft_dxf::Value::Int(i) => Value::Int(i32::try_from(*i).unwrap_or(if *i < 0 { i32::MIN } else { i32::MAX })),
cadcraft_dxf::Value::Bool(b) => Value::Int(i32::from(*b)),
}
}
/// DXF groups as an association list: points combined into lists `(10 x y z)`.
fn groups_to_list(tags: &[Tag]) -> Vec<Value> {
let mut out = Vec::new();
let mut i = 0;
while let Some(t) = tags.get(i) {
if point_group(t.code) {
let x = tag_value(t).as_number().unwrap_or(0.0);
let y = tags.get(i + 1).filter(|n| n.code == t.code + 10).and_then(|n| tag_value(n).as_number());
let z = tags.get(i + 2).filter(|n| n.code == t.code + 20 && y.is_some()).and_then(|n| tag_value(n).as_number());
match (y, z) {
(Some(y), Some(z)) => {
out.push(Value::list(vec![Value::Int(t.code), Value::Real(x), Value::Real(y), Value::Real(z)]));
i += 3;
}
(Some(y), None) => {
out.push(Value::list(vec![Value::Int(t.code), Value::Real(x), Value::Real(y)]));
i += 2;
}
_ => {
out.push(Value::cons(Value::Int(t.code), Value::Real(x)));
i += 1;
}
}
continue;
}
out.push(Value::cons(Value::Int(t.code), tag_value(t)));
i += 1;
}
out
}
/// An association list back to DXF groups (points split into x, y, z groups). The type
/// (`0`), the entity name (`-1`), the handle (`5`), the owner (`330`) and the layout (`410`) are
/// left out: they aren't the entity's to set.
fn list_to_groups(list: &Value) -> Result<(String, Vec<Tag>), LispError> {
let mut kind = None;
let mut tags = Vec::new();
for item in list.iter() {
let Value::Cons(c) = &item else { return Err(LispError::bad_arg("listp", &item)) };
let Value::Int(code) = c.car else { return Err(LispError::bad_arg("fixnump", &c.car)) };
let v = c.cdr.clone();
match code {
0 => {
kind = Some(match &v {
Value::Str(s) => s.to_ascii_uppercase(),
other => return Err(LispError::bad_arg("stringp", other)),
})
}
-1 | 5 | 330 | 360 | 410 | 102 => {}
_ if point_group(code) && matches!(v, Value::Cons(_)) => {
let p = v.as_point().ok_or_else(|| LispError::bad_arg("2D/3D point", &v))?;
tags.push(Tag::f(code, p[0]));
tags.push(Tag::f(code + 10, p[1]));
tags.push(Tag::f(code + 20, p[2]));
}
_ => tags.push(match &v {
Value::Int(i) => Tag::i(code, i64::from(*i)),
Value::Real(r) => Tag::f(code, *r),
Value::Str(s) => Tag::s(code, &**s),
Value::Ename(h) => Tag::s(code, h.hex()),
other => return Err(LispError::bad_arg("DXF group value", other)),
}),
}
}
Ok((kind.ok_or_else(|| LispError::new("bad DXF group: no entity type (0)"))?, tags))
}
fn codec() -> Result<&'static crate::cmd::file::EntityCodec, LispError> {
crate::cmd::file::entity_codec().ok_or_else(|| LispError::new("entity lists are not available in this build"))
}
fn space_of(d: &cadcraft_doc::Drawing, h: Handle) -> Option<Space> {
d.space_of(h)
}
pub fn entget(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
arity(&a, 1, 2)?;
let h = ename(&a[0])?;
let Ok(d) = ctx.s.doc() else { return Ok(Flow::Value(Value::Nil)) };
let Some(e) = d.entity(h) else { return Ok(Flow::Value(Value::Nil)) };
let (kind, tags) = (codec()?.groups)(d, e);
let layout = match space_of(d, h) {
Some(Space::Paper(name)) => name,
_ => "Model".into(),
};
let mut items = vec![Value::cons(Value::Int(-1), Value::Ename(h)), Value::cons(Value::Int(0), Value::str(&kind))];
let mut placed_layout = false;
for v in groups_to_list(&tags) {
// The layout name follows the AcDbEntity marker, as AutoCAD lists it.
let after_marker = !placed_layout && v.car().as_number() == Some(100.0) && matches!(v.cdr(), Value::Str(ref s) if &**s == "AcDbEntity");
items.push(v);
if after_marker {
items.push(Value::cons(Value::Int(410), Value::str(&layout)));
placed_layout = true;
}
}
Ok(Flow::Value(Value::list(items)))
}
/// The entity a list describes, as the reader makes it; a missing layer (8) is the current one.
fn entity_from_list(ctx: &Ctx<'_>, list: &Value) -> Result<(String, cadcraft_doc::Common, cadcraft_doc::EntityKind), LispError> {
let (kind, mut tags) = list_to_groups(list)?;
if !tags.iter().any(|t| t.code == 8) {
let layer = crate::sysvars::get(ctx.s, "CLAYER").and_then(|v| v.as_str().map(str::to_string)).unwrap_or_else(|| "0".into());
tags.insert(0, Tag::s(8, layer));
}
match (codec()?.entity)(&kind, &tags) {
// A type the reader only carries through (an unknown or invalid name) isn't made.
Some((_, cadcraft_doc::EntityKind::Unknown(_))) | None => Err(LispError::new(format!("bad DXF group: {kind} can't be made from this list"))),
Some((common, k)) => Ok((kind, common, k)),
}
}
pub fn entmake(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
arity(&a, 0, 1)?;
let Some(list) = a.first().filter(|v| !v.is_nil()) else { return Ok(Flow::Value(Value::Nil)) };
let (_, common, kind) = match entity_from_list(ctx, list) {
Ok(x) => x,
Err(_) => return Ok(Flow::Value(Value::Nil)),
};
let space = ctx.s.space();
let Ok(d) = ctx.s.doc_mut() else { return Ok(Flow::Value(Value::Nil)) };
// A layer entmake names that doesn't exist yet is created.
d.ensure_layer(&common.layer);
match d.add(&space, common, kind) {
Ok(_) => {
ctx.s.touch();
Ok(Flow::Value(list.clone()))
}
Err(_) => Ok(Flow::Value(Value::Nil)),
}
}
pub fn entmod(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
arity(&a, 1, 1)?;
let list = &a[0];
let Some(Value::Ename(h)) = list.iter().find(|v| v.car().as_number() == Some(-1.0)).map(|v| v.cdr()) else {
return Err(LispError::bad_arg("lentityp", &Value::Nil));
};
let Some(old) = ctx.s.doc().ok().and_then(|d| d.entity(h)).cloned() else { return Ok(Flow::Value(Value::Nil)) };
let (kind_name, common, kind) = match entity_from_list(ctx, list) {
Ok(x) => x,
Err(_) => return Ok(Flow::Value(Value::Nil)),
};
// entmod changes an entity, never its type.
if !kind_name.eq_ignore_ascii_case(old.kind.dxf_name()) {
return Ok(Flow::Value(Value::Nil));
}
let Ok(d) = ctx.s.doc_mut() else { return Ok(Flow::Value(Value::Nil)) };
d.ensure_layer(&common.layer);
let ok = d
.modify_entity(h, |e| {
e.common = common;
e.kind = kind;
})
.is_ok();
if ok {
ctx.s.touch();
}
Ok(Flow::Value(if ok { list.clone() } else { Value::Nil }))
}
/// `(entupd ename)`: redraw an entity (the canvas redraws on its own).
pub fn entupd(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
arity(&a, 1, 1)?;
let h = ename(&a[0])?;
ctx.s.touch();
Ok(Flow::Value(if ctx.s.doc().is_ok_and(|d| d.entity(h).is_some()) { Value::Ename(h) } else { Value::Nil }))
}
/// `(entdel ename)`: delete an entity, or bring back one this routine's drawing deleted.
pub fn entdel(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
arity(&a, 1, 1)?;
let h = ename(&a[0])?;
let Ok(d) = ctx.s.doc_mut() else { return Ok(Flow::Value(Value::Nil)) };
if let Some(space) = d.space_of(h) {
if let Some(e) = d.remove_entity(h) {
ctx.env.deleted.insert(h, (space, e));
ctx.s.touch();
return Ok(Flow::Value(Value::Ename(h)));
}
return Ok(Flow::Value(Value::Nil));
}
match ctx.env.deleted.remove(&h) {
Some((space, e)) => {
let restored = d.space_mut(&space).map(|store| store.push(Entity::clone(&e))).is_some();
ctx.s.touch();
Ok(Flow::Value(if restored { Value::Ename(h) } else { Value::Nil }))
}
None => Ok(Flow::Value(Value::Nil)),
}
}
/// Every entity's handle in creation order (model space and layouts), cached per drawing.
fn handles_in_order(ctx: &mut Ctx<'_>) -> Arc<Vec<Handle>> {
let Ok(st) = ctx.s.state() else { return Arc::default() };
let key = Arc::as_ptr(&st.doc) as usize;
if let Some((k, v)) = &ctx.env.order
&& *k == key
{
return v.clone();
}
let d = &st.doc;
let mut v: Vec<Handle> = d.model.handles();
for l in &d.layouts {
v.extend(l.entities.handles());
}
v.sort_unstable();
let v = Arc::new(v);
ctx.env.order = Some((key, v.clone()));
v
}
pub fn entlast(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
arity(&a, 0, 0)?;
Ok(Flow::Value(handles_in_order(ctx).last().map(|h| Value::Ename(*h)).unwrap_or_default()))
}
pub fn entnext(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
arity(&a, 0, 1)?;
let order = handles_in_order(ctx);
let next = match a.first() {
None | Some(Value::Nil) => order.first().copied(),
Some(v) => {
let h = ename(v)?;
let i = order.partition_point(|x| *x <= h);
order.get(i).copied()
}
};
Ok(Flow::Value(next.map(Value::Ename).unwrap_or_default()))
}
pub fn handent(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
arity(&a, 1, 1)?;
let s = string(&a[0])?;
let h = Handle::parse_hex(&s).filter(|h| ctx.s.doc().is_ok_and(|d| d.entity(*h).is_some()));
Ok(Flow::Value(h.map(Value::Ename).unwrap_or_default()))
}
// ---------------- selection sets ----------------
/// `(ssget [mode] [pt1 [pt2]] [filter])`. Modes: "X" every entity, "L" the last, "P" the
/// previous selection, "I" the implied (pickfirst) one, "W"/"C" window and crossing with two
/// points, "F" fence, "WP"/"CP" polygons with a point list. With no mode the pickfirst
/// selection, or the user selects objects. The filter is a list of DXF groups (`-4` operators,
/// wildcards). nil when nothing matches.
pub fn ssget(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
let mut args = a.into_iter().peekable();
let mode = match args.peek() {
Some(Value::Str(s)) => {
let m = s.trim_start_matches(['_', '+', '.', ':']).to_ascii_uppercase();
args.next();
Some(m)
}
_ => None,
};
let rest: Vec<Value> = args.collect();
// A trailing list of dotted pairs is the filter.
let (pts, filter) = match rest.last() {
Some(f @ Value::Cons(_)) if f.car().car().as_number().is_some() && f.car().as_point().is_none() => {
(rest[..rest.len() - 1].to_vec(), Some(f.clone()))
}
_ => (rest, None),
};
let point = |i: usize| -> Result<Vec2, LispError> {
let v = pts.get(i).ok_or_else(|| args_err(true))?;
let p = v.as_point().ok_or_else(|| LispError::bad_arg("2D/3D point", v))?;
Ok(Vec2::new(p[0], p[1]))
};
let s = &mut *ctx.s;
let all = |s: &Session, all_spaces: bool| -> Vec<Handle> {
let Ok(d) = s.doc() else { return Vec::new() };
if all_spaces {
let mut v = d.model.handles();
for l in &d.layouts {
v.extend(l.entities.handles());
}
v
} else {
d.space(&s.state().map(|st| st.edit_space()).unwrap_or(Space::Model)).map(|st| st.handles()).unwrap_or_default()
}
};
let space = s.state().map(|st| st.edit_space()).unwrap_or(Space::Model);
let mut found: Vec<Handle> = match mode.as_deref() {
Some("X") => all(s, true),
Some("L") => {
let order = handles_in_order(ctx);
order.last().copied().into_iter().collect()
}
Some("P") => ctx.s.state().map(|st| st.previous_selection.clone()).unwrap_or_default(),
Some("I") => ctx.s.selection(),
Some("W") | Some("C") => {
let (p1, p2) = (point(0)?, point(1)?);
let s = &*ctx.s;
let Ok(d) = s.doc() else { return Ok(Flow::Value(Value::Nil)) };
crate::select::select_window(d, &space, cadcraft_geom::Bounds2::from_points([p1, p2]), mode.as_deref() == Some("C"))
}
Some("F") | Some("WP") | Some("CP") => {
let poly: Vec<Vec2> =
pts.first().map(|l| l.iter().filter_map(|p| p.as_point().map(|q| Vec2::new(q[0], q[1]))).collect()).unwrap_or_default();
let s = &*ctx.s;
let Ok(d) = s.doc() else { return Ok(Flow::Value(Value::Nil)) };
match mode.as_deref() {
Some("F") => crate::select::select_fence(d, &space, &poly),
m => crate::select::select_polygon(d, &space, &poly, m == Some("CP")),
}
}
Some(":S") | Some("A") | None => {
let implied = ctx.s.selection();
if !implied.is_empty() {
implied
} else if pts.is_empty() {
// The user selects objects; the result arrives when SELECT ends.
return Ok(Flow::Value(Value::Nil)).and_then(|_| interactive_ssget(ctx, filter));
} else {
// A point: the object there.
let p = point(0)?;
let s = &*ctx.s;
let ap = s.settings.pickbox.max(1.0) * s.pixel_size();
let Ok(d) = s.doc() else { return Ok(Flow::Value(Value::Nil)) };
crate::select::pick(d, &space, p, ap).into_iter().collect()
}
}
Some(m) => return Err(LispError::new(format!("bad argument value: ssget mode {m}"))),
};
if let Some(f) = &filter {
let d = ctx.s.doc().map_err(|e| LispError::new(e.to_string()))?;
let f = parse_filter(f)?;
found.retain(|h| d.entity(*h).is_some_and(|e| matches_filter(d, e, &f)));
}
if found.is_empty() {
return Ok(Flow::Value(Value::Nil));
}
Ok(Flow::Value(new_pickset(ctx, found)))
}
/// `ssget` asking the user: the SELECT command collects the objects.
fn interactive_ssget(ctx: &mut Ctx<'_>, filter: Option<Value>) -> Result<Flow, LispError> {
if ctx.s.running.is_some() {
return Ok(Flow::Value(Value::Nil));
}
if let Err(e) = ctx.s.start("select") {
return Err(LispError::new(e.to_string()));
}
let f = super::eval::lookup(ctx.env, &super::value::Sym::new("SSGET"));
// When SELECT ends, its objects are the previous selection: (ssget "P" [filter]).
let mut args = vec![Value::str("P")];
args.extend(filter);
Ok(Flow::WaitThen(Need::Pause, f, args))
}
/// A parsed `ssget` filter.
enum Filter {
/// One group to test, with the relational operator of a preceding `(-4 . "op")`.
Group {
code: i32,
value: Value,
op: String,
},
And(Vec<Filter>),
Or(Vec<Filter>),
Xor(Vec<Filter>),
Not(Box<Filter>),
}
fn parse_filter(list: &Value) -> Result<Filter, LispError> {
let items = list.to_vec();
let mut i = 0;
let mut out = Vec::new();
while i < items.len() {
out.push(parse_filter_item(&items, &mut i, 0)?);
}
Ok(Filter::And(out))
}
fn parse_filter_item(items: &[Value], i: &mut usize, depth: usize) -> Result<Filter, LispError> {
if depth > 64 {
return Err(LispError::new("bad ssget filter: nested too deeply"));
}
let Some(item) = items.get(*i) else { return Err(LispError::new("bad ssget filter")) };
*i += 1;
let code = item.car().as_number().map(|c| c as i32).ok_or_else(|| LispError::bad_arg("ssget filter", item))?;
let value = item.cdr();
if code != -4 {
return Ok(Filter::Group { code, value, op: "=".into() });
}
let op = match &value {
Value::Str(s) => s.to_ascii_uppercase(),
other => return Err(LispError::bad_arg("stringp", other)),
};
let group = |items: &[Value], i: &mut usize, depth: usize, end: &str| -> Result<Vec<Filter>, LispError> {
let mut v = Vec::new();
loop {
let Some(next) = items.get(*i) else { return Err(LispError::new(format!("bad ssget filter: no {end}"))) };
if next.car().as_number() == Some(-4.0) && matches!(next.cdr(), Value::Str(ref s) if s.eq_ignore_ascii_case(end)) {
*i += 1;
return Ok(v);
}
v.push(parse_filter_item(items, i, depth + 1)?);
}
};
Ok(match op.as_str() {
"<AND" => Filter::And(group(items, i, depth, "AND>")?),
"<OR" => Filter::Or(group(items, i, depth, "OR>")?),
"<XOR" => Filter::Xor(group(items, i, depth, "XOR>")?),
"<NOT" => {
let mut v = group(items, i, depth, "NOT>")?;
Filter::Not(Box::new(if v.len() == 1 { v.remove(0) } else { Filter::And(v) }))
}
_ => {
// A relational operator applies to the next group.
let Some(next) = items.get(*i) else { return Err(LispError::new("bad ssget filter")) };
*i += 1;
let code = next.car().as_number().map(|c| c as i32).ok_or_else(|| LispError::bad_arg("ssget filter", next))?;
Filter::Group { code, value: next.cdr(), op }
}
})
}
fn matches_filter(d: &cadcraft_doc::Drawing, e: &Entity, f: &Filter) -> bool {
let groups = || match crate::cmd::file::entity_codec() {
Some(c) => {
let (kind, tags) = (c.groups)(d, e);
let mut l = vec![Value::cons(Value::Int(0), Value::str(&kind))];
l.extend(groups_to_list(&tags));
l
}
None => vec![Value::cons(Value::Int(0), Value::str(e.kind.dxf_name())), Value::cons(Value::Int(8), Value::str(&e.common.layer))],
};
let groups = groups();
eval_filter(&groups, f)
}
fn eval_filter(groups: &[Value], f: &Filter) -> bool {
match f {
Filter::And(v) => v.iter().all(|x| eval_filter(groups, x)),
Filter::Or(v) => v.iter().any(|x| eval_filter(groups, x)),
Filter::Xor(v) => v.iter().filter(|x| eval_filter(groups, x)).count() == 1,
Filter::Not(x) => !eval_filter(groups, x),
Filter::Group { code, value, op } => {
let found: Vec<Value> = groups.iter().filter(|g| g.car().as_number() == Some(f64::from(*code))).map(Value::cdr).collect();
if found.is_empty() {
// A group the entity doesn't have: layer 0 / colour BYLAYER defaults count.
return match (code, value) {
(62, Value::Int(256)) => op == "=",
_ => false,
};
}
found.iter().any(|have| group_matches(have, value, op))
}
}
}
fn group_matches(have: &Value, want: &Value, op: &str) -> bool {
if op == "*" {
return true;
}
match (have, want) {
(Value::Str(h), Value::Str(w)) => {
// Strings match as wildcards, case-insensitively (type and layer names).
let hit = super::builtins::wild_match(&h.to_uppercase(), &w.to_uppercase());
if op == "!=" || op == "/=" || op == "<>" { !hit } else { hit }
}
_ => {
if let (Some(h), Some(w)) = (have.as_number(), want.as_number()) {
return compare(h, w, op);
}
if let (Some(h), Some(w)) = (have.as_point(), want.as_point()) {
// Points: an operator per coordinate ("<,>,*") or one for all.
let ops: Vec<&str> = op.split(',').map(str::trim).collect();
return (0..3).all(|i| compare(h[i], w[i], ops.get(i).or(ops.first()).copied().unwrap_or("=")));
}
super::value::equal(have, want, 0.0)
}
}
}
fn compare(h: f64, w: f64, op: &str) -> bool {
match op {
"=" => h == w,
"!=" | "/=" | "<>" => h != w,
"<" => h < w,
"<=" => h <= w,
">" => h > w,
">=" => h >= w,
"&" => (h as i64) & (w as i64) != 0,
"&=" => (h as i64) & (w as i64) == (w as i64),
"*" => true,
_ => h == w,
}
}
pub fn ssadd(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
arity(&a, 0, 2)?;
match (a.first(), a.get(1)) {
(None, _) => Ok(Flow::Value(new_pickset(ctx, Vec::new()))),
(Some(e), None) => {
let h = ename(e)?;
Ok(Flow::Value(new_pickset(ctx, vec![h])))
}
(Some(e), Some(ss)) => {
let h = ename(e)?;
let p = pickset(ss)?;
if let Ok(mut v) = p.items.lock()
&& !v.contains(&h)
{
v.push(h);
}
Ok(Flow::Value(ss.clone()))
}
}
}
pub fn ssdel(_ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
arity(&a, 2, 2)?;
let h = ename(&a[0])?;
let p = pickset(&a[1])?;
let removed = p.items.lock().map(|mut v| {
let n = v.len();
v.retain(|x| *x != h);
n != v.len()
});
Ok(Flow::Value(if removed.unwrap_or(false) { a[1].clone() } else { Value::Nil }))
}
pub fn sslength(_ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
arity(&a, 1, 1)?;
Ok(Flow::Value(Value::Int(i32::try_from(items(&pickset(&a[0])?).len()).unwrap_or(i32::MAX))))
}
pub fn ssname(_ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
arity(&a, 2, 2)?;
let p = pickset(&a[0])?;
let i = a[1].as_number().ok_or_else(|| LispError::bad_arg("numberp", &a[1]))?;
if i < 0.0 {
return Ok(Flow::Value(Value::Nil));
}
Ok(Flow::Value(items(&p).get(i as usize).map(|h| Value::Ename(*h)).unwrap_or_default()))
}
pub fn ssmemb(_ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
arity(&a, 2, 2)?;
let h = ename(&a[0])?;
Ok(Flow::Value(if items(&pickset(&a[1])?).contains(&h) { Value::Ename(h) } else { Value::Nil }))
}
pub fn sssetfirst(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
arity(&a, 1, 2)?;
let sel = match a.get(1) {
Some(Value::PickSet(p)) => items(p),
_ => Vec::new(),
};
ctx.s.set_selection(sel);
Ok(Flow::Value(Value::list(a)))
}
pub fn ssgetfirst(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
arity(&a, 0, 0)?;
let sel = ctx.s.selection();
let ss = if sel.is_empty() { Value::Nil } else { new_pickset(ctx, sel) };
Ok(Flow::Value(Value::list(vec![Value::Nil, ss])))
}
// ---------------- symbol tables ----------------
fn color_index(c: cadcraft_color::Color) -> i32 {
match c {
cadcraft_color::Color::Index(i) => i32::from(i),
cadcraft_color::Color::ByBlock => 0,
cadcraft_color::Color::ByLayer => 256,
cadcraft_color::Color::True(_) => 7,
}
}
/// The records of a symbol table, as `tblsearch` lists them.
fn table_records(d: &cadcraft_doc::Drawing, table: &str) -> Vec<Value> {
let pair = |c: i32, v: Value| Value::cons(Value::Int(c), v);
match table {
"LAYER" => d
.layers
.iter()
.map(|l| {
let flags = i32::from(l.frozen) | (i32::from(l.locked) << 2);
let color = color_index(l.color);
Value::list(vec![
pair(0, Value::str("LAYER")),
pair(2, Value::str(&l.name)),
pair(70, Value::Int(flags)),
pair(62, Value::Int(if l.on { color } else { -color })),
pair(6, Value::str(&l.linetype)),
])
})
.collect(),
"LTYPE" => d
.linetypes
.iter()
.map(|l| {
Value::list(vec![
pair(0, Value::str("LTYPE")),
pair(2, Value::str(&l.name)),
pair(70, Value::Int(0)),
pair(3, Value::str(&l.description)),
])
})
.collect(),
"STYLE" => d
.text_styles
.iter()
.map(|s| {
Value::list(vec![
pair(0, Value::str("STYLE")),
pair(2, Value::str(&s.name)),
pair(70, Value::Int(0)),
pair(40, Value::Real(s.height)),
pair(41, Value::Real(s.width_factor)),
pair(3, Value::str(&s.font)),
])
})
.collect(),
"BLOCK" => d
.blocks
.values()
.map(|b| {
Value::list(vec![
pair(0, Value::str("BLOCK")),
pair(2, Value::str(&b.name)),
pair(70, Value::Int(0)),
Value::list(vec![Value::Int(10), Value::Real(b.base.x), Value::Real(b.base.y), Value::Real(b.base.z)]),
])
})
.collect(),
"DIMSTYLE" => d
.dim_styles
.iter()
.map(|s| Value::list(vec![pair(0, Value::str("DIMSTYLE")), pair(2, Value::str(&s.name)), pair(70, Value::Int(0))]))
.collect(),
"APPID" => vec![Value::list(vec![pair(0, Value::str("APPID")), pair(2, Value::str("ACAD")), pair(70, Value::Int(0))])],
_ => Vec::new(),
}
}
fn record_name(r: &Value) -> String {
r.iter().find(|p| p.car().as_number() == Some(2.0)).map(|p| to_text(&p.cdr(), false)).unwrap_or_default()
}
pub fn tblsearch(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
arity(&a, 2, 3)?;
let table = string(&a[0])?.to_ascii_uppercase();
let name = string(&a[1])?;
let Ok(d) = ctx.s.doc() else { return Ok(Flow::Value(Value::Nil)) };
let records = table_records(d, &table);
let found = records.iter().position(|r| record_name(r).eq_ignore_ascii_case(&name));
if let Some(i) = found
&& a.get(2).is_some_and(|v| !v.is_nil())
{
ctx.env.tblnext.insert(table.clone(), i + 1);
}
Ok(Flow::Value(found.and_then(|i| records.get(i).cloned()).unwrap_or_default()))
}
pub fn tblnext(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
arity(&a, 1, 2)?;
let table = string(&a[0])?.to_ascii_uppercase();
let rewind = a.get(1).is_some_and(|v| !v.is_nil());
let Ok(d) = ctx.s.doc() else { return Ok(Flow::Value(Value::Nil)) };
let records = table_records(d, &table);
let i = if rewind { 0 } else { ctx.env.tblnext.get(&table).copied().unwrap_or(0) };
ctx.env.tblnext.insert(table, i + 1);
Ok(Flow::Value(records.get(i).cloned().unwrap_or_default()))
}
// ---------------- units ----------------
fn int_var(s: &Session, name: &str, default: i64) -> i64 {
crate::sysvars::get(s, name).and_then(|v| v.as_i64()).unwrap_or(default)
}
/// `(rtos number [mode [precision]])`: as LUNITS/LUPREC by default; decimal results honour
/// DIMZIN's zero suppression (4 leading, 8 trailing).
pub fn rtos(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
arity(&a, 1, 3)?;
let v = a[0].as_number().ok_or_else(|| LispError::bad_arg("numberp", &a[0]))?;
let mode = match a.get(1) {
Some(Value::Int(m)) => i64::from(*m),
_ => int_var(ctx.s, "LUNITS", 2),
};
let prec = match a.get(2) {
Some(Value::Int(p)) => i64::from(*p),
_ => int_var(ctx.s, "LUPREC", 4),
};
if !(1..=5).contains(&mode) {
return Err(LispError::new(format!("bad argument value: rtos mode {mode}")));
}
let text = if mode == 2 {
let dimzin = int_var(ctx.s, "DIMZIN", 0);
decimal(v, prec.clamp(0, 8) as usize, dimzin)
} else {
crate::units::format_distance(v, mode, prec)
};
Ok(Flow::Value(Value::str(&text)))
}
fn decimal(v: f64, prec: usize, dimzin: i64) -> String {
let mut s = format!("{v:.prec$}");
if s.starts_with("-0") && s.trim_start_matches(['-', '0', '.']).is_empty() {
s = s.trim_start_matches('-').to_string();
}
if dimzin & 8 != 0 && s.contains('.') {
s = s.trim_end_matches('0').trim_end_matches('.').to_string();
}
if dimzin & 4 != 0 {
if let Some(r) = s.strip_prefix("0.") {
s = format!(".{r}");
} else if let Some(r) = s.strip_prefix("-0.") {
s = format!("-.{r}");
}
}
s
}
pub fn angtos(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
arity(&a, 1, 3)?;
let v = a[0].as_number().ok_or_else(|| LispError::bad_arg("numberp", &a[0]))?;
let mode = match a.get(1) {
Some(Value::Int(m)) => i64::from(*m),
_ => int_var(ctx.s, "AUNITS", 0),
};
let prec = match a.get(2) {
Some(Value::Int(p)) => i64::from(*p),
_ => int_var(ctx.s, "AUPREC", 0),
};
Ok(Flow::Value(Value::str(&crate::units::format_angle(v, mode, prec))))
}
pub fn distof(_ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
arity(&a, 1, 2)?;
let s = string(&a[0])?;
Ok(Flow::Value(crate::units::parse_distance(&s).map(Value::Real).unwrap_or_default()))
}
pub fn angtof(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
arity(&a, 1, 2)?;
let s = string(&a[0])?;
Ok(Flow::Value(ctx.s.angle_settings().direction(&s).map(Value::Real).unwrap_or_default()))
}
/// `(trans pt from to [disp])`: points are in the world system throughout (no UCS yet).
pub fn trans(_ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
arity(&a, 3, 4)?;
let p = a[0].as_point().ok_or_else(|| LispError::bad_arg("2D/3D point", &a[0]))?;
Ok(Flow::Value(Value::point(&p)))
}
/// What `(load name)` reads: a file loaded through APPLOAD (the web), or one on disk, with
/// `.lsp` added when the name has no extension.
pub fn load_source(s: &Session, name: &str) -> Option<String> {
if let Some(t) = s.lisp_file(name) {
return Some(t);
}
let candidates =
if std::path::Path::new(name).extension().is_none() { vec![format!("{name}.lsp"), name.to_string()] } else { vec![name.to_string()] };
// As given (a path, or relative to the working folder), then in the support folder.
let support =
s.lisp_support_dir().map(|d| candidates.iter().map(|c| d.join(c).to_string_lossy().into_owned()).collect::<Vec<_>>()).unwrap_or_default();
candidates.iter().chain(&support).find_map(|c| super::builtins::read_text_file(c))
}
+656
View File
@@ -0,0 +1,656 @@
//! The evaluator: an explicit-stack machine. Nothing recurses on the Rust stack, so a routine
//! can stop in the middle (a `getpoint` waiting for a click, a `pause` inside `command`) and
//! carry on when the input arrives, on one thread (the web build has no others). Deep recursion
//! in LISP is an error ("internal stack limit reached"), never a crash.
//!
//! Variables are dynamically scoped, as in AutoLISP: a function's arguments and locals are bound
//! on entry and restored when it returns, or when an error unwinds through it.
use super::value::{BuiltinKind, Func, Sym, Value, to_text};
use super::{Env, LispError};
use crate::Session;
/// Frames on the machine's stack: what to do with the value of the expression just evaluated.
pub enum Frame {
/// Evaluating a call's arguments: `f` gets `done` and then the values of `rest`.
Args {
f: Value,
rest: Value,
done: Vec<Value>,
},
/// The head of a form was itself a form (`((lambda (x) …) 1)`): call its value with `args`.
Head {
args: Value,
},
Progn {
rest: Value,
},
If {
then: Value,
els: Value,
},
/// A `cond` clause's test was evaluated: its body runs when true, else the next clauses.
Cond {
body: Value,
rest: Value,
},
And {
rest: Value,
},
Or {
rest: Value,
},
Setq {
sym: Sym,
rest: Value,
},
WhileTest {
test: Value,
body: Value,
last: Value,
},
WhileBody {
test: Value,
body: Value,
},
/// `repeat`'s count was evaluated.
RepeatCount {
body: Value,
},
Repeat {
left: i64,
body: Value,
},
/// `foreach`'s list was evaluated.
ForeachList {
var: Sym,
body: Value,
},
Foreach {
var: Sym,
items: Value,
body: Value,
saved: Option<Value>,
},
/// A function call ends: restore the bindings its arguments and locals hid.
Unbind {
saved: Vec<(Sym, Option<Value>)>,
},
/// `mapcar`: `f` is called with the heads of `lists` in turn.
Mapcar {
f: Value,
lists: Vec<Value>,
acc: Vec<Value>,
},
/// Top-level forms of a loaded file or a command line, evaluated in turn.
Forms {
rest: Vec<Value>,
},
/// `vl-catch-all-apply`: an error below this frame becomes its value.
Catch,
/// After a wait, call `f` with `args` (the rest of a `command` after a `pause`).
Resume {
f: Value,
args: Vec<Value>,
},
}
/// Special forms: their arguments are not evaluated first.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Special {
Quote,
Function,
Setq,
If,
Cond,
While,
Repeat,
Foreach,
Progn,
And,
Or,
Defun,
Lambda,
}
/// What a built-in asks of the machine.
pub enum Flow {
Value(Value),
/// Call a function with these arguments (`apply`).
Call(Value, Vec<Value>),
/// Evaluate an expression (`eval`).
Eval(Value),
/// Evaluate forms in order; the value is the last one's (`load`).
Forms(Vec<Value>),
/// `mapcar`.
Map(Value, Vec<Value>),
/// `vl-catch-all-apply`: call the function, turning an error into a value.
Catch(Value, Vec<Value>),
/// Stop until the session supplies a value (see [`Need`]).
Wait(Need),
/// Stop, then call the function with these arguments.
WaitThen(Need, Value, Vec<Value>),
}
/// Why the machine stopped before finishing.
#[derive(Clone, Debug)]
pub enum Need {
/// A `get…` function waits for the user.
Get(super::input::GetRequest),
/// `pause` inside `command`: the running command takes one input from the user.
Pause,
/// The step budget ran out: let the interface draw a frame, then carry on.
Yield,
}
/// How a run ended.
pub enum Outcome {
Done(Value),
Wait(Need),
Error(LispError),
}
/// What builtins get: the session and the LISP environment.
pub struct Ctx<'a> {
pub s: &'a mut Session,
pub env: &'a mut Env,
/// Nesting depth of [`Ctx::call_sync`].
pub depth: usize,
}
/// Deepest stack the machine allows, in frames: well above real routines, well below memory
/// trouble.
pub const MAX_STACK: usize = 100_000;
/// Nesting depth of synchronous callbacks (`vl-sort` comparing with a LISP function, …).
const MAX_SYNC_DEPTH: usize = 32;
enum State {
Eval(Value),
Ret(Value),
Call(Value, Vec<Value>),
}
pub struct Machine {
stack: Vec<Frame>,
state: State,
need: Option<Need>,
}
impl Machine {
/// A machine evaluating `forms` in turn.
pub fn forms(forms: Vec<Value>) -> Machine {
Machine { stack: vec![Frame::Forms { rest: forms.into_iter().rev().collect() }], state: State::Ret(Value::Nil), need: None }
}
/// A machine calling `f` with `args`.
pub fn calling(f: Value, args: Vec<Value>) -> Machine {
Machine { stack: Vec::new(), state: State::Call(f, args), need: None }
}
/// Give the value a [`Need`] waited for, and carry on.
pub fn resume(&mut self, v: Value) {
self.state = State::Ret(v);
}
/// Run until done, waiting, or `budget` steps.
pub fn run(&mut self, ctx: &mut Ctx<'_>, budget: u64) -> Outcome {
let mut n = 0u64;
loop {
n += 1;
if n > budget {
return Outcome::Wait(Need::Yield);
}
let r = match std::mem::replace(&mut self.state, State::Ret(Value::Nil)) {
State::Eval(expr) => self.eval(ctx, expr),
State::Call(f, args) => self.call(ctx, f, args),
State::Ret(v) => match self.stack.pop() {
None => return Outcome::Done(v),
Some(frame) => self.ret(ctx, frame, v),
},
};
// A runaway routine stops before it takes all memory.
let r = r.and_then(|()| super::value::check_cells());
if let Err(e) = r {
self.need = None;
if let Some(o) = self.unwind(ctx, e) {
return o;
}
} else if self.stack.len() > MAX_STACK {
if let Some(o) = self.unwind(ctx, LispError::new("internal stack limit reached")) {
return o;
}
} else if let Some(need) = self.need.take() {
return Outcome::Wait(need);
}
}
}
/// An error: pop frames back to a `vl-catch-all-apply` (its value becomes the error), or out
/// of the run. Bindings are restored on the way.
fn unwind(&mut self, ctx: &mut Ctx<'_>, e: LispError) -> Option<Outcome> {
while let Some(frame) = self.stack.pop() {
match frame {
Frame::Unbind { saved } => restore(ctx.env, saved),
Frame::Foreach { var, saved, .. } => restore(ctx.env, vec![(var, saved)]),
Frame::Catch if !e.quit => {
self.state = State::Ret(super::builtins::catch_all_error(&e.msg));
return None;
}
_ => {}
}
}
Some(Outcome::Error(e))
}
fn eval(&mut self, ctx: &mut Ctx<'_>, expr: Value) -> Result<(), LispError> {
match expr {
Value::Sym(s) => self.state = State::Ret(lookup(ctx.env, &s)),
Value::Cons(c) => {
let (head, args) = (c.car.clone(), c.cdr.clone());
let f = match &head {
Value::Sym(s) => {
let f = lookup(ctx.env, s);
if f.is_nil() {
return Err(LispError::new(format!("no function definition: {}", s.name())));
}
f
}
Value::Cons(_) => {
self.stack.push(Frame::Head { args });
self.state = State::Eval(head);
return Ok(());
}
other => other.clone(),
};
return self.apply_form(ctx, f, args);
}
atom => self.state = State::Ret(atom),
}
Ok(())
}
/// A form whose head evaluated to `f`: a special form, or a call with `args` to evaluate.
fn apply_form(&mut self, ctx: &mut Ctx<'_>, f: Value, args: Value) -> Result<(), LispError> {
if let Value::Subr(b) = &f
&& let BuiltinKind::Special(sp) = b.kind
{
return self.special(ctx, sp, args);
}
if !matches!(f, Value::Subr(_) | Value::Func(_)) {
return Err(LispError::new(format!("bad function: {}", to_text(&f, true))));
}
match args {
Value::Cons(c) => {
self.stack.push(Frame::Args { f, rest: c.cdr.clone(), done: Vec::new() });
self.state = State::Eval(c.car.clone());
}
_ => self.state = State::Call(f, Vec::new()),
}
Ok(())
}
fn ret(&mut self, ctx: &mut Ctx<'_>, frame: Frame, v: Value) -> Result<(), LispError> {
match frame {
Frame::Args { f, rest, mut done } => {
done.push(v);
match rest {
Value::Cons(c) => {
self.stack.push(Frame::Args { f, rest: c.cdr.clone(), done });
self.state = State::Eval(c.car.clone());
}
_ => self.state = State::Call(f, done),
}
}
Frame::Head { args } => return self.apply_form(ctx, v, args),
Frame::Progn { rest } => self.progn(rest, v),
Frame::If { then, els } => self.state = State::Eval(if v.is_nil() { els } else { then }),
Frame::Cond { body, rest } => {
if v.is_nil() {
self.cond(rest);
} else if body.is_nil() {
self.state = State::Ret(v);
} else {
self.progn(body, Value::Nil);
}
}
Frame::And { rest } => {
if v.is_nil() {
self.state = State::Ret(Value::Nil);
} else {
self.and_or(rest, true);
}
}
Frame::Or { rest } => {
if v.is_nil() {
self.and_or(rest, false);
} else {
self.state = State::Ret(Value::t());
}
}
Frame::Setq { sym, rest } => {
ctx.env.set(&sym, v.clone());
self.setq(rest, v)?;
}
Frame::WhileTest { test, body, last } => {
if v.is_nil() {
self.state = State::Ret(last);
} else {
self.stack.push(Frame::WhileBody { test, body: body.clone() });
self.progn(body, Value::Nil);
}
}
Frame::WhileBody { test, body } => {
self.stack.push(Frame::WhileTest { test: test.clone(), body, last: v });
self.state = State::Eval(test);
}
Frame::RepeatCount { body } => {
let Value::Int(n) = v else { return Err(LispError::bad_arg("fixnump", &v)) };
self.repeat(i64::from(n), body, Value::Nil);
}
Frame::Repeat { left, body } => self.repeat(left, body, v),
Frame::ForeachList { var, body } => {
if !v.is_list() {
return Err(LispError::bad_arg("listp", &v));
}
let saved = ctx.env.globals.get(&var).cloned();
self.foreach(ctx, var, v, body, saved, Value::Nil);
}
Frame::Foreach { var, items, body, saved } => self.foreach(ctx, var, items, body, saved, v),
Frame::Unbind { saved } => {
restore(ctx.env, saved);
self.state = State::Ret(v);
}
Frame::Mapcar { f, lists, mut acc } => {
acc.push(v);
let lists: Vec<Value> = lists.iter().map(Value::cdr).collect();
if lists.iter().any(|l| !matches!(l, Value::Cons(_))) {
self.state = State::Ret(Value::list(acc));
} else {
let args = lists.iter().map(Value::car).collect();
self.stack.push(Frame::Mapcar { f: f.clone(), lists, acc });
self.state = State::Call(f, args);
}
}
Frame::Forms { mut rest } => match rest.pop() {
Some(next) => {
self.stack.push(Frame::Forms { rest });
self.state = State::Eval(next);
}
None => self.state = State::Ret(v),
},
Frame::Catch => self.state = State::Ret(v),
Frame::Resume { f, args } => self.state = State::Call(f, args),
}
Ok(())
}
/// Evaluate the forms of `body` in turn; `last` is the value when there are none.
fn progn(&mut self, body: Value, last: Value) {
match body {
Value::Cons(c) => {
if !c.cdr.is_nil() {
self.stack.push(Frame::Progn { rest: c.cdr.clone() });
}
self.state = State::Eval(c.car.clone());
}
_ => self.state = State::Ret(last),
}
}
fn cond(&mut self, clauses: Value) {
match clauses {
Value::Cons(c) => {
let clause = c.car.clone();
self.stack.push(Frame::Cond { body: clause.cdr(), rest: c.cdr.clone() });
self.state = State::Eval(clause.car());
}
_ => self.state = State::Ret(Value::Nil),
}
}
/// The next operand of `and` (`and_`) or `or`.
fn and_or(&mut self, rest: Value, and_: bool) {
match rest {
Value::Cons(c) => {
self.stack.push(if and_ { Frame::And { rest: c.cdr.clone() } } else { Frame::Or { rest: c.cdr.clone() } });
self.state = State::Eval(c.car.clone());
}
_ => self.state = State::Ret(Value::bool(and_)),
}
}
fn setq(&mut self, pairs: Value, last: Value) -> Result<(), LispError> {
match pairs {
Value::Cons(c) => {
let Value::Sym(sym) = c.car.clone() else {
return Err(LispError::bad_arg("symbolp", &c.car));
};
let Value::Cons(rest) = c.cdr.clone() else { return Err(LispError::new("too few arguments")) };
self.stack.push(Frame::Setq { sym, rest: rest.cdr.clone() });
self.state = State::Eval(rest.car.clone());
}
_ => self.state = State::Ret(last),
}
Ok(())
}
fn repeat(&mut self, left: i64, body: Value, last: Value) {
if left <= 0 || body.is_nil() {
self.state = State::Ret(last);
} else {
self.stack.push(Frame::Repeat { left: left - 1, body: body.clone() });
self.progn(body, Value::Nil);
}
}
fn foreach(&mut self, ctx: &mut Ctx<'_>, var: Sym, items: Value, body: Value, saved: Option<Value>, last: Value) {
match items {
Value::Cons(c) => {
ctx.env.set(&var, c.car.clone());
self.stack.push(Frame::Foreach { var, items: c.cdr.clone(), body: body.clone(), saved });
self.progn(body, Value::Nil);
}
_ => {
restore(ctx.env, vec![(var, saved)]);
self.state = State::Ret(last);
}
}
}
fn special(&mut self, ctx: &mut Ctx<'_>, sp: Special, args: Value) -> Result<(), LispError> {
match sp {
Special::Quote => self.state = State::Ret(args.car()),
Special::Function => {
let a = args.car();
self.state = State::Ret(if a.car().as_sym().is_some_and(|s| s.name() == "LAMBDA") { make_func(None, a.cdr())? } else { a });
}
Special::Setq => {
if args.is_nil() {
return Err(LispError::new("too few arguments"));
}
self.setq(args, Value::Nil)?;
}
Special::If => {
let v = args.to_vec();
if v.len() < 2 {
return Err(LispError::new("too few arguments"));
}
if v.len() > 3 {
return Err(LispError::new("too many arguments"));
}
let mut it = v.into_iter();
let test = it.next().unwrap_or_default();
let then = it.next().unwrap_or_default();
let els = it.next().unwrap_or_default();
self.stack.push(Frame::If { then, els });
self.state = State::Eval(test);
}
Special::Cond => self.cond(args),
Special::While => {
let test = args.car();
self.stack.push(Frame::WhileTest { test: test.clone(), body: args.cdr(), last: Value::Nil });
self.state = State::Eval(test);
}
Special::Repeat => {
self.stack.push(Frame::RepeatCount { body: args.cdr() });
self.state = State::Eval(args.car());
}
Special::Foreach => {
let Value::Sym(var) = args.car() else { return Err(LispError::bad_arg("symbolp", &args.car())) };
self.stack.push(Frame::ForeachList { var, body: args.cdr().cdr() });
self.state = State::Eval(args.cdr().car());
}
Special::Progn => self.progn(args, Value::Nil),
Special::And => self.and_or(args, true),
Special::Or => self.and_or(args, false),
Special::Defun => {
let Value::Sym(name) = args.car() else { return Err(LispError::bad_arg("symbolp", &args.car())) };
let f = make_func(Some(name.clone()), args.cdr())?;
ctx.env.set(&name, f);
self.state = State::Ret(Value::Sym(name));
}
Special::Lambda => self.state = State::Ret(make_func(None, args)?),
}
Ok(())
}
/// Call `f` with evaluated `args`.
fn call(&mut self, ctx: &mut Ctx<'_>, f: Value, args: Vec<Value>) -> Result<(), LispError> {
match &f {
Value::Subr(b) => match b.kind {
BuiltinKind::Pure(func) => {
let v = func(&args)?;
super::value::charge_result(&v)?;
self.state = State::Ret(v);
}
BuiltinKind::Ctx(func) => {
let flow = func(ctx, args)?;
if let Flow::Value(v) = &flow {
super::value::charge_result(v)?;
}
self.flow(flow);
}
BuiltinKind::Special(_) => return Err(LispError::new(format!("bad function: {}", b.name))),
},
Value::Func(func) => {
if args.len() < func.params.len() {
return Err(LispError::new("too few arguments"));
}
if args.len() > func.params.len() {
return Err(LispError::new("too many arguments"));
}
let mut saved = Vec::with_capacity(func.params.len() + func.locals.len());
for (p, a) in func.params.iter().zip(args) {
saved.push((p.clone(), ctx.env.globals.get(p).cloned()));
ctx.env.set(p, a);
}
for l in &func.locals {
saved.push((l.clone(), ctx.env.globals.get(l).cloned()));
ctx.env.set(l, Value::Nil);
}
self.stack.push(Frame::Unbind { saved });
self.progn(func.body.clone(), Value::Nil);
}
other => return Err(LispError::new(format!("bad function: {}", to_text(other, true)))),
}
Ok(())
}
fn flow(&mut self, flow: Flow) {
match flow {
Flow::Value(v) => self.state = State::Ret(v),
Flow::Call(f, args) => self.state = State::Call(f, args),
Flow::Eval(e) => self.state = State::Eval(e),
Flow::Forms(forms) => {
self.stack.push(Frame::Forms { rest: forms.into_iter().rev().collect() });
self.state = State::Ret(Value::Nil);
}
Flow::Map(f, lists) => {
if lists.is_empty() || lists.iter().any(|l| !matches!(l, Value::Cons(_))) {
self.state = State::Ret(Value::Nil);
} else {
let args = lists.iter().map(Value::car).collect();
self.stack.push(Frame::Mapcar { f: f.clone(), lists, acc: Vec::new() });
self.state = State::Call(f, args);
}
}
Flow::Catch(f, args) => {
self.stack.push(Frame::Catch);
self.state = State::Call(f, args);
}
Flow::WaitThen(need, f, args) => {
self.stack.push(Frame::Resume { f, args });
self.state = State::Ret(Value::Nil);
self.need = Some(need);
}
Flow::Wait(need) => {
// The value the session supplies replaces this call's.
self.state = State::Ret(Value::Nil);
self.need = Some(need);
}
}
}
}
/// A variable's value (nil when unbound).
pub fn lookup(env: &Env, s: &Sym) -> Value {
env.globals.get(s).cloned().unwrap_or(Value::Nil)
}
fn restore(env: &mut Env, saved: Vec<(Sym, Option<Value>)>) {
for (s, v) in saved.into_iter().rev() {
match v {
Some(v) => {
env.globals.insert(s, v);
}
None => {
env.globals.remove(&s);
}
}
}
}
/// A function from `((params / locals) body…)`.
fn make_func(name: Option<Sym>, def: Value) -> Result<Value, LispError> {
let arglist = def.car();
if !arglist.is_list() {
return Err(LispError::bad_arg("listp", &arglist));
}
let (mut params, mut locals) = (Vec::new(), Vec::new());
let mut after_slash = false;
for a in arglist.iter() {
let Value::Sym(s) = a else { return Err(LispError::bad_arg("symbolp", &a)) };
if s.name() == "/" {
after_slash = true;
} else if after_slash {
locals.push(s);
} else {
params.push(s);
}
}
Ok(Value::Func(std::sync::Arc::new(Func { name, params, locals, body: def.cdr() })))
}
impl Ctx<'_> {
/// Call `f` to completion here (a comparison for `vl-sort`, a test for `vl-remove-if`…).
/// It may not wait for input.
pub fn call_sync(&mut self, f: &Value, args: Vec<Value>) -> Result<Value, LispError> {
if self.depth >= MAX_SYNC_DEPTH {
return Err(LispError::new("internal stack limit reached"));
}
if let Value::Subr(b) = f
&& let BuiltinKind::Pure(func) = b.kind
{
let v = func(&args)?;
super::value::charge_result(&v)?;
return Ok(v);
}
let mut m = Machine::calling(f.clone(), args);
let mut sub = Ctx { s: &mut *self.s, env: &mut *self.env, depth: self.depth + 1 };
match m.run(&mut sub, u64::MAX) {
Outcome::Done(v) => Ok(v),
Outcome::Error(e) => Err(e),
Outcome::Wait(_) => Err(LispError::new("this function can't ask for input here")),
}
}
}
+383
View File
@@ -0,0 +1,383 @@
//! User input from LISP: `getpoint`, `getcorner`, `getdist`, `getangle`, `getorient`,
//! `getreal`, `getint`, `getstring`, `getkword`, `entsel`, `nentsel`, and `initget`, which sets
//! what the next one accepts (bits: 1 no Enter, 2 no zero, 4 no negatives, 128 any text; and
//! keywords).
use cadcraft_geom::Vec2;
use super::eval::{Ctx, Flow, Need};
use super::value::Value;
use super::{LispError, builtins::args_err};
use crate::{Accept, Input, Prompt, Session};
/// `initget` settings.
#[derive(Clone, Debug, Default)]
pub struct Initget {
pub bits: i32,
pub keywords: Vec<String>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum GetKind {
Point,
Corner,
Dist,
/// `getangle` (relative to ANGBASE) or `getorient` (absolute).
Angle {
orient: bool,
},
Real,
Int,
/// `getstring`; `spaces`: a space is part of the text (non-nil first argument).
String {
spaces: bool,
},
Kword,
/// `entsel` / `nentsel`: an object picked.
EntSel,
}
/// A `get…` call waiting for the user.
#[derive(Clone, Debug)]
pub struct GetRequest {
pub kind: GetKind,
pub message: String,
pub base: Option<[f64; 3]>,
pub initget: Initget,
/// `getdist`/`getangle` without a base point: the first point given, the second to come.
pub first: Option<[f64; 3]>,
}
impl GetRequest {
/// The prompt the command line shows.
pub fn prompt(&self) -> Prompt {
let any = self.initget.bits & 128 != 0 || !self.initget.keywords.is_empty();
let accept = match self.kind {
GetKind::Point | GetKind::Corner | GetKind::EntSel => Accept { text: any, ..Accept::POINT },
GetKind::Dist | GetKind::Angle { .. } => Accept { text: any, ..Accept::POINT_OR_NUMBER },
GetKind::Real | GetKind::Int => Accept { text: any, ..Accept::NUMBER },
GetKind::String { .. } | GetKind::Kword => Accept::TEXT,
};
let message = match (&self.first, self.message.is_empty()) {
(Some(_), _) => "Specify second point: ".to_string(),
(None, true) => default_message(self.kind).to_string(),
(None, false) => self.message.clone(),
};
let base = self.first.or(self.base).map(|p| Vec2::new(p[0], p[1]));
let mut p = Prompt::new(message, accept).base_opt(base);
p.keywords = self.initget.keywords.clone();
p.raw = true;
p
}
/// What an input gives: a value, a reason to ask again, or (for a distance or an angle
/// without a base point) the first of two points.
pub fn answer(&self, s: &mut Session, input: Input) -> Answer {
match self.answer_value(s, input) {
Ok(v) => Answer::Value(v),
Err(Again::Second(p)) => Answer::Second(p),
Err(Again::Msg(m)) => Answer::Again(m),
}
}
fn answer_value(&self, s: &mut Session, input: Input) -> Result<Value, Again> {
let ig = &self.initget;
let no_null = ig.bits & 1 != 0;
match input {
Input::Enter => {
if matches!(self.kind, GetKind::String { .. }) {
return Ok(Value::str(""));
}
if no_null {
return Err(match self.kind {
GetKind::Int => "Requires an integer value.",
GetKind::Real | GetKind::Dist | GetKind::Angle { .. } => "Requires numeric value.",
GetKind::Kword => "Invalid option keyword.",
_ => "Invalid point.",
}
.into());
}
Ok(Value::Nil)
}
Input::Keyword(k) => Ok(Value::str(&k)),
Input::Point(p) => self.point(s, [p.x, p.y, 0.0]),
// A deferred tangent/perpendicular snap is the point it was picked at here.
Input::Deferred(d) => self.point(s, [d.at.x, d.at.y, 0.0]),
Input::Pick(hs) => match (self.kind, hs.first()) {
(GetKind::EntSel, Some(h)) => Ok(Value::list(vec![Value::Ename(*h), Value::point(&[s.last_point.x, s.last_point.y, 0.0])])),
_ => Err("Invalid point.".into()),
},
Input::Text(t) => self.text(s, &t),
Input::Cancel => Err("*Cancel*".into()),
}
}
fn point(&self, s: &mut Session, p: [f64; 3]) -> Result<Value, Again> {
match self.kind {
GetKind::Point | GetKind::Corner => Ok(Value::point(&p)),
GetKind::Dist | GetKind::Angle { .. } => {
let Some(base) = self.first.or(self.base) else {
// The first of two points: ask for the second.
return Err(Again::Second(p));
};
let (dx, dy) = (p[0] - base[0], p[1] - base[1]);
if let GetKind::Angle { orient } = self.kind {
let mut a = dy.atan2(dx);
if !orient {
a -= s.lisp_angbase();
}
return self.check_number(a.rem_euclid(std::f64::consts::TAU), false);
}
let d = if self.initget.bits & 64 != 0 { (dx * dx + dy * dy).sqrt() } else { (dx * dx + dy * dy + (p[2] - base[2]).powi(2)).sqrt() };
self.check_number(d, false)
}
GetKind::EntSel => {
let ap = s.settings.pickbox.max(1.0) * s.pixel_size();
let space = s.state().map(|st| st.edit_space()).map_err(|e| Again::Msg(e.to_string()))?;
let d = s.doc().map_err(|e| Again::Msg(e.to_string()))?;
match crate::select::pick(d, &space, Vec2::new(p[0], p[1]), ap) {
Some(h) => Ok(Value::list(vec![Value::Ename(h), Value::point(&p)])),
None => Err("Missed, try again.".into()),
}
}
_ => Err(match self.kind {
GetKind::Int => "Requires an integer value.",
GetKind::Kword => "Invalid option keyword.",
GetKind::String { .. } => "Requires a string.",
_ => "Requires numeric value.",
}
.into()),
}
}
fn text(&self, s: &mut Session, t: &str) -> Result<Value, Again> {
let ig = &self.initget;
if let Some(k) = keyword(&ig.keywords, t) {
return Ok(Value::str(&k));
}
let any = ig.bits & 128 != 0;
match self.kind {
GetKind::String { spaces } => {
let t = if spaces { t } else { t.split(' ').next().unwrap_or("") };
Ok(Value::str(t))
}
GetKind::Kword => Err("Invalid option keyword.".into()),
GetKind::Int => match t.trim().parse::<i32>() {
Ok(i) if (-32768..=32767).contains(&i) => self.check_number(f64::from(i), true),
Ok(_) => Err("Value must be between -32768 and 32767.".into()),
Err(_) if any => Ok(Value::str(t)),
Err(_) => Err("Requires an integer value.".into()),
},
GetKind::Real => match t.trim().parse::<f64>() {
Ok(v) if v.is_finite() => self.check_number(v, false),
_ if any => Ok(Value::str(t)),
_ => Err("Requires numeric value.".into()),
},
GetKind::Dist => match crate::units::parse_distance(t) {
Some(v) => self.check_number(v, false),
None if any => Ok(Value::str(t)),
None => Err("Requires numeric distance or two points.".into()),
},
GetKind::Angle { orient } => match s.angle_settings().direction(t) {
Some(a) => {
let a = if orient { a } else { a - s.lisp_angbase() };
self.check_number(a.rem_euclid(std::f64::consts::TAU), false)
}
None if any => Ok(Value::str(t)),
None => Err("Requires valid numeric angle or second point.".into()),
},
GetKind::Point | GetKind::Corner | GetKind::EntSel => {
if any {
Ok(Value::str(t))
} else {
Err("Invalid point.".into())
}
}
}
}
fn check_number(&self, v: f64, int: bool) -> Result<Value, Again> {
let bits = self.initget.bits;
if bits & 2 != 0 && v == 0.0 {
return Err("Value must be nonzero.".into());
}
if bits & 4 != 0 && v < 0.0 {
return Err("Value must be positive.".into());
}
Ok(if int { Value::Int(v as i32) } else { Value::Real(v) })
}
}
/// What an input to a `get…` prompt gives.
pub enum Answer {
Value(Value),
/// Not an answer here: the message says why, and the prompt asks again.
Again(String),
/// `getdist`/`getangle` without a base point got its first point: ask for the second.
Second([f64; 3]),
}
enum Again {
Msg(String),
Second([f64; 3]),
}
impl From<String> for Again {
fn from(m: String) -> Again {
Again::Msg(m)
}
}
impl From<&str> for Again {
fn from(m: &str) -> Again {
Again::Msg(m.to_string())
}
}
fn default_message(kind: GetKind) -> &'static str {
match kind {
GetKind::EntSel => "Select object: ",
_ => "",
}
}
/// A keyword typed, matched as the command line matches them: the whole word or its capitals.
fn keyword(keywords: &[String], t: &str) -> Option<String> {
if keywords.is_empty() {
return None;
}
let mut p = Prompt::new("", Accept::TEXT);
p.keywords = keywords.to_vec();
p.match_keyword(t).or_else(|| t.strip_prefix('_').and_then(|r| p.match_keyword(r)))
}
impl Session {
/// ANGBASE (radians).
fn lisp_angbase(&self) -> f64 {
crate::sysvars::get(self, "ANGBASE").and_then(|v| v.as_f64()).unwrap_or(0.0)
}
}
// ---------------- the builtins ----------------
/// `(initget [bits] [keywords])`.
pub fn initget(ctx: &mut Ctx<'_>, args: Vec<Value>) -> Result<Flow, LispError> {
let mut ig = Initget::default();
for a in &args {
match a {
Value::Int(b) => ig.bits = *b,
Value::Str(k) => ig.keywords = k.split_whitespace().map(|w| w.split(',').next().unwrap_or(w).to_string()).collect(),
Value::Nil => {}
other => return Err(LispError::bad_arg("stringp", other)),
}
}
ctx.env.initget = Some(ig);
Ok(Flow::Value(Value::Nil))
}
/// Optional point then optional message, in that order (`getpoint`, `getdist`, `getangle`…).
fn point_and_message(args: &[Value], name: &str) -> Result<(Option<[f64; 3]>, String), LispError> {
let (mut base, mut msg) = (None, String::new());
for a in args {
match a {
Value::Str(s) => msg = s.to_string(),
Value::Cons(_) => base = Some(a.as_point().ok_or_else(|| LispError::bad_arg("2D/3D point", a))?),
Value::Nil => {}
other => return Err(LispError::bad_arg(if name == "getcorner" { "2D/3D point" } else { "stringp" }, other)),
}
}
if args.len() > 2 {
return Err(args_err(false));
}
Ok((base, msg))
}
fn wait(ctx: &mut Ctx<'_>, kind: GetKind, message: String, base: Option<[f64; 3]>) -> Result<Flow, LispError> {
let initget = ctx.env.initget.take().unwrap_or_default();
// Text printed so far shows before the prompt.
let printed = std::mem::take(&mut ctx.env.out);
if !printed.is_empty() {
for l in printed.split('\n').filter(|l| !l.is_empty()) {
ctx.s.echo(l.to_string());
ctx.env.printed_at = Some(ctx.s.log.len());
}
}
Ok(Flow::Wait(Need::Get(GetRequest { kind, message, base, initget, first: None })))
}
pub fn getpoint(ctx: &mut Ctx<'_>, args: Vec<Value>) -> Result<Flow, LispError> {
let (base, msg) = point_and_message(&args, "getpoint")?;
wait(ctx, GetKind::Point, msg, base)
}
pub fn getcorner(ctx: &mut Ctx<'_>, args: Vec<Value>) -> Result<Flow, LispError> {
let (base, msg) = point_and_message(&args, "getcorner")?;
if base.is_none() {
return Err(args_err(true));
}
wait(ctx, GetKind::Corner, msg, base)
}
pub fn getdist(ctx: &mut Ctx<'_>, args: Vec<Value>) -> Result<Flow, LispError> {
let (base, msg) = point_and_message(&args, "getdist")?;
wait(ctx, GetKind::Dist, msg, base)
}
pub fn getangle(ctx: &mut Ctx<'_>, args: Vec<Value>) -> Result<Flow, LispError> {
let (base, msg) = point_and_message(&args, "getangle")?;
wait(ctx, GetKind::Angle { orient: false }, msg, base)
}
pub fn getorient(ctx: &mut Ctx<'_>, args: Vec<Value>) -> Result<Flow, LispError> {
let (base, msg) = point_and_message(&args, "getorient")?;
wait(ctx, GetKind::Angle { orient: true }, msg, base)
}
fn message_only(args: &[Value]) -> Result<String, LispError> {
match args {
[] | [Value::Nil] => Ok(String::new()),
[Value::Str(s)] => Ok(s.to_string()),
[other] => Err(LispError::bad_arg("stringp", other)),
_ => Err(args_err(false)),
}
}
pub fn getreal(ctx: &mut Ctx<'_>, args: Vec<Value>) -> Result<Flow, LispError> {
let m = message_only(&args)?;
wait(ctx, GetKind::Real, m, None)
}
pub fn getint(ctx: &mut Ctx<'_>, args: Vec<Value>) -> Result<Flow, LispError> {
let m = message_only(&args)?;
wait(ctx, GetKind::Int, m, None)
}
pub fn getkword(ctx: &mut Ctx<'_>, args: Vec<Value>) -> Result<Flow, LispError> {
let m = message_only(&args)?;
wait(ctx, GetKind::Kword, m, None)
}
/// `(getstring [cr] [msg])`.
pub fn getstring(ctx: &mut Ctx<'_>, args: Vec<Value>) -> Result<Flow, LispError> {
let (spaces, msg) = match args.as_slice() {
[] => (false, String::new()),
[Value::Str(m)] => (false, m.to_string()),
[cr] => (!cr.is_nil(), String::new()),
[cr, Value::Str(m)] => (!cr.is_nil(), m.to_string()),
[_, Value::Nil] => (!args[0].is_nil(), String::new()),
[_, other] => return Err(LispError::bad_arg("stringp", other)),
_ => return Err(args_err(false)),
};
wait(ctx, GetKind::String { spaces }, msg, None)
}
/// `(entsel [msg])` and `(nentsel [msg])`.
pub fn entsel(ctx: &mut Ctx<'_>, args: Vec<Value>) -> Result<Flow, LispError> {
let m = message_only(&args)?;
wait(ctx, GetKind::EntSel, m, None)
}
/// A second point after the first of `getdist`/`getangle` without a base.
pub fn with_first(req: &GetRequest, p: [f64; 3]) -> GetRequest {
GetRequest { first: Some(p), ..req.clone() }
}
+613
View File
@@ -0,0 +1,613 @@
//! AutoLISP: load `.lsp` files, evaluate expressions typed at the command line, and run
//! commands defined with `(defun c:NAME …)`.
//!
//! The language runs on an explicit-stack machine ([`eval`]) that can stop for input
//! (`getpoint`, `pause`) and carry on later, so the interface keeps drawing while a routine
//! waits. Each drawing has its own symbols, as in AutoCAD. A run is one undo step.
pub mod builtins;
mod drawing;
pub mod eval;
pub mod input;
pub mod read;
mod startup;
pub mod value;
use std::collections::HashMap;
use std::sync::Arc;
use cadcraft_doc::{Drawing, Handle};
use crate::{Input, Prompt, Session};
use eval::{Ctx, Machine, Need, Outcome};
use value::{Sym, Value};
/// A LISP error: the message `*error*` receives (`bad argument type: numberp: nil`…).
#[derive(Clone, Debug)]
pub struct LispError {
pub msg: String,
/// `exit`/`quit`, or a cancelled input: not caught by `vl-catch-all-apply`.
pub quit: bool,
}
impl LispError {
pub fn new(msg: impl Into<String>) -> LispError {
LispError { msg: msg.into(), quit: false }
}
pub fn bad_arg(pred: &str, v: &Value) -> LispError {
LispError::new(format!("bad argument type: {pred}: {}", value::to_text(v, true)))
}
pub fn quit(msg: impl Into<String>) -> LispError {
LispError { msg: msg.into(), quit: true }
}
}
/// One drawing's LISP environment.
pub struct Env {
pub globals: HashMap<Sym, Value>,
/// Printed text not yet ended by a newline.
pub out: String,
/// `initget` settings for the next `get…` call.
pub initget: Option<input::Initget>,
pub next_pickset: u32,
pub files: HashMap<u32, builtins::LispFile>,
pub next_file: u32,
/// Entities `entdel` deleted (it brings them back when called again), with their space.
pub deleted: HashMap<Handle, (cadcraft_doc::Space, Arc<cadcraft_doc::Entity>)>,
/// Every entity's handle in creation order, for the drawing it was taken from (`entnext`).
pub order: Option<(usize, Arc<Vec<Handle>>)>,
/// `tblnext`'s place in each table.
pub tblnext: HashMap<String, usize>,
/// The command history's length after LISP last printed a line: when something else wrote since
/// (a prompt, a command's echo), a leading newline only ends that line.
pub printed_at: Option<usize>,
}
impl Default for Env {
fn default() -> Self {
let mut e = Env {
globals: HashMap::new(),
out: String::new(),
initget: None,
next_pickset: 1,
files: HashMap::new(),
next_file: 1,
deleted: HashMap::new(),
order: None,
tblnext: HashMap::new(),
printed_at: None,
};
builtins::install(&mut e);
e
}
}
impl Env {
/// Set a variable (nil unbinds it).
pub fn set(&mut self, s: &Sym, v: Value) {
if v.is_nil() {
self.globals.remove(s);
} else {
self.globals.insert(s.clone(), v);
}
}
pub fn get(&self, name: &str) -> Value {
eval::lookup(self, &Sym::new(name))
}
/// Text printed by `princ` and friends.
pub fn print(&mut self, text: &str) {
self.out.push_str(text);
}
}
/// The session's LISP: each drawing's environment and the routine running, if any.
#[derive(Default)]
pub struct LispState {
envs: HashMap<u64, Env>,
run: Option<Box<Run>>,
/// A routine runs, from its start to its end (also while its machine is out of `run`).
active: bool,
/// Files loaded by APPLOAD, by name, for `(load name)` where there is no file system.
files: HashMap<String, String>,
/// Where startup files are read from (see `startup.rs`).
support_dir: Option<std::path::PathBuf>,
/// Drawings that had their startup files.
autoloaded: std::collections::HashSet<u64>,
/// `acad.lsp` was loaded this session.
acad_loaded: bool,
/// Memory caps (lowered by tests).
pub(crate) limits: Limits,
}
/// What a run may use (see [`value`]'s memory notes).
#[derive(Clone, Copy, Debug)]
pub(crate) struct Limits {
/// Live cons cells (all sessions).
pub cells: usize,
/// Bytes of strings, printed text and files read per run.
pub string_bytes: u64,
}
impl Default for Limits {
fn default() -> Self {
Limits { cells: value::MAX_CELLS, string_bytes: value::STRING_BUDGET }
}
}
impl std::fmt::Debug for LispState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "LispState {{ drawings: {}, running: {} }}", self.envs.len(), self.run.is_some())
}
}
/// A routine on its way: the machine, what it waits for, and its undo step.
struct Run {
machine: Machine,
/// The drawing it runs in.
uid: u64,
waiting: Option<Need>,
/// A command-line expression: its value is printed at the end.
print: bool,
/// Undo label: the command (`c:` function) or "LISP".
label: String,
/// The drawing and selection before the run (one undo step for the whole run).
before: Option<(Arc<Drawing>, Vec<Handle>)>,
/// Inputs given up front (`lisp {code, inputs}`): what `get…` calls answer.
inputs: std::collections::VecDeque<Value>,
/// Run to the end without waiting (programmatic calls): `get…` without an input gets nil.
blocking: bool,
/// Steps taken, for the cap on blocking runs.
steps: u64,
/// The `*error*` handler is the one running.
handler: bool,
/// Bytes of strings and printed text the run has made ([`value::charge_bytes`]).
string_bytes: u64,
/// Cons cells alive when the run started: cells the drawing's variables already hold don't
/// stop a run (it can still set them to nil) unless it adds more.
cells_at_start: usize,
}
/// Steps per slice before the interface gets a frame.
const SLICE: u64 = 400_000;
/// Steps a blocking run (a programmatic call) may take before it is stopped.
const BLOCKING_CAP: u64 = 500_000_000;
impl Session {
/// The LISP environment of the active drawing.
fn lisp_env_take(&mut self) -> Env {
let uid = self.state().map(|s| s.uid).unwrap_or(0);
self.lisp.envs.remove(&uid).unwrap_or_default()
}
fn lisp_env_put(&mut self, uid: u64, env: Env) {
self.lisp.envs.insert(uid, env);
}
/// A routine is running (possibly waiting for input).
pub fn lisp_running(&self) -> bool {
self.lisp.active
}
/// A prompt waits for input: a command's, or a LISP routine's `get…` function. Clicks and
/// typing answer it; with none, the interface is idle (clicks select, Delete erases).
pub fn prompting(&self) -> bool {
self.running.is_some() || self.lisp_prompt().is_some()
}
/// A routine is stopped, waiting for input or for a frame.
pub(crate) fn lisp_waiting(&self) -> bool {
self.lisp.run.is_some()
}
/// The value of a LISP variable in the active drawing (nil when unbound).
pub fn lisp_var(&self, name: &str) -> Value {
let uid = self.state().map(|s| s.uid).unwrap_or(0);
self.lisp.envs.get(&uid).map(|e| e.get(name)).unwrap_or_default()
}
/// Evaluate LISP source (a command-line expression or a file's contents). `print`: show the
/// value on the command line. `blocking`: run to the end, answering `get…` calls from
/// `inputs` (nil when they run out), as programmatic calls must; otherwise the routine may
/// wait for the user.
pub fn lisp_eval(
&mut self,
src: &str,
print: bool,
blocking: bool,
inputs: Vec<Value>,
) -> crate::Result<Option<std::result::Result<Value, String>>> {
let forms = read::read_all(src).map_err(|e| crate::EngineError::Other(format!("; error: {}", e.msg)))?;
self.lisp_start(Machine::forms(forms), print, blocking, inputs, "LISP".into())
}
/// Call a LISP function (`c:` commands typed at the command line).
fn lisp_call(&mut self, f: Value, label: String) -> crate::Result<Option<std::result::Result<Value, String>>> {
self.lisp_start(Machine::calling(f, Vec::new()), false, false, Vec::new(), label)
}
fn lisp_start(
&mut self,
machine: Machine,
print: bool,
blocking: bool,
inputs: Vec<Value>,
label: String,
) -> crate::Result<Option<std::result::Result<Value, String>>> {
if self.lisp.active {
return Err(crate::EngineError::Other("a LISP routine is already running".into()));
}
self.lisp.active = true;
let st = self.state()?;
let before = Some((st.doc.clone(), st.selection.clone()));
let uid = st.uid;
self.lisp.run = Some(Box::new(Run {
machine,
uid,
waiting: None,
print,
label,
before,
inputs: inputs.into(),
blocking,
steps: 0,
handler: false,
string_bytes: 0,
cells_at_start: value::live_cells(),
}));
self.lisp_drive();
if blocking {
// A blocking run that waits for a command's input (`pause`) can't get it: cancel.
if self.lisp.run.is_some() {
self.lisp_abort("Function cancelled");
}
}
Ok(self.lisp_last.take())
}
/// Call once per frame: loads the startup files into a drawing seen for the first time, and
/// carries on a routine that gave the interface a frame.
pub fn lisp_tick(&mut self) {
// Called every frame, outside any command: a panic in it ends the routine with an error
// instead of taking down the app.
if let Err(p) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| self.lisp_tick_unguarded())) {
self.lisp_internal_error(&crate::guard::message(&*p));
}
}
fn lisp_tick_unguarded(&mut self) {
// A drawing seen for the first time gets its startup files.
self.lisp_autoload();
if self.lisp.run.as_ref().is_some_and(|r| matches!(r.waiting, Some(Need::Yield))) {
if let Some(r) = self.lisp.run.as_mut() {
r.waiting = None;
}
self.lisp_drive();
}
}
/// Whether the routine needs ticks ([`Session::lisp_tick`]) to go on.
pub fn lisp_busy(&self) -> bool {
self.lisp.run.as_ref().is_some_and(|r| matches!(r.waiting, Some(Need::Yield)))
}
/// Run the machine until it finishes or waits.
fn lisp_drive(&mut self) {
loop {
let Some(mut run) = self.lisp.run.take() else { return };
if run.waiting.is_some() {
self.lisp.run = Some(run);
return;
}
// The routine belongs to its drawing; another one became active: stop it.
if self.state().map(|s| s.uid).ok() != Some(run.uid) {
self.lisp.run = Some(run);
self.lisp_abort("Function cancelled");
return;
}
let mut env = self.lisp_env_take();
value::set_string_budget(run.string_bytes, self.lisp.limits.string_bytes);
value::set_cell_limit(self.lisp.limits.cells.max(run.cells_at_start));
let outcome = {
let machine = &mut run.machine;
let mut ctx = Ctx { s: self, env: &mut env, depth: 0 };
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| machine.run(&mut ctx, SLICE)))
};
run.string_bytes = value::string_bytes_used();
let outcome = match outcome {
Ok(o) => o,
Err(p) => {
// A panic inside a built-in: put the drawing back as the routine found it.
if let Some((doc, sel)) = &run.before
&& let Some(st) = self.docs.iter_mut().find(|d| d.uid == run.uid)
{
st.doc = doc.clone();
st.selection = sel.clone();
}
run.handler = true;
Outcome::Error(LispError::quit(format!("internal error: {}", crate::guard::message(&*p))))
}
};
let uid = run.uid;
self.lisp_flush(&mut env, false);
self.lisp_env_put(uid, env);
match outcome {
Outcome::Done(v) => {
self.lisp.run = Some(run);
self.lisp_end(Ok(v));
return;
}
Outcome::Error(e) => {
self.lisp.run = Some(run);
self.lisp_error(e);
return;
}
Outcome::Wait(Need::Yield) => {
run.steps = run.steps.saturating_add(SLICE);
if run.blocking {
if run.steps > BLOCKING_CAP {
self.lisp.run = Some(run);
self.lisp_error(LispError::quit(format!("routine stopped after {BLOCKING_CAP} steps")));
return;
}
self.lisp.run = Some(run);
continue;
}
run.waiting = Some(Need::Yield);
self.lisp.run = Some(run);
return;
}
Outcome::Wait(Need::Pause) => {
// The command `command` left running takes the next input; with none
// running there is nothing to wait for.
if self.running.is_none() || run.blocking {
run.machine.resume(Value::Nil);
if run.blocking && self.running.is_some() {
self.cancel();
}
self.lisp.run = Some(run);
continue;
}
run.waiting = Some(Need::Pause);
self.lisp.run = Some(run);
return;
}
Outcome::Wait(Need::Get(req)) => {
if let Some(v) = run.inputs.pop_front() {
run.machine.resume(v);
self.lisp.run = Some(run);
continue;
}
if run.blocking {
run.machine.resume(Value::Nil);
self.lisp.run = Some(run);
continue;
}
run.waiting = Some(Need::Get(req));
self.lisp.run = Some(run);
return;
}
}
}
}
/// Move printed text to the command history: complete lines, and with `all` the rest too.
fn lisp_flush(&mut self, env: &mut Env, all: bool) {
while let Some(i) = env.out.find('\n') {
let line: String = env.out.drain(..=i).collect();
let line = line.trim_end_matches(['\n', '\r']).to_string();
// `(princ "\nText")` after a prompt or a command's echo ends that line instead of
// leaving an empty one; after LISP's own line it does print an empty line.
if line.is_empty() && env.printed_at != Some(self.log.len()) {
env.printed_at = Some(self.log.len());
continue;
}
self.echo(line);
env.printed_at = Some(self.log.len());
}
if all && !env.out.is_empty() {
let line = std::mem::take(&mut env.out);
self.echo(line);
env.printed_at = Some(self.log.len());
}
}
/// The routine finished: print its value (command-line expressions), record its undo step.
fn lisp_end(&mut self, result: std::result::Result<Value, String>) {
let Some(run) = self.lisp.run.take() else { return };
self.lisp.active = false;
let mut env = self.lisp.envs.remove(&run.uid).unwrap_or_default();
self.lisp_flush(&mut env, true);
self.lisp.envs.insert(run.uid, env);
if let (Ok(v), true) = (&result, run.print) {
let text = value::to_text(v, true);
if !builtins::is_null_symbol(v) {
self.echo(text);
}
}
if let Some((doc, sel)) = run.before
&& let Some(st) = self.docs.iter_mut().find(|d| d.uid == run.uid)
&& !Arc::ptr_eq(&doc, &st.doc)
{
st.push_undo(crate::Snapshot { label: run.label.clone(), doc, selection: sel });
st.revision += 1;
}
self.lisp_last = Some(result);
}
/// A LISP error: run `*error*` when the routine defined one, else print it.
fn lisp_error(&mut self, e: LispError) {
let Some(mut run) = self.lisp.run.take() else { return };
// The command a failed routine left running is cancelled.
if self.running.is_some() {
self.cancel();
}
let handler = self.lisp.envs.get(&run.uid).map(|env| env.get("*ERROR*")).unwrap_or_default();
if !run.handler && matches!(handler, Value::Func(_) | Value::Subr(_)) {
run.handler = true;
run.waiting = None;
run.print = false;
run.machine = Machine::calling(handler, vec![Value::str(&e.msg)]);
self.lisp.run = Some(run);
self.lisp_drive();
// The routine failed whatever its handler returned.
if let Some(Ok(_)) = &self.lisp_last {
self.lisp_last = Some(Err(e.msg));
}
return;
}
self.lisp.run = Some(run);
let mut env = self.lisp_env_take();
self.lisp_flush(&mut env, true);
let uid = self.state().map(|s| s.uid).unwrap_or(0);
self.lisp_env_put(uid, env);
self.echo(format!("; error: {}", e.msg));
self.lisp_end(Err(e.msg));
}
/// A panic escaped the LISP machinery outside a routine's steps: end the routine, if any,
/// with an error, so the session stays usable.
fn lisp_internal_error(&mut self, msg: &str) {
let msg = format!("internal error: {msg}");
if self.lisp.run.is_some() {
if let Some(r) = self.lisp.run.as_mut() {
// Don't run the routine's own `*error*` after an internal failure.
r.handler = true;
}
self.lisp_error(LispError::quit(msg));
} else if self.lisp.active {
self.lisp.active = false;
self.echo(format!("; error: {msg}"));
self.lisp_last = Some(Err(msg));
} else {
self.echo(format!("; error: {msg}"));
}
}
/// Stop the routine (Esc while it waits, its drawing closed) with `msg` as its error.
pub fn lisp_abort(&mut self, msg: &str) {
if self.lisp.run.is_some() {
self.lisp_error(LispError::quit(msg));
}
}
/// A routine waits for the user's answer to a `get…` function: its prompt.
pub(crate) fn lisp_prompt(&self) -> Option<Prompt> {
match self.lisp.run.as_ref()?.waiting.as_ref()? {
Need::Get(req) => Some(req.prompt()),
_ => None,
}
}
/// The rubber band of a waiting `get…` function: a rectangle from `getcorner`'s corner, a
/// line from the base point of the others. `None` when no routine waits for input.
pub(crate) fn lisp_preview(&self, cursor: cadcraft_geom::Vec2) -> Option<Vec<cadcraft_doc::EntityKind>> {
let Need::Get(req) = self.lisp.run.as_ref()?.waiting.as_ref()? else { return None };
let line =
|a: cadcraft_geom::Vec2, b: cadcraft_geom::Vec2| cadcraft_doc::EntityKind::Line(cadcraft_doc::Line { a: a.to3(0.0), b: b.to3(0.0) });
let Some(b) = req.first.or(req.base).map(|p| cadcraft_geom::Vec2::new(p[0], p[1])) else { return Some(Vec::new()) };
Some(match req.kind {
input::GetKind::Corner => {
let (c1, c2) = (cadcraft_geom::Vec2::new(cursor.x, b.y), cadcraft_geom::Vec2::new(b.x, cursor.y));
vec![line(b, c1), line(c1, cursor), line(cursor, c2), line(c2, b)]
}
input::GetKind::Point | input::GetKind::Dist | input::GetKind::Angle { .. } => vec![line(b, cursor)],
_ => Vec::new(),
})
}
/// Give an input to a waiting routine. `true` when the routine took it.
pub(crate) fn lisp_input(&mut self, input: Input) -> bool {
let Some(req) = self.lisp.run.as_ref().and_then(|r| match &r.waiting {
Some(Need::Get(req)) => Some(req.clone()),
_ => None,
}) else {
return false;
};
if input == Input::Cancel {
self.echo("*Cancel*");
self.lisp_abort("Function cancelled");
return true;
}
match req.answer(self, input) {
input::Answer::Value(v) => {
if let Some(r) = self.lisp.run.as_mut() {
r.waiting = None;
r.machine.resume(v);
}
self.lisp_drive();
}
// Not an answer this prompt takes: say why and ask again.
input::Answer::Again(msg) => self.echo(msg),
// The first of two points: the prompt asks for the second.
input::Answer::Second(p) => {
if let Some(r) = self.lisp.run.as_mut() {
r.waiting = Some(Need::Get(input::with_first(&req, p)));
}
}
}
true
}
/// After the running command took an input: a routine paused for it carries on.
pub(crate) fn lisp_after_command_input(&mut self) {
if self.lisp.run.as_ref().is_some_and(|r| matches!(r.waiting, Some(Need::Pause))) {
if let Some(r) = self.lisp.run.as_mut() {
r.waiting = None;
r.machine.resume(Value::Nil);
}
self.lisp_drive();
}
}
/// Whether commands' undo steps go into a running routine's (one step per routine).
pub(crate) fn lisp_groups_undo(&self) -> bool {
self.lisp.active
}
/// A `c:` function for a command name typed at the command line.
pub(crate) fn lisp_command(&self, name: &str) -> Option<Value> {
if self.lisp.active {
return None;
}
let uid = self.state().ok()?.uid;
let f = self.lisp.envs.get(&uid)?.get(&format!("C:{name}"));
matches!(f, Value::Func(_) | Value::Subr(_)).then_some(f)
}
/// Run a `c:` command.
pub(crate) fn lisp_run_command(&mut self, name: &str, f: Value) -> crate::Result<()> {
self.lisp_call(f, name.to_ascii_uppercase()).map(|_| ())
}
}
pub(crate) use drawing::from_json as drawing_from_json;
pub(crate) use drawing::load_source;
impl Session {
/// A file APPLOAD loaded, by its name (with or without `.lsp`, any case).
pub(crate) fn lisp_file(&self, name: &str) -> Option<String> {
let key = file_key(name);
self.lisp.files.get(&key).cloned()
}
/// Load a LISP file (APPLOAD, a dropped `.lsp` file): its forms run in the active drawing,
/// and `(load name)` finds it later even where there is no file system. `blocking`: run to
/// the end (programmatic calls).
pub fn lisp_load(&mut self, name: &str, text: &str, blocking: bool) -> crate::Result<Option<std::result::Result<Value, String>>> {
self.lisp.files.insert(file_key(name), text.to_string());
let r = self.lisp_eval(text, false, blocking, Vec::new())?;
if !matches!(r, Some(Err(_))) {
let shown = std::path::Path::new(name).file_name().and_then(|n| n.to_str()).unwrap_or(name).to_string();
self.echo(format!("{shown} successfully loaded."));
}
Ok(r)
}
}
fn file_key(name: &str) -> String {
let base = std::path::Path::new(&name.replace('\\', "/")).file_name().and_then(|n| n.to_str()).unwrap_or(name).to_ascii_lowercase();
base.strip_suffix(".lsp").map(str::to_string).unwrap_or(base)
}
#[cfg(test)]
mod tests;
+249
View File
@@ -0,0 +1,249 @@
//! The reader: AutoLISP source text to values. Lists, dotted pairs, `'x` quoting, strings with
//! backslash escapes, integers (a literal beyond 32 bits reads as a real), reals, symbols
//! (upper-cased), `;` line comments and `;| … |;` block comments.
//!
//! It keeps its own stack instead of recursing, so deeply nested input can't overflow it.
use super::LispError;
use super::value::{MAX_DEPTH, Value};
enum Open {
List { items: Vec<Value>, tail: Option<Value>, dot: bool },
Quote,
}
/// Every top-level form in `src`.
pub fn read_all(src: &str) -> Result<Vec<Value>, LispError> {
let mut r = Reader { chars: src.chars().collect(), pos: 0 };
let mut out = Vec::new();
while let Some(v) = r.datum()? {
out.push(v);
}
Ok(out)
}
/// The first form in `src` (`read`), nil when there is none.
pub fn read_one(src: &str) -> Result<Value, LispError> {
let mut r = Reader { chars: src.chars().collect(), pos: 0 };
Ok(r.datum()?.unwrap_or(Value::Nil))
}
struct Reader {
chars: Vec<char>,
pos: usize,
}
impl Reader {
fn peek(&self) -> Option<char> {
self.chars.get(self.pos).copied()
}
fn skip_space(&mut self) {
while let Some(c) = self.peek() {
if c.is_whitespace() {
self.pos += 1;
} else if c == ';' {
if self.chars.get(self.pos + 1) == Some(&'|') {
// Block comment up to |;
self.pos += 2;
while self.pos < self.chars.len() && !(self.chars[self.pos] == '|' && self.chars.get(self.pos + 1) == Some(&';')) {
self.pos += 1;
}
self.pos = (self.pos + 2).min(self.chars.len());
} else {
while let Some(c) = self.peek() {
self.pos += 1;
if c == '\n' {
break;
}
}
}
} else {
break;
}
}
}
/// The next complete datum; `None` at the end of the input.
fn datum(&mut self) -> Result<Option<Value>, LispError> {
let mut stack: Vec<Open> = Vec::new();
loop {
self.skip_space();
let Some(c) = self.peek() else {
if stack.iter().any(|o| matches!(o, Open::List { .. })) {
return Err(LispError::new("malformed list on input"));
}
if !stack.is_empty() {
return Err(LispError::new("malformed quote on input"));
}
return Ok(None);
};
let done = match c {
'(' => {
self.pos += 1;
if stack.len() >= MAX_DEPTH {
return Err(LispError::new("too deeply nested input"));
}
stack.push(Open::List { items: Vec::new(), tail: None, dot: false });
continue;
}
')' => {
self.pos += 1;
match stack.pop() {
Some(Open::List { items, tail, dot }) => {
if dot && tail.is_none() {
return Err(LispError::new("misplaced dot on input"));
}
Value::list_with_tail(items, tail.unwrap_or(Value::Nil))
}
Some(Open::Quote) => return Err(LispError::new("malformed quote on input")),
// A stray closing parenthesis at top level is ignored, as AutoCAD does.
None => continue,
}
}
'\'' => {
self.pos += 1;
if stack.len() >= MAX_DEPTH {
return Err(LispError::new("too deeply nested input"));
}
stack.push(Open::Quote);
continue;
}
'"' => {
self.pos += 1;
self.string()?
}
_ => {
let tok = self.token();
if tok == "." && matches!(stack.last(), Some(Open::List { items, dot: false, .. }) if !items.is_empty()) {
if let Some(Open::List { dot, .. }) = stack.last_mut() {
*dot = true;
}
continue;
}
atom(&tok)
}
};
// A finished datum: wrap pending quotes, then hand it to the enclosing list.
let mut v = done;
loop {
match stack.last_mut() {
Some(Open::Quote) => {
stack.pop();
v = Value::list(vec![Value::sym("QUOTE"), v]);
}
Some(Open::List { items, tail, dot }) => {
if *dot {
if tail.is_some() {
return Err(LispError::new("misplaced dot on input"));
}
*tail = Some(v);
} else {
items.push(v);
}
break;
}
None => return Ok(Some(v)),
}
}
}
}
fn string(&mut self) -> Result<Value, LispError> {
let mut s = String::new();
loop {
let Some(c) = self.peek() else { return Err(LispError::new("malformed string on input")) };
self.pos += 1;
match c {
'"' => return Ok(Value::str(&s)),
'\\' => {
let Some(e) = self.peek() else { return Err(LispError::new("malformed string on input")) };
self.pos += 1;
match e {
'n' => s.push('\n'),
'r' => s.push('\r'),
't' => s.push('\t'),
'e' => s.push('\u{1b}'),
'0'..='7' => {
// \nnn: an octal character code.
let mut code = e.to_digit(8).unwrap_or(0);
for _ in 0..2 {
match self.peek().and_then(|d| d.to_digit(8)) {
Some(d) => {
code = code * 8 + d;
self.pos += 1;
}
None => break,
}
}
s.extend(char::from_u32(code));
}
other => s.push(other),
}
}
c => s.push(c),
}
}
}
fn token(&mut self) -> String {
let start = self.pos;
while let Some(c) = self.peek() {
if c.is_whitespace() || matches!(c, '(' | ')' | '\'' | '"' | ';') {
break;
}
self.pos += 1;
}
self.chars[start..self.pos].iter().collect()
}
}
/// A number or a symbol.
fn atom(tok: &str) -> Value {
if let Ok(i) = tok.parse::<i64>() {
return match i32::try_from(i) {
Ok(i) => Value::Int(i),
Err(_) => Value::Real(i as f64),
};
}
let numeric = tok.chars().next().is_some_and(|c| c.is_ascii_digit() || matches!(c, '.' | '-' | '+'))
&& tok.chars().any(|c| c.is_ascii_digit())
&& tok.chars().all(|c| c.is_ascii_digit() || matches!(c, '.' | '-' | '+' | 'e' | 'E'));
if numeric && let Ok(r) = tok.parse::<f64>() {
return Value::Real(r);
}
if tok.eq_ignore_ascii_case("nil") {
return Value::Nil;
}
Value::sym(tok)
}
#[cfg(test)]
mod tests {
use super::super::value::to_text;
use super::*;
fn show(src: &str) -> Vec<String> {
read_all(src).unwrap().iter().map(|v| to_text(v, true)).collect()
}
#[test]
fn reads_forms_atoms_and_comments() {
assert_eq!(show("(defun c:hi () (princ \"hi\\n\")) ; c\n;| block\n |; 'x"), ["(DEFUN C:HI nil (PRINC \"hi\\n\"))", "'X"]);
assert_eq!(show("(8 . \"0\") (1 2 . 3) (a .b)"), ["(8 . \"0\")", "(1 2 . 3)", "(A .B)"]);
assert_eq!(show("1 -2 3.5 .5 1e3 4294967296 nil T"), ["1", "-2", "3.5", "0.5", "1000.0", "4.29497e+009", "nil", "T"]);
assert_eq!(show("\"a\\\\b\\\"c\\101\""), ["\"a\\\\b\\\"cA\""]);
}
#[test]
fn malformed_and_hostile_input_is_an_error_not_a_crash() {
assert!(read_all("(1 2").is_err());
assert!(read_all("\"open").is_err());
assert!(read_all("(1 . )").is_err());
assert!(read_all("'").is_err());
assert!(read_all(&"(".repeat(100_000)).is_err());
assert!(read_all(&"'".repeat(100_000)).is_err());
// A stray ) at top level is skipped.
assert_eq!(show(") 1"), ["1"]);
}
}
+59
View File
@@ -0,0 +1,59 @@
//! LISP loaded at startup, from CADCraft's support folder (the app sets it: `<settings>/support`):
//!
//! - `acad.lsp` once per session, in the first drawing (every drawing with ACADLSPASDOC on);
//! - `acaddoc.lsp` in every drawing opened or created;
//! - then `S::STARTUP`, when they define it.
//!
//! Never from a drawing's own folder: an `acaddoc.lsp` that came along with a downloaded drawing
//! would run on opening it, the way LISP viruses spread.
use std::path::PathBuf;
use super::value::Value;
use crate::Session;
/// The startup files, in load order: (name, once per session unless ACADLSPASDOC).
const FILES: [(&str, bool); 2] = [("acad.lsp", true), ("acaddoc.lsp", false)];
impl Session {
/// The folder startup LISP files are read from (`None`: no startup files, the default; the
/// app sets it).
pub fn set_lisp_support_dir(&mut self, dir: Option<PathBuf>) {
self.lisp.support_dir = dir;
}
/// The folder startup LISP files are read from.
pub fn lisp_support_dir(&self) -> Option<&std::path::Path> {
self.lisp.support_dir.as_deref()
}
/// Load the startup files into the active drawing, once (from [`Session::lisp_tick`]).
pub(crate) fn lisp_autoload(&mut self) {
let Some(dir) = self.lisp.support_dir.clone() else { return };
if self.lisp.active {
return;
}
let Ok(uid) = self.state().map(|s| s.uid) else { return };
if !self.lisp.autoloaded.insert(uid) {
return;
}
let per_drawing = self.settings.acadlspasdoc;
for (name, once) in FILES {
if once && self.lisp.acad_loaded && !per_drawing {
continue;
}
let Some(text) = super::builtins::read_text_file(&dir.join(name).to_string_lossy()) else { continue };
if once {
self.lisp.acad_loaded = true;
}
self.lisp.files.insert(name.trim_end_matches(".lsp").to_string(), text.clone());
// Startup files don't ask for input: they run to the end. Errors are shown.
if let Err(e) = self.lisp_eval(&text, false, true, Vec::new()) {
self.echo(format!("{name}: {e}"));
}
}
if matches!(self.lisp_var("S::STARTUP"), Value::Func(_) | Value::Subr(_)) {
let _ = self.lisp_eval("(S::STARTUP)", false, true, Vec::new());
}
}
}
+553
View File
@@ -0,0 +1,553 @@
#![allow(clippy::unwrap_used)]
use super::value::{Value, to_text};
use crate::Session;
use crate::cmd::file::{EntityCodec, set_entity_codec};
fn session() -> Session {
set_entity_codec(EntityCodec { groups: cadcraft_io::entity_groups, entity: cadcraft_io::entity_from_groups });
Session::new()
}
/// Evaluate `code` to the end; its value as `prin1` shows it, or the error message.
fn eval(s: &mut Session, code: &str) -> Result<String, String> {
eval_with(s, code, Vec::new())
}
fn eval_with(s: &mut Session, code: &str, inputs: Vec<Value>) -> Result<String, String> {
match s.lisp_eval(code, false, true, inputs) {
Ok(Some(Ok(v))) => Ok(to_text(&v, true)),
Ok(Some(Err(e))) => Err(e),
Ok(None) => Err("did not finish".into()),
Err(e) => Err(e.to_string()),
}
}
fn ok(s: &mut Session, code: &str) -> String {
match eval(s, code) {
Ok(v) => v,
Err(e) => panic!("{code}: {e}"),
}
}
#[test]
fn arithmetic_follows_autolisp() {
let mut s = session();
assert_eq!(ok(&mut s, "(+ 1 2 3)"), "6");
assert_eq!(ok(&mut s, "(+ 1 2.0)"), "3.0");
assert_eq!(ok(&mut s, "(/ 7 2)"), "3");
assert_eq!(ok(&mut s, "(/ 7 2.0)"), "3.5");
assert_eq!(ok(&mut s, "(- 5)"), "-5");
assert_eq!(ok(&mut s, "(rem 7 3)"), "1");
assert_eq!(ok(&mut s, "(1+ 41)"), "42");
assert_eq!(ok(&mut s, "(fix 3.7)"), "3");
assert_eq!(ok(&mut s, "(float 3)"), "3.0");
assert_eq!(ok(&mut s, "(expt 2 10)"), "1024");
assert_eq!(ok(&mut s, "(sqrt 16)"), "4.0");
assert_eq!(ok(&mut s, "(max 1 5 3)"), "5");
assert_eq!(ok(&mut s, "(min 1 5.0 3)"), "1.0");
assert_eq!(ok(&mut s, "(abs -4)"), "4");
assert_eq!(ok(&mut s, "(logand 12 10)"), "8");
assert_eq!(ok(&mut s, "(lsh 1 4)"), "16");
assert_eq!(ok(&mut s, "(+ 2147483647 1)"), "-2147483648", "32-bit integers wrap");
assert_eq!(ok(&mut s, "(< 1 2 3)"), "T");
assert_eq!(ok(&mut s, "(= 1 1.0)"), "T");
assert_eq!(ok(&mut s, "(= \"a\" \"a\")"), "T");
assert_eq!(eval(&mut s, "(/ 1 0)"), Err("divide by zero".into()));
assert_eq!(eval(&mut s, "(+ 1 nil)"), Err("bad argument type: numberp: nil".into()));
}
#[test]
fn lists_and_strings() {
let mut s = session();
assert_eq!(ok(&mut s, "(cons 1 '(2 3))"), "(1 2 3)");
assert_eq!(ok(&mut s, "(cons 8 \"0\")"), "(8 . \"0\")");
assert_eq!(ok(&mut s, "(cadr '(1 2 3))"), "2");
assert_eq!(ok(&mut s, "(cdr (assoc 40 '((10 1.0 2.0 0.0) (40 . 2.5))))"), "2.5");
assert_eq!(ok(&mut s, "(subst '(40 . 5.0) '(40 . 2.5) '((8 . \"0\") (40 . 2.5)))"), "((8 . \"0\") (40 . 5.0))");
assert_eq!(ok(&mut s, "(append '(1) '(2 3) nil '(4))"), "(1 2 3 4)");
assert_eq!(ok(&mut s, "(reverse '(1 2 3))"), "(3 2 1)");
assert_eq!(ok(&mut s, "(nth 1 '(a b c))"), "B");
assert_eq!(ok(&mut s, "(member 2 '(1 2 3))"), "(2 3)");
assert_eq!(ok(&mut s, "(length '(1 2 3))"), "3");
assert_eq!(ok(&mut s, "(last '(1 2 3))"), "3");
assert_eq!(ok(&mut s, "(mapcar '+ '(1 2 3) '(10 20 30))"), "(11 22 33)");
assert_eq!(ok(&mut s, "(mapcar '(lambda (x) (* x x)) '(1 2 3))"), "(1 4 9)");
assert_eq!(ok(&mut s, "(apply 'max '(3 9 2))"), "9");
assert_eq!(ok(&mut s, "(vl-sort '(3 1 2) '<)"), "(1 2 3)");
assert_eq!(ok(&mut s, "(vl-sort '(3 1 2) (function (lambda (a b) (> a b))))"), "(3 2 1)");
assert_eq!(ok(&mut s, "(vl-remove-if 'minusp '(1 -2 3))"), "(1 3)");
assert_eq!(ok(&mut s, "(strcat \"a\" \"b\" \"c\")"), "\"abc\"");
assert_eq!(ok(&mut s, "(substr \"Hello\" 2 3)"), "\"ell\"");
assert_eq!(ok(&mut s, "(strcase \"abc\")"), "\"ABC\"");
assert_eq!(ok(&mut s, "(strlen \"abc\" \"de\")"), "5");
assert_eq!(ok(&mut s, "(itoa 42)"), "\"42\"");
assert_eq!(ok(&mut s, "(atoi \"12abc\")"), "12");
assert_eq!(ok(&mut s, "(atof \"2.5mm\")"), "2.5");
assert_eq!(ok(&mut s, "(atof \"x\")"), "0.0");
assert_eq!(ok(&mut s, "(wcmatch \"Name\" \"N*\")"), "T");
assert_eq!(ok(&mut s, "(wcmatch \"A12\" \"@##\")"), "T");
assert_eq!(ok(&mut s, "(wcmatch \"abc\" \"x*,a?c\")"), "T");
assert_eq!(ok(&mut s, "(wcmatch \"abc\" \"~a*\")"), "nil");
assert_eq!(ok(&mut s, "(wcmatch \"b\" \"[a-c]\")"), "T");
assert_eq!(ok(&mut s, "(vl-string-subst \"X\" \"b\" \"abcb\")"), "\"aXcb\"");
assert_eq!(ok(&mut s, "(vl-string-search \"c\" \"abc\")"), "2");
assert_eq!(ok(&mut s, "(read \"(1 2)\")"), "(1 2)");
assert_eq!(ok(&mut s, "(type \"x\")"), "STR");
assert_eq!(ok(&mut s, "(type 'x)"), "SYM");
assert_eq!(ok(&mut s, "(equal '(1 2.0) '(1 2.0000001) 1e-3)"), "T");
}
#[test]
fn geometry() {
let mut s = session();
assert_eq!(ok(&mut s, "(polar '(0 0) 0 5)"), "(5.0 0.0)");
assert_eq!(ok(&mut s, "(distance '(0 0) '(3 4))"), "5.0");
assert_eq!(ok(&mut s, "(angle '(0 0) '(0 1))"), "1.5708");
assert_eq!(ok(&mut s, "(inters '(0 0) '(10 10) '(0 10) '(10 0))"), "(5.0 5.0)");
assert_eq!(ok(&mut s, "(inters '(0 0) '(1 1) '(0 10) '(10 0))"), "nil");
assert_eq!(ok(&mut s, "(inters '(0 0) '(1 1) '(0 10) '(10 0) nil)"), "(5.0 5.0)");
}
#[test]
fn functions_scope_and_control() {
let mut s = session();
ok(&mut s, "(defun sq (x) (* x x))");
assert_eq!(ok(&mut s, "(sq 7)"), "49");
ok(&mut s, "(defun fact (n) (if (< n 2) 1 (* n (fact (1- n)))))");
assert_eq!(ok(&mut s, "(fact 10)"), "3628800");
// Dynamic scope: arguments and locals hide globals while the function runs.
ok(&mut s, "(setq x 1 y 2)");
ok(&mut s, "(defun f (x / y) (setq y 5) (g))");
ok(&mut s, "(defun g () (list x y))");
assert_eq!(ok(&mut s, "(f 9)"), "(9 5)");
assert_eq!(ok(&mut s, "(list x y)"), "(1 2)", "restored after the call");
assert_eq!(ok(&mut s, "(progn (setq i 0 acc nil) (while (< i 3) (setq acc (cons i acc) i (1+ i))) acc)"), "(2 1 0)");
assert_eq!(ok(&mut s, "(progn (setq n 0) (repeat 4 (setq n (+ n 2))) n)"), "8");
assert_eq!(ok(&mut s, "(progn (setq tot 0) (foreach v '(1 2 3) (setq tot (+ tot v))) tot)"), "6");
assert_eq!(ok(&mut s, "(cond ((= 1 2) 'a) ((= 1 1) 'b) (t 'c))"), "B");
assert_eq!(ok(&mut s, "(and 1 2)"), "T");
assert_eq!(ok(&mut s, "(or nil nil)"), "nil");
assert_eq!(ok(&mut s, "((lambda (a b) (+ a b)) 1 2)"), "3");
assert_eq!(ok(&mut s, "(eval '(+ 1 2))"), "3");
assert_eq!(eval(&mut s, "(sq)"), Err("too few arguments".into()));
assert_eq!(eval(&mut s, "(sq 1 2)"), Err("too many arguments".into()));
assert_eq!(eval(&mut s, "(nosuchfunction 1)"), Err("no function definition: NOSUCHFUNCTION".into()));
}
#[test]
fn errors_unwind_bindings_and_reach_the_handler() {
let mut s = session();
ok(&mut s, "(setq v 'outer)");
ok(&mut s, "(defun boom (v) (/ 1 0))");
assert!(eval(&mut s, "(boom 'inner)").is_err());
assert_eq!(ok(&mut s, "v"), "OUTER", "the error restored the binding");
// *error* runs with the message.
ok(&mut s, "(defun *error* (msg) (setq caught msg))");
assert!(eval(&mut s, "(boom 1)").is_err());
assert_eq!(ok(&mut s, "caught"), "\"divide by zero\"");
ok(&mut s, "(setq *error* nil)");
// vl-catch-all-apply turns an error into a value.
assert_eq!(ok(&mut s, "(vl-catch-all-error-message (vl-catch-all-apply '/ '(1 0)))"), "\"divide by zero\"");
assert_eq!(ok(&mut s, "(vl-catch-all-apply '+ '(1 2))"), "3");
assert!(eval(&mut s, "(exit)").is_err());
}
#[test]
fn runaway_routines_are_errors_not_crashes() {
let mut s = session();
ok(&mut s, "(defun deep (n) (+ 1 (deep (1+ n))))");
assert_eq!(eval(&mut s, "(deep 0)"), Err("internal stack limit reached".into()));
// A deeply nested source text.
let nested = format!("{}1{}", "(+ ".repeat(50_000), ")".repeat(50_000));
assert!(eval(&mut s, &nested).is_err());
// Hostile values.
assert!(eval(&mut s, "(substr \"abc\" 0)").is_err());
assert!(eval(&mut s, "(nth -1 '(1))").is_ok());
assert!(eval(&mut s, "(chr -5)").is_ok());
assert!(eval(&mut s, "(expt 2.0 99999)").is_err());
assert!(eval(&mut s, "(gcd 0 0)").is_err());
assert!(eval(&mut s, "(wcmatch \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaab\" \"*a*a*a*a*a*a*a*a*a*c\")").is_ok());
assert!(eval(&mut s, "(fix 1e300)").is_ok());
assert!(eval(&mut s, "(lsh 1 99)").is_ok());
// An endless loop in a blocking run stops at the step cap.
let r = eval(&mut s, "(setq n 0) (while (< n 3000000) (setq n (1+ n))) n");
assert_eq!(r, Ok("3000000".into()));
}
#[test]
fn printing() {
let mut s = session();
let before = s.log.len();
ok(&mut s, "(princ \"Hello\") (princ \" world\\n\") (prin1 \"q\") (terpri) (print 3) (princ)");
let out: Vec<_> = s.log[before..].to_vec();
assert_eq!(out, ["Hello world", "\"q\"", "", "3 "]);
assert_eq!(ok(&mut s, "(vl-prin1-to-string '(1 \"a\"))"), "\"(1 \\\"a\\\")\"");
assert_eq!(ok(&mut s, "(rtos 12.5 2 2)"), "\"12.50\"");
}
#[test]
fn command_draws_and_one_routine_is_one_undo_step() {
let mut s = session();
let n = |s: &Session| s.doc().unwrap().model.handles().len();
let undo_before = s.state().unwrap().undo.len();
ok(&mut s, "(command \"_.LINE\" '(0 0) '(10 0) \"\") (command \"_.CIRCLE\" '(5 5) 2)");
assert_eq!(n(&s), 2);
assert_eq!(s.state().unwrap().undo.len(), undo_before + 1, "the whole routine undoes at once");
// A command left open carries on in the next call.
ok(&mut s, "(command \"_.LINE\" '(0 10) '(10 10)) (command '(10 20) \"\")");
assert_eq!(n(&s), 4);
assert!(s.running.is_none());
// (defun c:…) makes a command.
ok(&mut s, "(defun c:twolines () (command \"_.LINE\" '(0 0) '(0 5) '(5 5) \"\") (princ))");
s.cmdline("TWOLINES").unwrap();
assert_eq!(n(&s), 6);
// An expression typed at Command: shows its value.
s.cmdline("(+ 40 2)").unwrap();
assert_eq!(s.log.last().map(String::as_str), Some("42"));
s.cmdline("(setq dist 7.5)").unwrap();
s.cmdline("!dist").unwrap();
assert_eq!(s.log.last().map(String::as_str), Some("7.5"));
// An expression at a command's prompt answers it.
s.cmdline("CIRCLE 0,0 (* 2 dist)").unwrap();
let r = s.doc().unwrap().model.iter().find_map(|e| match &e.kind {
cadcraft_doc::EntityKind::Circle(c) if c.center.x == 0.0 && c.center.y == 0.0 => Some(c.radius),
_ => None,
});
assert_eq!(r, Some(15.0));
}
#[test]
fn entity_lists_round_trip() {
let mut s = session();
let made = ok(&mut s, "(entmake '((0 . \"CIRCLE\") (8 . \"WALLS\") (10 1.0 2.0 0.0) (40 . 3.0)))");
assert!(made.contains("CIRCLE"));
assert!(s.doc().unwrap().layer("WALLS").is_some(), "entmake creates the layer");
assert_eq!(ok(&mut s, "(cdr (assoc 0 (entget (entlast))))"), "\"CIRCLE\"");
assert_eq!(ok(&mut s, "(cdr (assoc 8 (entget (entlast))))"), "\"WALLS\"");
assert_eq!(ok(&mut s, "(cdr (assoc 10 (entget (entlast))))"), "(1.0 2.0 0.0)");
assert_eq!(ok(&mut s, "(cdr (assoc 410 (entget (entlast))))"), "\"Model\"");
// entmod changes it in place.
ok(&mut s, "(setq d (entget (entlast))) (entmod (subst (cons 40 9.0) (assoc 40 d) d))");
assert_eq!(ok(&mut s, "(cdr (assoc 40 (entget (entlast))))"), "9.0");
// A list without a layer uses the current one.
ok(&mut s, "(setvar \"CLAYER\" \"WALLS\") (entmake '((0 . \"LINE\") (10 0 0 0) (11 5 0 0)))");
assert_eq!(ok(&mut s, "(cdr (assoc 8 (entget (entlast))))"), "\"WALLS\"");
// entnext walks in creation order; entdel deletes and brings back.
assert_eq!(ok(&mut s, "(cdr (assoc 0 (entget (entnext))))"), "\"CIRCLE\"");
assert_eq!(ok(&mut s, "(entnext (entlast))"), "nil");
ok(&mut s, "(setq e (entlast)) (entdel e)");
assert_eq!(ok(&mut s, "(entget e)"), "nil");
ok(&mut s, "(entdel e)");
assert_eq!(ok(&mut s, "(cdr (assoc 0 (entget e)))"), "\"LINE\"");
assert_eq!(ok(&mut s, "(entmake '((0 . \"NOSUCHTHING\")))"), "nil");
assert_eq!(ok(&mut s, "(handent \"ZZZZZZ\")"), "nil");
}
#[test]
fn selection_sets_and_filters() {
let mut s = session();
ok(&mut s, "(command \"_.CIRCLE\" '(0 0) 1) (command \"_.CIRCLE\" '(10 0) 2) (command \"_.LINE\" '(0 5) '(20 5) \"\")");
assert_eq!(ok(&mut s, "(sslength (ssget \"_X\" '((0 . \"CIRCLE\"))))"), "2");
assert_eq!(ok(&mut s, "(sslength (ssget \"X\" '((0 . \"CIRCLE,LINE\"))))"), "3");
assert_eq!(ok(&mut s, "(sslength (ssget \"X\" '((-4 . \"<OR\") (0 . \"LINE\") (40 . 2.0) (-4 . \"OR>\"))))"), "2");
assert_eq!(ok(&mut s, "(sslength (ssget \"X\" '((0 . \"CIRCLE\") (-4 . \">\") (40 . 1.5))))"), "1");
assert_eq!(ok(&mut s, "(ssget \"X\" '((0 . \"ARC\")))"), "nil");
assert_eq!(ok(&mut s, "(sslength (ssget \"_W\" '(-5 -5) '(5 5)))"), "1");
assert_eq!(ok(&mut s, "(sslength (ssget \"_C\" '(-5 -5) '(5 6)))"), "2");
assert_eq!(ok(&mut s, "(setq ss (ssadd)) (ssadd (entlast) ss) (sslength ss)"), "1");
assert_eq!(ok(&mut s, "(ssdel (entlast) ss) (sslength ss)"), "0");
assert_eq!(ok(&mut s, "(ssname (ssget \"X\") 99)"), "nil");
}
#[test]
fn tables_and_variables() {
let mut s = session();
ok(&mut s, "(command \"_.-LAYER\" \"_M\" \"DOORS\" \"_C\" \"1\" \"\" \"\")");
assert_eq!(ok(&mut s, "(cdr (assoc 2 (tblsearch \"LAYER\" \"doors\")))"), "\"DOORS\"");
assert_eq!(ok(&mut s, "(tblsearch \"LAYER\" \"nope\")"), "nil");
assert_eq!(ok(&mut s, "(cdr (assoc 0 (tblnext \"LAYER\" t)))"), "\"LAYER\"");
assert_eq!(ok(&mut s, "(getvar \"CLAYER\")"), "\"DOORS\"");
assert_eq!(ok(&mut s, "(setvar \"OSMODE\" 0)"), "0");
assert_eq!(ok(&mut s, "(getvar \"OSMODE\")"), "0");
assert_eq!(ok(&mut s, "(getvar \"NOSUCHVAR\")"), "nil");
}
#[test]
fn input_functions_take_supplied_answers() {
let mut s = session();
let r = eval_with(
&mut s,
"(list (getpoint \"\\nPick: \") (getreal) (getstring) (getint))",
vec![Value::point(&[1.0, 2.0]), Value::Real(2.5), Value::str("hi"), Value::Int(3)],
);
assert_eq!(r, Ok("((1.0 2.0) 2.5 \"hi\" 3)".into()));
// No answer in a programmatic run: nil.
assert_eq!(eval(&mut s, "(getpoint)"), Ok("nil".into()));
}
#[test]
fn interactive_routine_waits_for_the_user() {
let mut s = session();
s.cmdline("(defun c:pt () (setq p (getpoint \"\\nWhere: \")) (command \"_.CIRCLE\" p 1) (princ))").unwrap();
s.cmdline("PT").unwrap();
assert!(s.lisp_running());
assert_eq!(s.prompt_text(), "Where: ");
s.input(crate::Input::Point(cadcraft_geom::Vec2::new(3.0, 4.0))).unwrap();
assert!(!s.lisp_running());
assert!(s.doc().unwrap().model.iter().any(|e| matches!(&e.kind, cadcraft_doc::EntityKind::Circle(c) if c.center.x == 3.0 && c.center.y == 4.0)));
// Typed answers and keywords.
s.cmdline("(progn (initget 1 \"Yes No\") (setq ans (getkword \"\\nSure? [Yes/No]: \")))").unwrap();
s.cmdline("n").unwrap();
assert_eq!(to_text(&s.lisp_var("ANS"), true), "\"No\"");
s.cmdline("(setq d (getdist '(0 0)))").unwrap();
s.cmdline("3,4").unwrap();
assert_eq!(to_text(&s.lisp_var("D"), true), "5.0");
// Esc cancels the routine.
s.cmdline("(setq z (getint))").unwrap();
s.cancel();
assert!(!s.lisp_running());
// `pause` lets the user answer one prompt of a command.
s.cmdline("(command \"_.CIRCLE\" pause 2)").unwrap();
assert!(s.lisp_running());
s.input(crate::Input::Point(cadcraft_geom::Vec2::new(50.0, 50.0))).unwrap();
assert!(!s.lisp_running());
assert!(s.doc().unwrap().model.iter().any(|e| matches!(&e.kind, cadcraft_doc::EntityKind::Circle(c) if c.center.x == 50.0 && c.radius == 2.0)));
}
#[test]
fn appload_and_load() {
let mut s = session();
let r =
s.execute("appload", &serde_json::json!({"name": "tools.lsp", "text": "(defun c:hi () (princ \"hi\") (princ)) (setq loaded 1)"})).unwrap();
assert!(r.get("error").is_none(), "{r}");
assert!(s.log.iter().any(|l| l == "tools.lsp successfully loaded."));
s.cmdline("HI").unwrap();
assert_eq!(s.log.last().map(String::as_str), Some("hi"));
// (load) finds a file APPLOAD loaded, by name.
assert_eq!(ok(&mut s, "(setq loaded 0) (load \"tools\") loaded"), "1");
assert!(eval(&mut s, "(load \"no-such-file-anywhere\")").is_err());
assert_eq!(ok(&mut s, "(load \"no-such-file-anywhere\" 'missing)"), "MISSING");
let r = s.execute("lisp", &serde_json::json!({"code": "(princ \"x\") (* 6 7)"})).unwrap();
assert_eq!(r["value"], "42");
assert_eq!(r["output"][0], "x");
let r = s.execute("lisp", &serde_json::json!({"code": "(car 1)"})).unwrap();
assert!(r["error"].as_str().is_some_and(|e| e.contains("bad argument type")));
}
#[test]
fn hostile_lisp_never_panics() {
let mut s = session();
for code in [
"(",
")",
"'",
"\"",
"(1 . 2 3)",
"(quote)",
"(setq)",
"(setq 1 2)",
"(if)",
"(defun)",
"(defun f)",
"(lambda)",
"(repeat 1.5)",
"(repeat -3 1)",
"(foreach 1 2)",
"(mapcar)",
"(mapcar 'car 1)",
"(apply 'car 1)",
"(entget 1)",
"(entmake '(1 2))",
"(entmod nil)",
"(ssget \"Q\")",
"(ssget \"X\" '((-4 . \"<OR\")))",
"(ssname 1 2)",
"(command 1.5 nil '(a b))",
"(tblsearch 1 2)",
"(rtos 1 9)",
"(getvar 1)",
"(setvar \"OSMODE\" \"x\")",
"(open \"\" \"z\")",
"(read-line 5)",
"(close 5)",
"(vl-sort 1 2)",
"(eval (read \"(\"))",
"(substr \"\" 1 -1)",
"(nth 99999999 '(1))",
"(polar 1 2 3)",
"(inters '(0 0) '(0 0) '(0 0) '(0 0))",
"(chr 1114112)",
] {
let _ = eval(&mut s, code);
}
assert!(!s.lisp_running());
}
#[test]
fn a_waiting_routine_is_a_prompt_with_a_rubber_band() {
let mut s = session();
assert!(!s.prompting());
s.cmdline("(setq c (getcorner '(0 0) \"\nOther corner: \"))").unwrap();
// No command runs, yet a prompt waits: clicks must answer it (not select).
assert!(s.running.is_none() && s.prompting());
let current = s.current_prompt().unwrap();
assert!(current.raw && current.display() == "Other corner: ");
// getcorner drags a rectangle from its corner.
let band = s.preview(cadcraft_geom::Vec2::new(3.0, 2.0));
assert_eq!(band.len(), 4);
s.input(crate::Input::Point(cadcraft_geom::Vec2::new(3.0, 2.0))).unwrap();
assert!(!s.prompting());
// getpoint with a base point drags a line; without one, nothing.
s.cmdline("(getpoint '(1 1))").unwrap();
assert_eq!(s.preview(cadcraft_geom::Vec2::new(4.0, 5.0)).len(), 1);
s.cancel();
s.cmdline("(getpoint)").unwrap();
assert!(s.preview(cadcraft_geom::Vec2::new(4.0, 5.0)).is_empty());
s.cancel();
}
#[test]
fn a_leading_newline_ends_the_command_line_instead_of_adding_an_empty_one() {
let mut s = session();
let from = s.log.len();
s.cmdline("(progn (princ \"\nHello\") (princ \"\n\nAfter a blank\") (princ))").unwrap();
assert_eq!(&s.log[from..], ["Hello", "", "After a blank"]);
}
#[test]
fn startup_files_load_from_the_support_folder() {
let dir = std::env::temp_dir().join(format!("cadcraft-lisp-startup-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("acad.lsp"), "(defun c:fromacad () (princ \"acad\") (princ))").unwrap();
std::fs::write(
dir.join("acaddoc.lsp"),
"(setq docvar 1) (autoload \"lazy\" '(\"LAZY\")) (defun S::STARTUP () (setq started (1+ (if started started 0))))",
)
.unwrap();
std::fs::write(dir.join("lazy.lsp"), "(defun c:lazy () (setq lazy-ran 1) (princ \"lazy ran\") (princ))").unwrap();
// A drawing's own folder is never read: an acaddoc.lsp next to it must not run.
let drawings = dir.join("drawings");
std::fs::create_dir_all(&drawings).unwrap();
std::fs::write(drawings.join("acaddoc.lsp"), "(setq evil 1)").unwrap();
let mut s = session();
s.set_lisp_support_dir(Some(dir.clone()));
s.lisp_tick();
let v = |s: &Session, n: &str| to_text(&s.lisp_var(n), true);
assert_eq!(v(&s, "DOCVAR"), "1");
assert_eq!(v(&s, "STARTED"), "1", "S::STARTUP ran once");
assert!(matches!(s.lisp_var("C:FROMACAD"), Value::Func(_)));
s.lisp_tick();
assert_eq!(v(&s, "STARTED"), "1", "a drawing gets its startup files once");
// autoload: the command loads its file on first use.
s.cmdline("LAZY").unwrap();
assert_eq!(v(&s, "LAZY-RAN"), "1");
assert_eq!(s.log.last().map(String::as_str), Some("lazy ran"));
// A new drawing: acaddoc.lsp again, acad.lsp only once per session...
s.new_drawing(false);
s.lisp_tick();
assert_eq!(v(&s, "DOCVAR"), "1");
assert!(s.lisp_var("C:FROMACAD").is_nil());
// ...unless ACADLSPASDOC is on.
crate::sysvars::set(&mut s, "ACADLSPASDOC", &serde_json::json!(1)).unwrap();
s.new_drawing(false);
s.lisp_tick();
assert!(matches!(s.lisp_var("C:FROMACAD"), Value::Func(_)));
// A drawing opened from a folder with its own acaddoc.lsp: only the support folder's runs.
let path = drawings.join("plan.dxf").to_string_lossy().into_owned();
s.open_drawing(cadcraft_doc::Drawing::new_metric(), "plan.dxf", Some(path));
s.lisp_tick();
assert_eq!(v(&s, "DOCVAR"), "1");
assert!(s.lisp_var("EVIL").is_nil());
// No support folder: nothing loads.
let mut s = session();
s.lisp_tick();
assert!(s.lisp_var("DOCVAR").is_nil());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn deeply_nested_lists_drop_without_overflowing_the_stack() {
let _heavy = super::value::HEAVY_CELLS.lock().unwrap_or_else(|e| e.into_inner());
// Nested in the car a million deep: dropping it must not recurse.
let mut v = Value::Nil;
for _ in 0..1_000_000 {
v = Value::list(vec![v]);
}
drop(v);
// Functions nested in function bodies too.
let mut f = Value::Nil;
for _ in 0..200_000 {
f = Value::Func(std::sync::Arc::new(super::value::Func { name: None, params: Vec::new(), locals: Vec::new(), body: Value::list(vec![f]) }));
}
drop(f);
// The same built by a routine and dropped when the variable is cleared.
let mut s = session();
assert_eq!(ok(&mut s, "(setq x nil) (repeat 100000 (setq x (list x))) (setq x nil) 1"), "1");
}
#[test]
fn too_many_list_cells_stop_the_routine() {
let _heavy = super::value::HEAVY_CELLS.lock().unwrap_or_else(|e| e.into_inner());
let mut s = session();
s.lisp.limits.cells = 50_000;
let e = eval(&mut s, "(setq l nil) (repeat 2000000 (setq l (cons 1 l)))").unwrap_err();
assert!(e.contains("too many list cells"), "{e}");
// The variable still holds what was made, but the drawing's LISP stays usable.
assert_eq!(ok(&mut s, "(setq l nil) (+ 1 2)"), "3");
// A doubling list is stopped as well.
let e = eval(&mut s, "(setq l '(1)) (repeat 100 (setq l (append l l)))").unwrap_err();
assert!(e.contains("too many list cells"), "{e}");
assert_eq!(ok(&mut s, "(setq l nil) (length '(1 2 3))"), "3");
}
#[test]
fn strings_are_capped() {
let _heavy = super::value::HEAVY_CELLS.lock().unwrap_or_else(|e| e.into_inner());
let mut s = session();
// A string doubled until it would not fit in memory.
let e = eval(&mut s, "(setq s \"x\") (repeat 100 (setq s (strcat s s)))").unwrap_err();
assert!(e.contains("string longer than"), "{e}");
// Printing a list that shares its parts (exponentially long text) is cut.
let t = ok(&mut s, "(setq x '(1 2)) (repeat 40 (setq x (list x x))) (strlen (vl-prin1-to-string x))");
assert!(t.parse::<usize>().unwrap() <= super::value::MAX_STRING + 64, "{t}");
ok(&mut s, "(setq x nil s nil)");
// Many strings, each short, add up to the run's budget.
s.lisp.limits.string_bytes = 1_000_000;
let e = eval(&mut s, "(setq a \"0123456789\") (repeat 20000 (setq b (strcat a a a a a a a a a a)))").unwrap_err();
assert!(e.contains("too much text"), "{e}");
let e = eval(&mut s, "(repeat 200000 (princ \"0123456789\"))").unwrap_err();
assert!(e.contains("too much text"), "{e}");
// The budget is per run: the next one starts afresh.
assert_eq!(ok(&mut s, "(strcat \"a\" \"b\")"), "\"ab\"");
}
#[test]
fn a_panic_inside_a_routine_ends_it_with_an_error() {
let mut s = session();
let n = |s: &Session| s.doc().unwrap().model.handles().len();
let before = n(&s);
let e = eval(&mut s, "(entmake '((0 . \"LINE\") (10 0 0 0) (11 5 0 0))) (cadcraft-test-panic)").unwrap_err();
assert!(e.contains("internal error"), "{e}");
assert_eq!(n(&s), before, "the drawing is put back");
assert_eq!(ok(&mut s, "(+ 1 2)"), "3");
// A routine carried on by the per-frame tick.
let r = s.lisp_eval("(repeat 500000 (setq i 1)) (cadcraft-test-panic)", false, false, Vec::new()).unwrap();
assert!(r.is_none(), "the routine gave the interface a frame");
assert!(s.lisp_busy());
for _ in 0..100 {
if !s.lisp_busy() {
break;
}
s.lisp_tick();
}
assert!(!s.lisp_running());
assert!(s.log.iter().any(|l| l.contains("internal error")), "{:?}", s.log);
assert_eq!(ok(&mut s, "(+ 1 2)"), "3");
}
+562
View File
@@ -0,0 +1,562 @@
//! AutoLISP values: integers (32-bit, wrapping as AutoLISP's do), reals, strings, symbols,
//! lists built of cons cells, entity names, selection sets, files and functions.
//!
//! Values are shared (`Arc`) so a session stays `Send`. Cons cells (and functions) are dropped
//! iteratively: a list a million long, or nested a million deep, would otherwise overflow the
//! stack when it goes away.
//!
//! Memory is bounded so a runaway routine fails with a LISP error instead of the process running
//! out of memory (which no panic guard can catch): live cons cells are counted ([`live_cells`],
//! checked by the evaluator at every step), no string a built-in makes may be longer than
//! [`MAX_STRING`], and the strings and printed text a run makes share a byte budget
//! ([`charge_bytes`]).
use std::cell::Cell;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use cadcraft_doc::Handle;
use super::LispError;
/// A symbol: its name in upper case (AutoLISP symbols are case-insensitive).
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct Sym(pub Arc<str>);
impl Sym {
pub fn new(name: &str) -> Sym {
Sym(Arc::from(name.to_ascii_uppercase().as_str()))
}
pub fn name(&self) -> &str {
&self.0
}
}
/// A cons cell.
#[derive(Debug)]
pub struct Cons {
pub car: Value,
pub cdr: Value,
}
/// Most cons cells alive at once (about 64 bytes each): far above real routines (a list of
/// every entity of a large drawing, its `entget` lists), well below memory trouble.
pub const MAX_CELLS: usize = 5_000_000;
/// Longest string a built-in may make, in bytes.
pub const MAX_STRING: usize = 8 << 20;
/// Bytes of new strings, printed text and files read one run may make in all.
pub const STRING_BUDGET: u64 = 2 << 30;
/// Cons cells alive in the process.
static LIVE_CELLS: AtomicUsize = AtomicUsize::new(0);
/// Tests that make or cap many cells run one at a time: the count is shared by the process.
#[cfg(test)]
pub(crate) static HEAVY_CELLS: Mutex<()> = Mutex::new(());
/// Cons cells alive now (all sessions).
pub fn live_cells() -> usize {
LIVE_CELLS.load(Ordering::Relaxed)
}
thread_local! {
/// The running slice's string budget: bytes made so far and the most allowed.
static STRING_BYTES: Cell<(u64, u64)> = const { Cell::new((0, STRING_BUDGET)) };
/// The running slice's cap on live cons cells.
static CELL_LIMIT: Cell<usize> = const { Cell::new(MAX_CELLS) };
}
/// Start a slice of a run on this thread: it fails when more than `limit` cells are alive.
pub fn set_cell_limit(limit: usize) {
CELL_LIMIT.with(|c| c.set(limit));
}
/// Too many cons cells are alive: the error that stops the run.
pub fn check_cells() -> Result<(), LispError> {
if live_cells() > CELL_LIMIT.with(Cell::get) { Err(LispError::quit("out of memory: too many list cells")) } else { Ok(()) }
}
/// Start a slice of a run on this thread: it has made `used` bytes of its `budget`.
pub fn set_string_budget(used: u64, budget: u64) {
STRING_BYTES.with(|c| c.set((used, budget)));
}
/// The bytes the run has made so far on this thread.
pub fn string_bytes_used() -> u64 {
STRING_BYTES.with(|c| c.get().0)
}
/// Count `n` new bytes (a string, printed text, a file read) against the run's budget.
pub fn charge_bytes(n: usize) -> Result<(), LispError> {
STRING_BYTES.with(|c| {
let (used, budget) = c.get();
let used = used.saturating_add(u64::try_from(n).unwrap_or(u64::MAX));
c.set((used, budget));
if used > budget { Err(LispError::quit("out of memory: the routine made too much text")) } else { Ok(()) }
})
}
/// A string of `len` bytes may be made.
pub fn check_string_len(len: usize) -> Result<(), LispError> {
if len > MAX_STRING { Err(LispError::quit(format!("out of memory: string longer than {MAX_STRING} bytes"))) } else { Ok(()) }
}
/// A built-in's result: a new string (one nothing else holds) is checked and charged.
pub fn charge_result(v: &Value) -> Result<(), LispError> {
if let Value::Str(s) = v
&& Arc::strong_count(s) == 1
{
check_string_len(s.len())?;
charge_bytes(s.len())?;
}
Ok(())
}
impl Drop for Cons {
fn drop(&mut self) {
// Cells are only made by `Value::cons`, which counts them; never wrap below zero.
if LIVE_CELLS.fetch_sub(1, Ordering::Relaxed) == 0 {
LIVE_CELLS.fetch_add(1, Ordering::Relaxed);
}
// Unlink everything this cell alone holds (the rest of the list, nested lists in its
// car, function bodies) one cell at a time, so nothing recurses on the stack.
let mut work = Vec::new();
keep_for_drop(&mut work, std::mem::take(&mut self.car));
keep_for_drop(&mut work, std::mem::take(&mut self.cdr));
while let Some(v) = work.pop() {
match v {
Value::Cons(c) => {
if let Ok(mut cell) = Arc::try_unwrap(c) {
keep_for_drop(&mut work, std::mem::take(&mut cell.car));
keep_for_drop(&mut work, std::mem::take(&mut cell.cdr));
}
}
Value::Func(f) => {
if let Ok(mut func) = Arc::try_unwrap(f) {
keep_for_drop(&mut work, std::mem::take(&mut func.body));
}
}
_ => {}
}
}
}
}
/// Values that can hold others go on the work list; atoms are dropped here.
fn keep_for_drop(work: &mut Vec<Value>, v: Value) {
if matches!(v, Value::Cons(_) | Value::Func(_)) {
work.push(v);
}
}
/// A selection set: the entities it holds, in order. `ssadd` and `ssdel` change it in place.
#[derive(Clone, Debug)]
pub struct PickSet {
pub id: u32,
pub items: Arc<Mutex<Vec<Handle>>>,
}
/// A function defined in LISP (`defun`, `lambda`).
#[derive(Debug)]
pub struct Func {
pub name: Option<Sym>,
pub params: Vec<Sym>,
/// Local variables (after the `/` in the argument list): bound to nil during a call.
pub locals: Vec<Sym>,
/// The body forms, as a list.
pub body: Value,
}
/// A built-in function. `special` forms get their arguments unevaluated.
pub struct Builtin {
pub name: &'static str,
pub kind: BuiltinKind,
}
impl std::fmt::Debug for Builtin {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "<Subr: {}>", self.name)
}
}
#[derive(Clone, Copy)]
pub enum BuiltinKind {
/// Evaluated arguments to a value.
Pure(fn(&[Value]) -> Result<Value, LispError>),
/// Evaluated arguments, with the interpreter (and its session) at hand.
Ctx(fn(&mut super::eval::Ctx<'_>, Vec<Value>) -> Result<super::eval::Flow, LispError>),
/// A special form, handled by the evaluator itself.
Special(super::eval::Special),
}
#[derive(Clone, Debug, Default)]
pub enum Value {
#[default]
Nil,
Int(i32),
Real(f64),
Str(Arc<str>),
Sym(Sym),
Cons(Arc<Cons>),
Ename(Handle),
PickSet(PickSet),
File(u32),
Subr(&'static Builtin),
Func(Arc<Func>),
}
impl Value {
pub fn t() -> Value {
Value::Sym(Sym::new("T"))
}
pub fn bool(b: bool) -> Value {
if b { Value::t() } else { Value::Nil }
}
pub fn str(s: &str) -> Value {
Value::Str(Arc::from(s))
}
pub fn sym(s: &str) -> Value {
Value::Sym(Sym::new(s))
}
pub fn cons(car: Value, cdr: Value) -> Value {
LIVE_CELLS.fetch_add(1, Ordering::Relaxed);
Value::Cons(Arc::new(Cons { car, cdr }))
}
pub fn is_nil(&self) -> bool {
matches!(self, Value::Nil)
}
/// A proper list of `items`.
pub fn list(items: impl IntoIterator<Item = Value, IntoIter: DoubleEndedIterator>) -> Value {
items.into_iter().rev().fold(Value::Nil, |acc, v| Value::cons(v, acc))
}
/// `items` followed by `tail` (a dotted list when `tail` is an atom).
pub fn list_with_tail(items: Vec<Value>, tail: Value) -> Value {
items.into_iter().rev().fold(tail, |acc, v| Value::cons(v, acc))
}
/// A point as a list of reals.
pub fn point(p: &[f64]) -> Value {
Value::list(p.iter().map(|v| Value::Real(*v)).collect::<Vec<_>>())
}
pub fn car(&self) -> Value {
match self {
Value::Cons(c) => c.car.clone(),
_ => Value::Nil,
}
}
pub fn cdr(&self) -> Value {
match self {
Value::Cons(c) => c.cdr.clone(),
_ => Value::Nil,
}
}
/// The elements of a list (a dotted tail is left out).
pub fn iter(&self) -> ListIter {
ListIter { next: self.clone() }
}
pub fn to_vec(&self) -> Vec<Value> {
self.iter().collect()
}
/// A proper list (nil or cons cells ending in nil).
pub fn is_list(&self) -> bool {
let mut v = self.clone();
loop {
match v {
Value::Nil => return true,
Value::Cons(c) => v = c.cdr.clone(),
_ => return false,
}
}
}
pub fn as_number(&self) -> Option<f64> {
match self {
Value::Int(i) => Some(f64::from(*i)),
Value::Real(r) => Some(*r),
_ => None,
}
}
pub fn as_sym(&self) -> Option<&Sym> {
match self {
Value::Sym(s) => Some(s),
_ => None,
}
}
/// The type name `type` returns (nil for nil).
pub fn type_name(&self) -> Option<&'static str> {
Some(match self {
Value::Nil => return None,
Value::Int(_) => "INT",
Value::Real(_) => "REAL",
Value::Str(_) => "STR",
Value::Sym(_) => "SYM",
Value::Cons(_) => "LIST",
Value::Ename(_) => "ENAME",
Value::PickSet(_) => "PICKSET",
Value::File(_) => "FILE",
Value::Subr(_) => "SUBR",
Value::Func(_) => "USUBR",
})
}
/// A 2D or 3D point given as a list of two or three numbers.
pub fn as_point(&self) -> Option<[f64; 3]> {
let mut it = self.iter();
let x = it.next()?.as_number()?;
let y = it.next()?.as_number()?;
let z = match it.next() {
Some(v) => v.as_number()?,
None => 0.0,
};
(it.next().is_none() && self.is_list()).then_some([x, y, z])
}
}
pub struct ListIter {
next: Value,
}
impl Iterator for ListIter {
type Item = Value;
fn next(&mut self) -> Option<Value> {
match std::mem::take(&mut self.next) {
Value::Cons(c) => {
self.next = c.cdr.clone();
Some(c.car.clone())
}
_ => None,
}
}
}
/// Deepest nesting `equal`, printing and reading follow before giving up (hostile input).
pub const MAX_DEPTH: usize = 2_000;
/// `eq`: the same atom or the same cons cell.
pub fn eq(a: &Value, b: &Value) -> bool {
match (a, b) {
(Value::Cons(x), Value::Cons(y)) => Arc::ptr_eq(x, y),
(Value::Str(x), Value::Str(y)) => Arc::ptr_eq(x, y) || x == y,
(Value::PickSet(x), Value::PickSet(y)) => x.id == y.id,
(Value::Func(x), Value::Func(y)) => Arc::ptr_eq(x, y),
(Value::Subr(x), Value::Subr(y)) => std::ptr::eq(*x, *y),
_ => atom_equal(a, b, 0.0),
}
}
/// `equal`: the same structure, numbers within `fuzz`.
pub fn equal(a: &Value, b: &Value, fuzz: f64) -> bool {
equal_depth(a, b, fuzz, 0)
}
fn equal_depth(a: &Value, b: &Value, fuzz: f64, depth: usize) -> bool {
if depth > MAX_DEPTH {
return false;
}
let (mut a, mut b) = (a.clone(), b.clone());
loop {
match (&a, &b) {
(Value::Cons(x), Value::Cons(y)) => {
if !equal_depth(&x.car, &y.car, fuzz, depth + 1) {
return false;
}
let (na, nb) = (x.cdr.clone(), y.cdr.clone());
a = na;
b = nb;
}
_ => return atom_equal(&a, &b, fuzz),
}
}
}
fn atom_equal(a: &Value, b: &Value, fuzz: f64) -> bool {
match (a, b) {
(Value::Nil, Value::Nil) => true,
(Value::Int(x), Value::Int(y)) => x == y,
(Value::Str(x), Value::Str(y)) => x == y,
(Value::Sym(x), Value::Sym(y)) => x == y,
(Value::Ename(x), Value::Ename(y)) => x == y,
(Value::File(x), Value::File(y)) => x == y,
(Value::PickSet(x), Value::PickSet(y)) => x.id == y.id,
(Value::Subr(x), Value::Subr(y)) => std::ptr::eq(*x, *y),
(Value::Func(x), Value::Func(y)) => Arc::ptr_eq(x, y),
(x, y) => match (x.as_number(), y.as_number()) {
(Some(p), Some(q)) if matches!(x, Value::Real(_)) || matches!(y, Value::Real(_)) || fuzz > 0.0 => (p - q).abs() <= fuzz,
_ => false,
},
}
}
/// A real as AutoLISP prints it: six significant digits, always with a decimal point.
pub fn real_text(r: f64) -> String {
if !r.is_finite() {
return if r.is_nan() {
"-1.#IND".into()
} else if r > 0.0 {
"1.#INF".into()
} else {
"-1.#INF".into()
};
}
if r == 0.0 {
return "0.0".into();
}
let exp = r.abs().log10().floor() as i32;
// As C's %g with six digits: exponent form outside 1e-5 ..< 1e6.
if !(-5..6).contains(&exp) {
// Exponent form: d.ddddde+NNN
let m = r / 10f64.powi(exp);
let mut s = trim_fraction(&format!("{m:.5}"));
if !s.contains('.') {
s.push_str(".0");
}
return format!("{s}e{}{:03}", if exp < 0 { '-' } else { '+' }, exp.abs());
}
let decimals = (5 - exp).max(0) as usize;
let mut s = trim_fraction(&format!("{r:.decimals$}"));
if !s.contains('.') {
s.push_str(".0");
}
s
}
fn trim_fraction(s: &str) -> String {
if s.contains('.') {
let t = s.trim_end_matches('0');
t.strip_suffix('.').map_or(t.to_string(), |u| format!("{u}.0"))
} else {
s.to_string()
}
}
/// `prin1`/`print` text (`escape`: strings quoted with their escapes) or `princ` text.
pub fn to_text(v: &Value, escape: bool) -> String {
let mut out = String::new();
write_value(&mut out, v, escape, 0);
if out.len() > MAX_STRING {
let mut end = MAX_STRING;
while !out.is_char_boundary(end) {
end -= 1;
}
out.truncate(end);
}
out
}
fn write_value(out: &mut String, v: &Value, escape: bool, depth: usize) {
// Text past the string cap is cut (a list sharing its parts can print exponentially long).
if out.len() > MAX_STRING {
return;
}
if depth > MAX_DEPTH {
out.push_str("...");
return;
}
match v {
Value::Nil => out.push_str("nil"),
Value::Int(i) => out.push_str(&i.to_string()),
Value::Real(r) => out.push_str(&real_text(*r)),
Value::Str(s) => {
if escape {
out.push('"');
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\t' => out.push_str("\\t"),
'\r' => out.push_str("\\r"),
'\u{1b}' => out.push_str("\\e"),
c => out.push(c),
}
}
out.push('"');
} else {
out.push_str(s);
}
}
Value::Sym(s) => out.push_str(s.name()),
Value::Cons(_) => {
// (quote x) prints as 'x.
if let (Value::Sym(s), Value::Cons(rest)) = (v.car(), v.cdr())
&& s.name() == "QUOTE"
&& rest.cdr.is_nil()
{
out.push('\'');
write_value(out, &rest.car, escape, depth + 1);
return;
}
out.push('(');
let mut cur = v.clone();
let mut first = true;
let mut count = 0usize;
loop {
match cur {
Value::Cons(c) => {
if !first {
out.push(' ');
}
first = false;
write_value(out, &c.car, escape, depth + 1);
cur = c.cdr.clone();
count += 1;
if count > 1_000_000 || out.len() > MAX_STRING {
out.push_str(" ...");
break;
}
}
Value::Nil => break,
other => {
out.push_str(" . ");
write_value(out, &other, escape, depth + 1);
break;
}
}
}
out.push(')');
}
Value::Ename(h) => out.push_str(&format!("<Entity name: {:x}>", h.0)),
Value::PickSet(p) => out.push_str(&format!("<Selection set: {:x}>", p.id)),
Value::File(f) => out.push_str(&format!("#<FILE {f:x}>")),
Value::Subr(b) => out.push_str(&format!("#<SUBR @{} {}>", "0", b.name)),
Value::Func(f) => out.push_str(&format!("#<USUBR {}>", f.name.as_ref().map_or("-lambda-", |s| s.name()))),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reals_print_with_six_significant_digits() {
assert_eq!(real_text(1.0), "1.0");
assert_eq!(real_text(std::f64::consts::PI), "3.14159");
assert_eq!(real_text(1.23456789), "1.23457");
assert_eq!(real_text(0.1), "0.1");
assert_eq!(real_text(12.5), "12.5");
assert_eq!(real_text(-2.0), "-2.0");
assert_eq!(real_text(100000.0), "100000.0");
assert_eq!(real_text(123456.7), "123457.0");
assert_eq!(real_text(0.0001), "0.0001");
assert_eq!(real_text(1_000_000.0), "1.0e+006");
assert_eq!(real_text(0.000001), "1.0e-006");
}
#[test]
fn a_long_list_drops_without_overflowing_the_stack() {
let _heavy = HEAVY_CELLS.lock().unwrap_or_else(|e| e.into_inner());
let mut l = Value::Nil;
for i in 0..2_000_000 {
l = Value::cons(Value::Int(i), l);
}
drop(l);
}
#[test]
fn printing_and_equality() {
let l = Value::list(vec![Value::Int(1), Value::str("a\"b"), Value::cons(Value::Int(8), Value::str("0"))]);
assert_eq!(to_text(&l, true), "(1 \"a\\\"b\" (8 . \"0\"))");
assert_eq!(to_text(&l, false), "(1 a\"b (8 . 0))");
assert!(equal(&l, &l.clone(), 0.0));
assert!(equal(&Value::Real(1.0), &Value::Real(1.0000001), 1e-3));
assert!(!equal(&Value::Real(1.0), &Value::Real(1.1), 1e-3));
assert!(eq(&Value::sym("a"), &Value::sym("A")));
}
}
+568
View File
@@ -0,0 +1,568 @@
//! The model behind the in-place MTEXT editor: MTEXT contents as plain text with a format per
//! character and the properties of each paragraph, so a text box can show and change bold,
//! italic, underline, font, height, colour, indents and tabs and hand back MTEXT contents.
//!
//! Codes the editor has no control for (stacked fractions `\S`, `\W`, `\T`, `\Q`, `\A`, column
//! breaks `\N`, non-breaking spaces …) are kept as opaque *atoms*: one placeholder character in
//! the text ([`ATOM_BASE`] + n) that [`Formatted::to_contents`] writes back as the original
//! code, so opening and closing the editor never loses them.
use cadcraft_fonts::ParaProps;
pub use cadcraft_fonts::{ListStyle, MTextColor, ParaAlign, TabKind};
/// Largest paragraph indent or tab stop, in drawing units.
const MAX_INDENT: f64 = 1e6;
/// First placeholder character of the opaque codes (Unicode private use area).
pub const ATOM_BASE: u32 = 0xE000;
/// Most atoms one text keeps; further codes are dropped.
const MAX_ATOMS: usize = 1000;
/// Longest text the editor takes (characters).
pub const MAX_CHARS: usize = 100_000;
/// Most paragraphs.
const MAX_PARAS: usize = 20_000;
/// Formatting of one character.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct CharFmt {
pub bold: bool,
pub italic: bool,
pub underline: bool,
pub overline: bool,
/// Font family set inline (`\f`); `None`: the text style's.
pub font: Option<String>,
/// Absolute character height set inline (`\H`); `None`: the text's.
pub height: Option<f64>,
/// Colour set inline (`\C`, `\c`); `None`: the entity's (ByLayer/ByBlock).
pub color: Option<MTextColor>,
}
/// A character format switch of the toolbar.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Flag {
Bold,
Italic,
Underline,
Overline,
}
impl CharFmt {
fn flag(&self, f: Flag) -> bool {
match f {
Flag::Bold => self.bold,
Flag::Italic => self.italic,
Flag::Underline => self.underline,
Flag::Overline => self.overline,
}
}
fn set_flag(&mut self, f: Flag, on: bool) {
match f {
Flag::Bold => self.bold = on,
Flag::Italic => self.italic = on,
Flag::Underline => self.underline = on,
Flag::Overline => self.overline = on,
}
}
}
/// MTEXT contents as editable text.
#[derive(Clone, Debug, PartialEq)]
pub struct Formatted {
/// The text, paragraphs separated by `\n`; atoms are placeholders.
text: String,
/// One format per character of `text`.
fmt: Vec<CharFmt>,
/// One per paragraph (`\n` count + 1).
paras: Vec<ParaProps>,
/// The original code of each atom.
atoms: Vec<String>,
/// The font family bold and italic runs are written with when no font was set inline
/// (`\fFamily|b1;`): the text style's.
pub base_font: String,
}
impl Default for Formatted {
fn default() -> Self {
Formatted { text: String::new(), fmt: Vec::new(), paras: vec![ParaProps::default()], atoms: Vec::new(), base_font: "Arial".into() }
}
}
fn arg_end(chars: &[char], from: usize) -> usize {
let mut j = from;
while chars.get(j).is_some_and(|c| *c != ';') {
j += 1;
}
j
}
impl Formatted {
/// Parse MTEXT contents. Never fails: what isn't understood is kept as atoms or dropped.
pub fn from_contents(contents: &str) -> Formatted {
let mut f = Formatted::default();
let chars: Vec<char> = contents.chars().collect();
let mut stack: Vec<CharFmt> = Vec::new();
let mut cur = CharFmt::default();
let mut props = ParaProps::default();
f.paras = vec![props.clone()];
let mut i = 0;
while let Some(&c) = chars.get(i) {
if f.fmt.len() >= MAX_CHARS {
break;
}
match c {
'{' => {
if stack.len() < 64 {
stack.push(cur.clone());
}
i += 1;
}
'}' => {
if let Some(p) = stack.pop() {
cur = p;
}
i += 1;
}
'\r' => i += 1,
'\n' => {
if f.paras.len() < MAX_PARAS {
f.push_char('\n', &cur);
f.paras.push(props.clone());
}
i += 1;
}
'\\' => {
let code = chars.get(i + 1).copied();
i += 2;
match code {
None => {}
Some('P') => {
if f.paras.len() < MAX_PARAS {
f.push_char('\n', &cur);
f.paras.push(props.clone());
}
}
Some(c @ ('\\' | '{' | '}')) => f.push_char(c, &cur),
Some('L') => cur.underline = true,
Some('l') => cur.underline = false,
Some('O') => cur.overline = true,
Some('o') => cur.overline = false,
Some(k @ ('f' | 'F' | 'H' | 'C' | 'c' | 'p')) => {
let end = arg_end(&chars, i);
let a: String = chars.get(i..end).unwrap_or_default().iter().collect();
i = end + 1;
match k {
'f' | 'F' => apply_font(&mut cur, &a),
'H' => cur.height = parse_height(&a, cur.height),
'C' => {
cur.color = a.trim().parse::<u16>().ok().and_then(|v| (v > 0 && v < 256).then_some(MTextColor::Aci(v)));
}
'c' => {
if let Ok(v) = a.trim().parse::<u32>() {
cur.color = Some(MTextColor::Rgb(v & 0xFF_FFFF));
}
}
_ => {
props.apply(&a);
if let Some(p) = f.paras.last_mut() {
*p = props.clone();
}
}
}
}
Some(k) => {
// Any other code is kept verbatim: the ones with an argument up to
// their `;`, the others alone.
let mut code = format!("\\{k}");
if !matches!(k, '~' | 'K' | 'k' | 'N') {
let end = arg_end(&chars, i);
code.extend(chars.get(i..=end.min(chars.len().saturating_sub(1))).unwrap_or_default().iter());
i = end + 1;
}
f.push_atom(code, &cur);
}
}
}
_ => {
f.push_char(c, &cur);
i += 1;
}
}
}
f
}
fn push_char(&mut self, c: char, fmt: &CharFmt) {
self.text.push(c);
self.fmt.push(fmt.clone());
}
fn push_atom(&mut self, code: String, fmt: &CharFmt) {
if self.atoms.len() >= MAX_ATOMS {
return;
}
let ch = char::from_u32(ATOM_BASE + self.atoms.len() as u32).unwrap_or('\u{FFFD}');
self.atoms.push(code);
self.push_char(ch, fmt);
}
/// The text as the editor shows it.
pub fn text(&self) -> &str {
&self.text
}
/// Number of characters.
pub fn len(&self) -> usize {
self.fmt.len()
}
pub fn is_empty(&self) -> bool {
self.fmt.is_empty()
}
/// Number of paragraphs.
pub fn paragraph_count(&self) -> usize {
self.paras.len()
}
/// Format of character `i` (the last one past the end, the default for no text).
pub fn fmt_at(&self, i: usize) -> CharFmt {
self.fmt.get(i).or(self.fmt.last()).cloned().unwrap_or_default()
}
/// The paragraph character `i` is in.
pub fn paragraph_of(&self, i: usize) -> usize {
self.text.chars().take(i).filter(|c| *c == '\n').count().min(self.paras.len().saturating_sub(1))
}
/// Properties of paragraph `p`.
pub fn para(&self, p: usize) -> ParaProps {
self.paras.get(p).or(self.paras.last()).cloned().unwrap_or_default()
}
/// The edited text replaced by `new` (what a text box hands back after a keystroke): the
/// characters that stay keep their format, new ones take `typing`, and paragraphs follow
/// the line breaks added and removed. Text beyond [`MAX_CHARS`] is refused (`false`).
pub fn set_text(&mut self, new: &str, typing: &CharFmt) -> bool {
if new == self.text {
return true;
}
let old: Vec<char> = self.text.chars().collect();
let new_c: Vec<char> = new.chars().collect();
if new_c.len() > MAX_CHARS || new_c.iter().filter(|c| **c == '\n').count() >= MAX_PARAS {
return false;
}
let pre = old.iter().zip(&new_c).take_while(|(a, b)| a == b).count();
let suf = old.iter().rev().zip(new_c.iter().rev()).take(old.len().min(new_c.len()) - pre).take_while(|(a, b)| a == b).count();
let removed = old.get(pre..old.len() - suf).unwrap_or_default();
let inserted = new_c.get(pre..new_c.len() - suf).unwrap_or_default();
// Typing after a character takes its format unless the toolbar changed it.
let first_para = old.iter().take(pre).filter(|c| **c == '\n').count();
let gone = removed.iter().filter(|c| **c == '\n').count();
let added = inserted.iter().filter(|c| **c == '\n').count();
let end = (first_para + 1 + gone).min(self.paras.len());
self.paras.drain((first_para + 1).min(end)..end);
let template = self.para(first_para);
for _ in 0..added {
self.paras.insert((first_para + 1).min(self.paras.len()), template.clone());
}
let ins: Vec<CharFmt> = inserted.iter().map(|_| typing.clone()).collect();
self.fmt.splice(pre..old.len() - suf, ins);
self.text = new.to_string();
if self.paras.is_empty() {
self.paras.push(ParaProps::default());
}
true
}
fn clamp(&self, r: std::ops::Range<usize>) -> std::ops::Range<usize> {
r.start.min(self.fmt.len())..r.end.min(self.fmt.len())
}
/// Apply `f` to the format of the characters in `range`.
pub fn edit(&mut self, range: std::ops::Range<usize>, f: impl Fn(&mut CharFmt)) {
let r = self.clamp(range);
if let Some(s) = self.fmt.get_mut(r) {
s.iter_mut().for_each(f);
}
}
/// Whether every character of `range` has `flag`.
pub fn all_have(&self, range: std::ops::Range<usize>, flag: Flag) -> bool {
let r = self.clamp(range);
!r.is_empty() && self.fmt.get(r).is_some_and(|s| s.iter().all(|c| c.flag(flag)))
}
/// Toggle `flag` on `range`: set unless every character has it already.
pub fn toggle(&mut self, range: std::ops::Range<usize>, flag: Flag) {
let on = !self.all_have(range.clone(), flag);
self.edit(range, |c| c.set_flag(flag, on));
}
/// The paragraphs `range` touches (a caret touches the one it is in).
fn paras_of(&self, range: std::ops::Range<usize>) -> std::ops::Range<usize> {
let a = self.paragraph_of(range.start);
let b = self.paragraph_of(range.end.max(range.start));
a..b + 1
}
/// Change the properties of the paragraphs `range` touches.
pub fn edit_paras(&mut self, range: std::ops::Range<usize>, f: impl Fn(&mut ParaProps)) {
let ps = self.paras_of(range);
if let Some(s) = self.paras.get_mut(ps) {
s.iter_mut().for_each(f);
}
}
/// Set the indents (in drawing units, as `\p` codes have them) of the paragraphs `range` touches: `first` is relative
/// to `left`.
pub fn set_indents(&mut self, range: std::ops::Range<usize>, first: f64, left: f64) {
if first.is_finite() && left.is_finite() {
let left = left.clamp(0.0, MAX_INDENT);
self.edit_paras(range, |p| {
p.first = first.clamp(-left, MAX_INDENT);
p.left = left;
});
}
}
/// Add a tab stop at `pos` drawing units to the paragraphs `range` touches.
pub fn add_tab(&mut self, range: std::ops::Range<usize>, pos: f64, kind: TabKind) {
if !pos.is_finite() || !(0.0..=MAX_INDENT).contains(&pos) {
return;
}
self.edit_paras(range, |p| {
if p.tabs.len() < 64 && !p.tabs.iter().any(|(t, _)| (t - pos).abs() < 1e-6) {
let at = p.tabs.partition_point(|(t, _)| *t < pos);
p.tabs.insert(at, (pos, kind));
}
});
}
/// Remove the tab stop nearest `pos` (within `tolerance`) from the paragraphs `range` touches.
pub fn remove_tab(&mut self, range: std::ops::Range<usize>, pos: f64, tolerance: f64) {
self.edit_paras(range, |p| {
if let Some(i) = p.tabs.iter().position(|(t, _)| (t - pos).abs() <= tolerance) {
p.tabs.remove(i);
}
});
}
/// Make the paragraphs `range` touches a list: a hanging indent of `indent` drawing units
/// with a tab stop there, and each paragraph a label and a tab.
pub fn make_list(&mut self, range: std::ops::Range<usize>, style: ListStyle, indent: f64) {
let indent = if indent.is_finite() && indent > 0.0 { indent.min(MAX_INDENT) } else { 3.0 };
let ps = self.paras_of(range);
let lines: Vec<&str> = self.text.split('\n').collect();
let mut out = String::new();
let mut fmts: Vec<CharFmt> = Vec::new();
let mut at = 0usize;
for (k, line) in lines.iter().enumerate() {
let n = line.chars().count();
if k > 0 {
out.push('\n');
fmts.push(self.fmt.get(at).cloned().unwrap_or_default());
at += 1;
}
if ps.contains(&k) {
let label = format!("{}\t", style.label(k - ps.start));
let f = self.fmt.get(at).cloned().unwrap_or_else(|| self.fmt_at(at));
label.chars().for_each(|_| fmts.push(f.clone()));
out.push_str(&label);
}
out.push_str(line);
fmts.extend(self.fmt.get(at..at + n).unwrap_or_default().iter().cloned());
at += n;
}
if fmts.len() > MAX_CHARS {
return;
}
self.text = out;
self.fmt = fmts;
let props = ParaProps { first: -indent, left: indent, tabs: vec![(indent, TabKind::Left)], ..ParaProps::default() };
for p in ps {
if let Some(q) = self.paras.get_mut(p) {
*q = props.clone();
}
}
}
/// The MTEXT contents.
pub fn to_contents(&self) -> String {
let mut out = String::new();
let mut emitted = ParaProps::default();
let chars: Vec<char> = self.text.chars().collect();
let mut i = 0;
for (pi, props) in self.paras.iter().enumerate() {
if pi > 0 {
out.push_str("\\P");
}
if *props != emitted {
out.push_str(&props.code());
emitted = props.clone();
}
let end = chars.iter().skip(i).position(|c| *c == '\n').map_or(chars.len(), |n| i + n);
while i < end {
let f = self.fmt.get(i).cloned().unwrap_or_default();
let mut j = i + 1;
while j < end && self.fmt.get(j) == Some(&f) {
j += 1;
}
let run: String = chars.get(i..j).unwrap_or_default().iter().map(|c| self.escape(*c)).collect();
if f == CharFmt::default() {
out.push_str(&run);
} else {
out.push('{');
out.push_str(&self.codes(&f));
out.push_str(&run);
out.push('}');
}
i = j;
}
i = end + 1;
}
out
}
fn escape(&self, c: char) -> String {
match c {
'\\' | '{' | '}' => format!("\\{c}"),
c if (c as u32) >= ATOM_BASE && (c as u32) < ATOM_BASE + MAX_ATOMS as u32 => {
self.atoms.get((c as u32 - ATOM_BASE) as usize).cloned().unwrap_or_default()
}
c => c.to_string(),
}
}
fn codes(&self, f: &CharFmt) -> String {
let mut s = String::new();
if f.underline {
s.push_str("\\L");
}
if f.overline {
s.push_str("\\O");
}
if f.bold || f.italic || f.font.is_some() {
let fam = f.font.clone().unwrap_or_else(|| self.base_font.clone());
let fam: String = fam.chars().filter(|c| !matches!(c, ';' | '|' | '{' | '}' | '\\')).collect();
s.push_str(&format!("\\f{fam}|b{}|i{};", u8::from(f.bold), u8::from(f.italic)));
}
if let Some(h) = f.height {
s.push_str(&format!("\\H{};", trim(h)));
}
match f.color {
Some(MTextColor::Aci(v)) => s.push_str(&format!("\\C{v};")),
Some(MTextColor::Rgb(v)) => s.push_str(&format!("\\c{v};")),
None => {}
}
s
}
}
fn trim(v: f64) -> String {
let s = format!("{v:.4}");
s.trim_end_matches('0').trim_end_matches('.').to_string()
}
fn parse_height(a: &str, cur: Option<f64>) -> Option<f64> {
let a = a.trim();
match a.strip_suffix(['x', 'X']) {
// A relative height needs the current one; without it the code is dropped.
Some(r) => r.trim().parse::<f64>().ok().zip(cur).map(|(m, h)| h * m).filter(|h| h.is_finite() && *h > 0.0 && *h < 1e6),
None => a.parse::<f64>().ok().filter(|h| h.is_finite() && *h > 0.0 && *h < 1e6).or(cur),
}
}
fn apply_font(cur: &mut CharFmt, a: &str) {
let mut parts = a.split('|');
let name = parts.next().unwrap_or("").trim();
if name.is_empty() {
return;
}
cur.font = Some(name.to_string());
cur.bold = false;
cur.italic = false;
for p in parts {
match p.as_bytes() {
[b'b', v] => cur.bold = *v == b'1',
[b'i', v] => cur.italic = *v == b'1',
_ => {}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn round_trips_formats_and_atoms() {
for src in [
"plain",
"a\\Pb",
"{\\fArial|b1|i0;bold} and {\\Lunder}",
"x \\S1/2; y",
"{\\H2;big}{\\C1;red}{\\c16711680;rgb}",
"\\pxi-3,l3,t3;1.\tone\\P2.\ttwo",
"esc \\{ \\} \\\\",
] {
let f = Formatted::from_contents(src);
let again = Formatted::from_contents(&f.to_contents());
assert_eq!(f, again, "{src}");
}
assert_eq!(Formatted::from_contents("plain").to_contents(), "plain");
assert!(Formatted::from_contents("x \\S1/2; y").to_contents().contains("\\S1/2;"));
}
#[test]
fn toggles_and_typing_keep_formats() {
let mut f = Formatted::from_contents("hello world");
f.toggle(0..5, Flag::Bold);
assert_eq!(f.to_contents(), "{\\fArial|b1|i0;hello} world");
assert!(f.all_have(0..5, Flag::Bold));
// Typing inside the bold run takes the typing format; paragraphs follow line breaks.
let typing = f.fmt_at(2);
assert!(f.set_text("hello big world", &CharFmt::default()));
assert!(f.fmt_at(6) == CharFmt::default() && typing.bold);
assert!(f.set_text("hello big\nworld", &CharFmt::default()));
assert_eq!(f.paragraph_count(), 2);
assert_eq!(f.to_contents(), "{\\fArial|b1|i0;hello} big\\Pworld");
f.toggle(0..5, Flag::Bold);
assert_eq!(f.to_contents(), "hello big\\Pworld");
}
#[test]
fn ruler_and_lists() {
let mut f = Formatted::from_contents("one\ntwo");
f.set_indents(0..0, -2.0, 4.0);
f.add_tab(0..3, 4.0, TabKind::Left);
let c = f.to_contents();
assert!(c.starts_with("\\pxi-2,l4,t4;one"), "{c}");
f.make_list(0..f.len(), ListStyle::Number, 3.0);
assert_eq!(f.text(), "1.\tone\n2.\ttwo");
assert_eq!(f.to_contents(), "\\pxi-3,l3,t3;1.\tone\\P2.\ttwo");
f.remove_tab(0..f.len(), 3.0, 0.1);
assert!(!f.to_contents().contains("t3"));
}
#[test]
fn hostile_input_never_panics() {
for src in
["\\", "{{{{", "}}}}", "\\f", "\\fArial|b", "\\H;\\H2x;\\Hnan;", "\\S", "\\p;\\pq;\\pt,,,;", "\\C999999999999;", "\u{E000}\u{E001}"]
{
let f = Formatted::from_contents(src);
let _ = f.to_contents();
}
let mut f = Formatted::from_contents("ab");
let (a, b) = (5, 1);
f.edit(a..b, |c| c.bold = true);
f.toggle(9..9, Flag::Italic);
f.set_indents(7..9, f64::NAN, 1.0);
f.add_tab(0..99, f64::INFINITY, TabKind::Left);
f.make_list(50..60, ListStyle::Bullet, f64::NAN);
assert!(!f.set_text(&"x".repeat(MAX_CHARS + 1), &CharFmt::default()));
let _ = f.paragraph_of(usize::MAX);
let _ = f.fmt_at(usize::MAX);
let _ = Formatted::from_contents(&"{".repeat(100_000));
}
}
+33 -1
View File
@@ -41,6 +41,10 @@ pub struct Prompt {
/// prompt (a point with no base yet, e.g. the first point of a line). Elsewhere a deferred
/// pick arrives as the plain point it was picked at.
pub deferred: bool,
/// The base is a window's first corner (ZOOM Window): ortho does not bend the opposite
/// corner, so the window stays a rectangle.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub corner: bool,
/// The grip this prompt stretches (one hot grip at the STRETCH prompt), as (object, grip
/// index): Dynamic Input shows its dimensions ([`crate::grip_dims`]).
#[serde(skip_serializing_if = "Option::is_none")]
@@ -49,6 +53,20 @@ pub struct Prompt {
/// [`Input::Text`].
#[serde(skip_serializing_if = "Option::is_none")]
pub picker: Option<Picker>,
/// Shown as written, without keywords, a default or a colon added: an AutoLISP `get…`
/// prompt (the routine writes all of it).
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub raw: bool,
}
/// Parse `@distance<angle` (relative polar), returning `(distance, angle in radians)`.
/// Returns `None` for any other form.
pub fn parse_polar_text(text: &str, angles: &crate::units::AngleSettings) -> Option<(f64, f64)> {
let body = text.trim().strip_prefix('@')?; // relative form only
let (d, a) = body.split_once('<')?;
let dist = d.trim().parse::<f64>().ok()?;
let angle = angles.amount(a.trim())?;
(dist.is_finite() && angle.is_finite()).then_some((dist, angle))
}
/// A visual chooser for a prompt ([`Prompt::picker`]).
@@ -61,7 +79,7 @@ pub enum Picker {
impl Prompt {
pub fn new(message: impl Into<String>, accept: Accept) -> Self {
Prompt { message: message.into(), keywords: Vec::new(), default: None, accept, base: None, deferred: false, grip: None, picker: None }
Prompt { message: message.into(), accept, ..Default::default() }
}
/// The grip being stretched (the `grip` field).
pub fn grip(mut self, g: (Handle, usize)) -> Self {
@@ -85,6 +103,12 @@ impl Prompt {
self.base = p;
self
}
/// The base is a window's first corner (the `corner` field).
pub fn corner(mut self, p: Vec2) -> Self {
self.base = Some(p);
self.corner = true;
self
}
/// Accept deferred tangent/perpendicular snaps (the `deferred` field).
pub fn deferred(mut self) -> Self {
self.deferred = true;
@@ -102,6 +126,9 @@ impl Prompt {
}
/// "Specify next point or [Undo/Close] <default>:".
pub fn display(&self) -> String {
if self.raw {
return self.message.trim_matches(['\n', '\r']).to_string();
}
let mut s = self.message.clone();
if !self.keywords.is_empty() {
// A question offers its answers ("Fix any errors detected? [Yes/No]"); anything else
@@ -188,6 +215,11 @@ pub trait Interactive: Send {
fn drag(&self, _s: &Session, _cursor: Vec2) -> Option<Drag> {
None
}
/// The multiline text being typed (MTEXT, once its box is known) with empty contents: where
/// the UI places its in-place editor and with which height, width and justification.
fn editing_mtext(&self) -> Option<cadcraft_doc::MText> {
None
}
}
/// See [`Interactive::drag`].
+121 -5
View File
@@ -88,15 +88,59 @@ fn dist_to_polyline(pts: &[Vec2], p: Vec2) -> f64 {
pub fn entity_distance(d: &Drawing, e: &Entity, p: Vec2, tol: f64) -> f64 {
let polys = hit_polylines(d, e, tol);
let mut best = polys.iter().map(|pl| dist_to_polyline(pl, p)).fold(f64::INFINITY, f64::min);
let filled = matches!(e.kind, EntityKind::Text(_) | EntityKind::MText(_) | EntityKind::AttDef(_) | EntityKind::Solid(_) | EntityKind::Table(_))
|| matches!(e.kind, EntityKind::Tolerance(_))
|| matches!(&e.kind, EntityKind::Hatch(h) if h.solid);
if filled && entity_bounds(d, e, 0).contains(p) {
let filled = matches!(e.kind, EntityKind::Text(_) | EntityKind::MText(_) | EntityKind::AttDef(_) | EntityKind::Table(_))
|| matches!(e.kind, EntityKind::Tolerance(_));
if (filled && entity_bounds(d, e, 0).contains(p)) || in_text(d, e, p) {
best = 0.0;
}
// A solid hatch fills its loops with odd parity under its island style, as it is drawn, not
// its bounding rectangle.
if let EntityKind::Hatch(h) = &e.kind
&& h.solid
&& cadcraft_render::hatch_fill_loops(h, tol).iter().filter(|l| point_in_polygon(l, p)).count() % 2 == 1
{
best = 0.0;
}
// A SOLID fills its polygon, not its bounding rectangle (half of which is empty for a triangle).
if matches!(e.kind, EntityKind::Solid(_)) && e.kind.prims().iter().any(|prim| matches!(prim, Prim::Fill(poly) if point_in_polygon(poly, p))) {
best = 0.0;
}
best
}
/// Whether `p` is on the text of a dimension or multileader: inside the box around its words,
/// turned with the text, as plain text is hit anywhere inside its box. Their other geometry is
/// hit along its lines, so without this the cursor found the object only between the letters'
/// edges and the lines.
fn in_text(d: &Drawing, e: &Entity, p: Vec2) -> bool {
match &e.kind {
// A dimension with a block (as AutoCAD saves them) is drawn from that block: its text is
// the text there, not the text the dimension's own geometry would place.
EntityKind::Dimension(dm) => match dm.block.as_ref().and_then(|n| d.block(n)) {
Some(blk) => blk.entities.iter().any(|be| match &be.kind {
EntityKind::MText(t) => in_box(cadcraft_render::layout_mtext_entity(d, t).strokes.into_iter().flatten(), t.rotation, p),
EntityKind::Text(_) => entity_bounds(d, be, 0).contains(p),
_ => false,
}),
None => {
let g = cadcraft_render::dimension_in(d, dm);
in_box(g.text.iter().flatten().chain(g.text_glyphs.iter().flatten().flatten()).copied(), g.text_angle, p)
}
},
EntityKind::MLeader(m) => {
m.text.as_ref().is_some_and(|t| in_box(cadcraft_render::layout_mtext_entity(d, t).strokes.into_iter().flatten(), t.rotation, p))
}
_ => false,
}
}
/// Whether `p` is inside the box around `pts` turned by `angle` (the box of text set at `angle`).
fn in_box(pts: impl Iterator<Item = Vec2>, angle: f64, p: Vec2) -> bool {
let turn = |q: Vec2| q.rotate(-angle);
let b = Bounds2::from_points(pts.filter(|q| q.is_finite()).map(turn));
!b.is_empty() && b.contains(turn(p))
}
fn selectable(d: &Drawing, e: &Entity) -> bool {
d.is_visible(e)
}
@@ -165,6 +209,18 @@ pub(crate) fn pick_with(d: &Drawing, space: &Space, ix: &Option<std::sync::Arc<S
best.map(|(_, h)| h)
}
/// Whether PREVIEWFILTER keeps `e` from being highlighted under the cursor: bits 1 objects on
/// locked layers, 2 xrefs, 4 tables, 8 multiline text, 16 hatches, 32 objects in groups.
pub fn preview_filtered(d: &Drawing, e: &Entity, filter: u8) -> bool {
let on = |bit: u8| filter & bit != 0;
(on(1) && d.layer(&e.common.layer).is_some_and(|l| l.locked))
|| (on(2) && matches!(&e.kind, EntityKind::Insert(i) if d.block(&i.block).is_some_and(|b| b.xref_path.is_some())))
|| (on(4) && matches!(e.kind, EntityKind::Table(_)))
|| (on(8) && matches!(e.kind, EntityKind::MText(_)))
|| (on(16) && matches!(e.kind, EntityKind::Hatch(_)))
|| (on(32) && d.groups.iter().any(|g| g.members.contains(&e.handle)))
}
fn seg_hits_box(a: Vec2, b: Vec2, bx: &Bounds2) -> bool {
if bx.contains(a) || bx.contains(b) {
return true;
@@ -325,7 +381,7 @@ pub enum SelectKind {
}
/// Most points a fence or polygon collects (typed input is hostile).
const MAX_MODE_POINTS: usize = 10_000;
pub(crate) const MAX_MODE_POINTS: usize = 10_000;
/// A typed selection mode in progress and the points given so far.
#[derive(Clone, Debug, PartialEq)]
@@ -453,6 +509,19 @@ mod solid_interior_tests {
let inside = Bounds2::new(Vec2::new(3.0, 3.0), Vec2::new(4.0, 4.0));
assert!(in_window(&d, &e, None, &inside, true, 0.01));
}
#[test]
fn pick_in_triangular_bbox_but_outside_fill_does_not_select() {
let mut d = Drawing::default();
d.model.push(face(true, true));
let at = |x: f64, y: f64| pick(&d, &Space::Model, Vec2::new(x, y), 0.25);
// The empty corner of the bounding rectangle, 5.66 from the hypotenuse.
assert_eq!(at(9.0, 9.0), None);
// Inside the fill, farther than the aperture from every edge.
assert_eq!(at(2.0, 2.0), Some(Handle(256)));
// Outside the fill but within the aperture of the hypotenuse.
assert_eq!(at(5.1, 5.1), Some(Handle(256)));
}
}
#[cfg(test)]
@@ -503,4 +572,51 @@ mod hatch_pattern_pick_tests {
assert!(pick(&d, &Space::Model, Vec2::new(1.0, 1.0), 0.05).is_some());
assert!(pick(&d, &Space::Model, Vec2::new(5.0, 5.0), 0.05).is_none());
}
fn solid_hatch(loops: Vec<HatchLoop>, style: u8) -> Drawing {
let mut d = Drawing::new_metric();
let hatch = Hatch {
pattern: "SOLID".into(),
solid: true,
loops,
scale: 1.0,
angle: 0.0,
associative: false,
style,
elevation: 0.0,
gradient: None,
origin: Vec2::ZERO,
background: None,
pattern_lines: Vec::new(),
};
d.add(&Space::Model, Common::default(), EntityKind::Hatch(hatch)).unwrap();
d
}
#[test]
fn solid_hatch_is_picked_inside_its_fill_not_its_bounding_rectangle() {
let tri = HatchLoop {
vertices: [(0.0, 0.0), (10.0, 0.0), (0.0, 10.0)].iter().map(|&(x, y)| PolyVertex::new(Vec2::new(x, y))).collect(),
outer: true,
sources: Vec::new(),
};
let d = solid_hatch(vec![tri], 0);
// The empty corner of the bounding rectangle, 5.66 from the hypotenuse.
assert!(pick(&d, &Space::Model, Vec2::new(9.0, 9.0), 0.25).is_none());
assert!(pick(&d, &Space::Model, Vec2::new(2.0, 2.0), 0.25).is_some());
// Outside the fill but within the aperture of the hypotenuse.
assert!(pick(&d, &Space::Model, Vec2::new(5.1, 5.1), 0.25).is_some());
}
#[test]
fn solid_hatch_island_follows_the_island_style() {
let loops = || vec![square(0.0, 10.0, true), square(4.0, 6.0, false)];
// Normal style leaves the island empty, as it is drawn.
let d = solid_hatch(loops(), 0);
assert!(pick(&d, &Space::Model, Vec2::new(5.0, 5.0), 0.05).is_none());
assert!(pick(&d, &Space::Model, Vec2::new(2.0, 2.0), 0.05).is_some());
// Ignore style fills over the island.
let d = solid_hatch(loops(), 2);
assert!(pick(&d, &Space::Model, Vec2::new(5.0, 5.0), 0.05).is_some());
}
}
+58 -2
View File
@@ -8,7 +8,7 @@ mod viewport;
pub use viewport::ViewportPoint;
use cadcraft_doc::{Drawing, EntityKind, Insert, MAX_BLOCK_DEPTH, Prim, Space};
use cadcraft_geom::{Bounds2, Circle, Ellipse, Mat3, PI, Segment, Vec2, intersect_ext};
use cadcraft_geom::{Bounds2, Circle, EPS, Ellipse, Mat3, PI, Segment, Vec2, intersect_ext};
use serde::Serialize;
/// OSMODE bits.
@@ -96,6 +96,21 @@ pub struct Deferred {
pub at: Vec2,
}
/// The point on `curve` whose tangent has direction `dir`, picking the solution
/// nearest `hint`. Used to resolve a deferred TAN first point when the next point
/// is typed as `@distance<angle`.
pub fn tangent_point_in_direction(curve: &SnapCurve, dir: f64, hint: Vec2) -> Option<Vec2> {
match curve {
SnapCurve::Circle { center, radius } => {
let n = dir + std::f64::consts::FRAC_PI_2;
let c1 = *center + Vec2::new(n.cos(), n.sin()) * *radius;
let c2 = *center - Vec2::new(n.cos(), n.sin()) * *radius;
Some(if c1.dist(hint) < c2.dist(hint) { c1 } else { c2 })
}
_ => None,
}
}
/// One end of a line being solved: a known point or a deferred snap.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum LineEnd {
@@ -245,7 +260,14 @@ fn osnap_in_with_prims(
// meet (lines extended, arcs as full circles: in 2D, the "apparent" crossing of objects
// that don't reach each other).
if osmode & (mode::INT | mode::APP) != 0 {
let segs: Vec<Vec<Segment>> = near_prims.iter().take(200).map(prim_segments).collect();
let mut segs: Vec<Vec<Segment>> = near_prims.iter().take(200).map(prim_segments).collect();
let probe = finite_intersection_probe(cursor, aperture);
for (i, segments) in segs.iter_mut().enumerate() {
let preserves_app = osmode & mode::APP != 0 && near_prims.get(i).is_some_and(extendable);
if !preserves_app {
segments.retain(|segment| segment_may_intersect_probe(segment, probe.as_ref()));
}
}
let real = if osmode & mode::INT != 0 { mode::INT } else { mode::APP };
for i in 0..segs.len() {
for j in i + 1..segs.len() {
@@ -274,6 +296,40 @@ fn osnap_in_with_prims(
Some((hit, near_prims))
}
fn finite_intersection_probe(cursor: Vec2, aperture: f64) -> Option<Bounds2> {
if !(cursor.is_finite() && aperture.is_finite() && aperture > 0.0) {
return None;
}
let probe = Bounds2::new(cursor, cursor).expand(aperture);
if !finite_bounds(&probe) {
return None;
}
// The finite line intersection accepts a 1e-9 parameter slack; retain that slack in the broad phase.
let scale = cursor.x.abs().max(cursor.y.abs()).max(aperture).max(1.0);
let padded = probe.expand(EPS * scale);
finite_bounds(&padded).then_some(padded)
}
fn segment_may_intersect_probe(segment: &Segment, probe: Option<&Bounds2>) -> bool {
let Some(probe) = probe else { return true };
let bounds = segment.bounds();
if !finite_bounds(&bounds) {
return true;
}
let width = bounds.width();
let height = bounds.height();
if !(width.is_finite() && height.is_finite()) {
return true;
}
let scale = bounds.min.x.abs().max(bounds.min.y.abs()).max(bounds.max.x.abs()).max(bounds.max.y.abs()).max(width).max(height).max(1.0);
let padded = bounds.expand(EPS * scale);
!finite_bounds(&padded) || padded.intersects(probe)
}
fn finite_bounds(bounds: &Bounds2) -> bool {
!bounds.is_empty() && bounds.min.is_finite() && bounds.max.is_finite()
}
/// Whether a primitive has a meaningful extension (a line, an arc, a circle, an xline or ray).
pub(crate) fn extendable(p: &Prim) -> bool {
matches!(p, Prim::Seg(_) | Prim::Circle(_) | Prim::Infinite { .. })
+3
View File
@@ -762,6 +762,8 @@ pub struct CursorQuery {
pub base: Option<Vec2>,
/// The prompt resolves deferred tangent/perpendicular snaps.
pub deferred: bool,
/// The base is a window's first corner ([`crate::prompt::Prompt::corner`]): no ortho.
pub corner: bool,
/// The object snap aperture in world units (APERTURE pixels at the view scale).
pub aperture: f64,
/// How close, in world units, the cursor slides onto a tracking path.
@@ -821,6 +823,7 @@ impl crate::Session {
}
if let Some(b) = q.base
&& s.orthomode
&& !q.corner
{
p = super::iso::ortho(&self.settings, b, p);
}
+52 -1
View File
@@ -73,6 +73,8 @@ pub struct SpatialIndex {
dimscale: f64,
/// Annotation scale factor and ANNOALLVISIBLE: annotative objects measure at that scale.
anno: (u64, bool),
/// Layers turned off or frozen: block contents on them take no room in their inserts.
hidden_layers: Vec<String>,
/// (draw index of the chunk's first entity, chunk), in draw order.
chunks: Vec<(u32, Arc<ChunkIx>)>,
/// Chunk serial → draw offset.
@@ -88,6 +90,10 @@ fn anno_key(d: &Drawing) -> (u64, bool) {
(d.current_anno_scale().factor().to_bits(), d.anno_all_visible())
}
fn hidden_layers(d: &Drawing) -> impl Iterator<Item = &str> {
d.layers.iter().filter(|l| !l.visible()).map(|l| l.name.as_str())
}
impl SpatialIndex {
/// Build an index over a store (no caching).
pub fn build(d: &Drawing, store: &EntityStore) -> SpatialIndex {
@@ -95,7 +101,7 @@ impl SpatialIndex {
}
/// Build, reusing `prev` for chunks the two stores share (only when the bounds context —
/// blocks, dimension styles, DIMSCALE — is the same).
/// blocks, dimension styles, DIMSCALE, hidden layers — is the same).
fn build_from(d: &Drawing, store: &EntityStore, prev: Option<&SpatialIndex>) -> SpatialIndex {
let prev = prev.filter(|p| p.same_context(d));
let old: std::collections::HashMap<usize, Arc<ChunkIx>> =
@@ -146,6 +152,7 @@ impl SpatialIndex {
dim_styles: d.dim_styles.clone(),
dimscale: d.header.f64("DIMSCALE", 1.0),
anno: anno_key(d),
hidden_layers: hidden_layers(d).map(str::to_owned).collect(),
chunks,
offsets,
tree,
@@ -160,6 +167,7 @@ impl SpatialIndex {
&& self.dimscale.to_bits() == d.header.f64("DIMSCALE", 1.0).to_bits()
&& self.dim_styles == d.dim_styles
&& self.anno == anno_key(d)
&& self.hidden_layers.iter().map(String::as_str).eq(hidden_layers(d))
}
fn matches(&self, d: &Drawing, store: &EntityStore) -> bool {
@@ -464,6 +472,49 @@ mod tests {
assert!(!Arc::ptr_eq(&before, &index(&d, &Space::Model).unwrap()));
}
/// Hiding a layer used inside a block shrinks its inserts' bounds without touching the
/// space or the block table: the index must not be reused (issue #716).
#[test]
fn index_follows_layer_visibility_inside_blocks() {
let mut d = Drawing::new_imperial();
let mut blk = (**drawing(1, 0).blocks.get("B").unwrap()).clone();
let big = Common { layer: "HID".into(), ..Common::default() };
blk.entities.push(Entity {
handle: cadcraft_doc::Handle(2_000_000),
common: big,
kind: EntityKind::Circle(cadcraft_doc::Circle { center: v3(0.0, 0.0), radius: 50.0 }),
});
d.blocks.insert("B".into(), Arc::new(blk));
d.ensure_layer("HID");
for i in 0..MIN_INDEXED {
let x = 1000.0 + i as f64;
d.add(&Space::Model, Common::default(), EntityKind::Line(cadcraft_doc::Line { a: v3(x, 0.0), b: v3(x, 1.0) })).unwrap();
}
let insert = Insert {
block: "B".into(),
insert: v3(0.0, 0.0),
scale: Vec3::new(1.0, 1.0, 1.0),
rotation: 0.0,
attribs: Vec::new(),
cols: 1,
rows: 1,
col_spacing: 0.0,
row_spacing: 0.0,
};
let h = d.add(&Space::Model, Common::default(), EntityKind::Insert(insert)).unwrap();
let small = Bounds2::new(Vec2::new(-2.0, -2.0), Vec2::new(2.0, 2.0));
let shown = SpatialIndex::build(&d, &d.model);
assert!(select::select_window(&d, &Space::Model, small, false).is_empty());
d.layer_mut("HID").unwrap().on = false;
assert!(!shown.matches(&d, &d.model));
let reused = SpatialIndex::build_from(&d, &d.model, Some(&shown));
let fresh = SpatialIndex::build(&d, &d.model);
let last = u32::try_from(MIN_INDEXED).unwrap();
assert_eq!(reused.bounds(last), fresh.bounds(last));
assert_eq!(select::select_window(&d, &Space::Model, small, false), vec![h]);
}
#[test]
fn small_spaces_are_scanned() {
let d = drawing(1, MIN_INDEXED - 1);
+49 -9
View File
@@ -34,12 +34,25 @@ const SESSION_VARS: &[&str] = &[
"DYNPICOORDS",
"DYNPROMPT",
"LWDISPLAY",
"PLOTTRANSPARENCYOVERRIDE",
"SELECTSIMILARMODE",
"OBJECTISOLATIONMODE",
"QPMODE",
"HIGHLIGHT",
"SELECTIONEFFECT",
"SELECTIONPREVIEW",
"PREVIEWEFFECT",
"PREVIEWFILTER",
"GRIPS",
"GRIPOBJLIMIT",
"SELECTIONAREA",
"WINDOWAREACOLOR",
"CROSSINGAREACOLOR",
"SELECTIONAREAOPACITY",
"PICKBOX",
"APERTURE",
"PICKFIRST",
"ACADLSPASDOC",
"PICKADD",
"PICKSTYLE",
"GRIPSIZE",
@@ -88,13 +101,26 @@ pub fn get(s: &Session, name: &str) -> Option<Value> {
"DYNPICOORDS" => json!(i32::from(st.dynpi_absolute)),
"DYNPROMPT" => json!(i32::from(st.dynprompt)),
"LWDISPLAY" => json!(i32::from(st.lwdisplay)),
"PLOTTRANSPARENCYOVERRIDE" => json!(st.plot_transparency_override),
"SELECTSIMILARMODE" => json!(st.selectsimilarmode),
// Isolation is temporary: hidden objects show again when the drawing is reopened.
"OBJECTISOLATIONMODE" => json!(0),
"QPMODE" => json!(i32::from(st.qpmode)),
"HIGHLIGHT" => json!(i32::from(st.highlight)),
"SELECTIONEFFECT" => json!(st.selectioneffect),
"SELECTIONPREVIEW" => json!(st.selectionpreview),
"PREVIEWEFFECT" => json!(st.previeweffect),
"PREVIEWFILTER" => json!(st.previewfilter),
"GRIPS" => json!(st.grips),
"GRIPOBJLIMIT" => json!(st.gripobjlimit),
"SELECTIONAREA" => json!(i32::from(st.selectionarea)),
"WINDOWAREACOLOR" => json!(st.windowareacolor),
"CROSSINGAREACOLOR" => json!(st.crossingareacolor),
"SELECTIONAREAOPACITY" => json!(st.selectionareaopacity),
"PICKBOX" => json!(st.pickbox),
"APERTURE" => json!(st.aperture),
"PICKFIRST" => json!(i32::from(st.pickfirst)),
"ACADLSPASDOC" => json!(i32::from(st.acadlspasdoc)),
"PICKADD" => json!(i32::from(st.pickadd)),
"PICKSTYLE" => json!(st.pickstyle),
"GRIPSIZE" => json!(st.gripsize),
@@ -114,16 +140,10 @@ pub fn get(s: &Session, name: &str) -> Option<Value> {
"CMDNAMES" => json!(s.running.as_ref().map(|r| r.id.to_ascii_uppercase()).unwrap_or_default()),
"DWGNAME" => json!(s.state().map(|d| d.title.clone()).unwrap_or_default()),
"DBMOD" => json!(s.state().map(|d| i32::from(d.is_dirty())).unwrap_or(0)),
"BLOCKEDITOR" => json!(i32::from(s.state().is_ok_and(|d| d.block_edit.is_some()))),
// The template NEW/QNEW start from (a profile setting, like FONTALT; "" = none).
"QNEWTEMPLATE" => json!(crate::cmd::file::qnew_template()),
"CTAB" => json!(
s.state()
.map(|d| match &d.space {
cadcraft_doc::Space::Model => "Model".to_string(),
cadcraft_doc::Space::Paper(n) => n.clone(),
})
.unwrap_or_default()
),
"CTAB" => json!(s.state().map(|d| d.space.tab_name().to_string()).unwrap_or_default()),
// UNDO state of the drawing: Control/Auto/group bits and the number of marks.
"UNDOCTL" => json!(s.state().ok()?.undo_ctl.bits()),
"UNDOMARKS" => json!(s.state().ok()?.undo_marks()),
@@ -164,6 +184,10 @@ fn as_bool(v: &Value) -> Option<bool> {
fn as_i(v: &Value) -> Option<i64> {
v.as_i64().or_else(|| v.as_bool().map(i64::from)).or_else(|| v.as_str().and_then(|s| s.trim().parse::<i64>().ok()))
}
/// A whole number from 0 to `max` (a mode or a small bitcode).
fn small(v: &Value, max: u8) -> Option<u8> {
as_i(v).filter(|n| (0..=i64::from(max)).contains(n)).and_then(|n| u8::try_from(n).ok())
}
fn as_f64(v: &Value) -> Option<f64> {
v.as_f64().or_else(|| v.as_str().and_then(crate::units::parse_distance)).filter(|f| f.is_finite())
}
@@ -172,7 +196,8 @@ fn as_pt(v: &Value) -> Option<cadcraft_geom::Vec2> {
}
/// Variables `get` reports from session or drawing state that `set` can't change.
const READ_ONLY: &[&str] = &["CMDNAMES", "DWGNAME", "DBMOD", "CTAB", "LASTPOINT", "VIEWCTR", "VIEWSIZE", "EXTMIN", "EXTMAX", "UNDOCTL", "UNDOMARKS"];
const READ_ONLY: &[&str] =
&["CMDNAMES", "DWGNAME", "DBMOD", "BLOCKEDITOR", "CTAB", "LASTPOINT", "VIEWCTR", "VIEWSIZE", "EXTMIN", "EXTMAX", "UNDOCTL", "UNDOMARKS"];
pub fn is_read_only(name: &str) -> bool {
READ_ONLY.iter().any(|r| r.eq_ignore_ascii_case(name.trim())) || table::is_read_only(&name.trim().to_ascii_uppercase())
@@ -238,6 +263,9 @@ pub fn set(s: &mut Session, name: &str, v: &Value) -> Result<()> {
"DYNPICOORDS" => st.dynpi_absolute = as_bool(v).ok_or_else(bad)?,
"DYNPROMPT" => st.dynprompt = as_bool(v).ok_or_else(bad)?,
"LWDISPLAY" => st.lwdisplay = as_bool(v).ok_or_else(bad)?,
"PLOTTRANSPARENCYOVERRIDE" => {
st.plot_transparency_override = as_i(v).filter(|i| (0..=2).contains(i)).and_then(|i| u8::try_from(i).ok()).ok_or_else(bad)?
}
"SELECTSIMILARMODE" => st.selectsimilarmode = as_i(v).filter(|b| (0..=255).contains(b)).ok_or_else(bad)? as u32,
"OBJECTISOLATIONMODE" => match as_i(v) {
Some(0) => {}
@@ -245,9 +273,21 @@ pub fn set(s: &mut Session, name: &str, v: &Value) -> Result<()> {
_ => return Err(bad()),
},
"QPMODE" => st.qpmode = as_i(v).ok_or_else(bad)? > 0,
"HIGHLIGHT" => st.highlight = as_bool(v).ok_or_else(bad)?,
"SELECTIONEFFECT" => st.selectioneffect = small(v, 1).ok_or_else(bad)?,
"SELECTIONPREVIEW" => st.selectionpreview = small(v, 3).ok_or_else(bad)?,
"PREVIEWEFFECT" => st.previeweffect = small(v, 2).ok_or_else(bad)?,
"PREVIEWFILTER" => st.previewfilter = small(v, 63).ok_or_else(bad)?,
"GRIPS" => st.grips = small(v, 2).ok_or_else(bad)?,
"GRIPOBJLIMIT" => st.gripobjlimit = as_i(v).filter(|n| (0..=32_767).contains(n)).and_then(|n| u32::try_from(n).ok()).ok_or_else(bad)?,
"SELECTIONAREA" => st.selectionarea = as_bool(v).ok_or_else(bad)?,
"WINDOWAREACOLOR" => st.windowareacolor = small(v, 255).filter(|c| *c >= 1).ok_or_else(bad)?,
"CROSSINGAREACOLOR" => st.crossingareacolor = small(v, 255).filter(|c| *c >= 1).ok_or_else(bad)?,
"SELECTIONAREAOPACITY" => st.selectionareaopacity = small(v, 100).ok_or_else(bad)?,
"PICKBOX" => st.pickbox = as_f64(v).ok_or_else(bad)?.clamp(0.0, 50.0),
"APERTURE" => st.aperture = as_f64(v).ok_or_else(bad)?.clamp(1.0, 50.0),
"PICKFIRST" => st.pickfirst = as_bool(v).ok_or_else(bad)?,
"ACADLSPASDOC" => st.acadlspasdoc = as_bool(v).ok_or_else(bad)?,
"PICKADD" => st.pickadd = as_bool(v).ok_or_else(bad)?,
"PICKSTYLE" => st.pickstyle = as_i(v).filter(|b| (0..=3).contains(b)).and_then(|b| u8::try_from(b).ok()).ok_or_else(bad)?,
"GRIPSIZE" => st.gripsize = as_f64(v).ok_or_else(bad)?.clamp(1.0, 255.0),
+2
View File
@@ -55,6 +55,8 @@ const HEADER: &[(&str, Kind, Def)] = &[
("DONUTOD", Kind::Real { min: 0.0, strict: true }, Def::Real(1.0)),
("EDGEMODE", Kind::Choice(&[0, 1]), Def::Int(0)),
("HPANG", Kind::Angle, Def::Real(0.0)),
// 1: HATCH and GRADIENT typed or from a menu open the Hatch and Gradient dialog; 0: the command line.
("HPDLGMODE", Kind::Choice(&[0, 1]), Def::Int(1)),
("HPNAME", Kind::Pattern, Def::Str("ANSI31")),
("HPSCALE", Kind::Real { min: 0.0, strict: true }, Def::Real(1.0)),
("INSNAME", Kind::Name, Def::Str("")),
+156
View File
@@ -76,6 +76,43 @@ fn programmatic_undo_during_a_command_ends_it_first() {
assert_eq!(kinds(&s), vec!["Line", "Text"]);
}
#[test]
fn undo_during_line_removes_the_last_segment_and_keeps_line_running() {
let mut s = Session::new();
s.cmdline("line 0,0 10,0 10,10 20,10").unwrap();
assert_eq!(kinds(&s), vec!["Line", "Line", "Line"]);
// Ctrl+Z / the U command / the menu (Session::start) act as the Undo keyword.
s.start("u").unwrap();
assert!(s.running.is_some());
assert_eq!(kinds(&s), vec!["Line", "Line"]);
s.start("u").unwrap();
assert!(s.running.is_some());
assert_eq!(kinds(&s), vec!["Line"]);
// The command goes on from the last remaining point.
s.cmdline("10,5").unwrap();
assert_eq!(kinds(&s), vec!["Line", "Line"]);
s.cmdline("").unwrap();
assert!(s.running.is_none());
}
#[test]
fn undo_during_a_command_without_undo_keyword_ends_it_and_undoes() {
let mut s = Session::new();
s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap();
// LINE at its first point offers no Undo: Ctrl+Z ends it, then undoes the earlier line.
s.cmdline("line").unwrap();
assert!(s.running.is_some());
s.start("u").unwrap();
assert!(s.running.is_none());
assert!(kinds(&s).is_empty());
// TEXT offers no Undo keyword either.
s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap();
s.script("TEXT 0,0 5 0 HELLO\n").unwrap();
s.start("u").unwrap();
assert!(s.running.is_none());
assert_eq!(kinds(&s), vec!["Line"]);
}
#[test]
fn polar_and_direct_distance() {
let mut s = Session::new();
@@ -638,6 +675,8 @@ fn hostile_params_never_panic() {
json!({"points": [[1e308, -1e308], ["a"]]}),
json!({"handles": ["zz", 99999]}),
json!({"radius": -1, "center": [0, 0]}),
json!({"gradient": {"name": 5, "color1": [1], "angle": "x", "centered": "y"}}),
json!({"points": [[0, 0]], "handles": ["1"], "gradient": "x", "name": "RAINBOW"}),
] {
let _ = s.execute(c.id, &p);
}
@@ -859,6 +898,21 @@ fn gradient_pick_point_keeps_island_of_separate_lines() {
}
}
#[test]
fn boundary_point_batch_is_capped() {
let mut s = Session::new();
let sq = [[0, 0], [20, 0], [20, 20], [0, 20]];
for i in 0..4 {
s.execute("line", &json!({"points": [sq[i], sq[(i + 1) % 4]]})).unwrap();
}
let many: Vec<_> = (0..100_000).map(|_| json!([2, 2])).collect();
let t = std::time::Instant::now();
assert!(s.execute("boundary", &json!({"points": many})).is_err());
assert!(t.elapsed().as_secs() < 5);
let ok: Vec<_> = (0..8).map(|_| json!([2, 2])).collect();
assert!(s.execute("boundary", &json!({"points": ok})).is_ok());
}
#[test]
fn boundary_pick_point_keeps_island_of_separate_lines() {
for lines in [true, false] {
@@ -981,6 +1035,27 @@ fn line_first_point_deferred_tangent() {
assert!(line_ends(&s)[1].0.near(b, 1e-12));
}
#[test]
fn line_deferred_tangent_with_typed_polar() {
let mut s = Session::new();
s.cmdline("circle 0,0 5").unwrap();
s.execute("osnap", &json!({"modes": ["tan"]})).unwrap();
s.cmdline("line").unwrap();
snap_click(&mut s, Vec2::new(0.1, 5.2)); // deferred TAN, near the top of the circle
s.cmdline("@150<218").unwrap();
s.cmdline("").unwrap();
let ends = line_ends(&s);
assert_eq!(ends.len(), 1);
let (a, b) = ends[0];
// The start is the tangent point on the circle.
assert!((a.len() - 5.0).abs() < 1e-9, "start not on circle: {a:?}");
// The typed direction and distance are exact.
let v = b - a;
assert!((v.len() - 150.0).abs() < 1e-9, "length was {}", v.len());
let angle = v.y.atan2(v.x).to_degrees().rem_euclid(360.0);
assert!((angle - 218.0).abs() < 1e-9, "angle was {angle}");
}
#[test]
fn line_belt_tangent_to_tangent() {
let mut s = Session::new();
@@ -1184,3 +1259,84 @@ fn block_rotation_property_turns_attribute_text() {
assert_eq!(t.value, "hello");
assert!((t.height - 2.5).abs() < 1e-12);
}
#[test]
fn bare_variable_name_runs_setvar() {
let mut s = Session::new();
s.cmdline("pickadd").unwrap();
assert!(s.current_prompt().is_some());
s.cmdline("0").unwrap();
assert!(s.current_prompt().is_none());
assert_eq!(crate::sysvars::get(&s, "PICKADD"), Some(json!(0)));
// Not a command and not a variable: still an error.
assert!(s.cmdline("nosuchthing").is_err());
// A dash or dot prefix never means a variable.
assert!(s.cmdline("-pickadd").is_err());
}
fn setvar_prompt_open(s: &Session) -> bool {
s.running.as_ref().is_some_and(|r| r.id == "setvar")
}
#[test]
fn every_variable_name_starts_setvar_unless_it_is_a_command() {
let mut s = Session::new();
let names: Vec<String> = match crate::sysvars::list(&s) {
Value::Object(m) => m.keys().cloned().collect(),
_ => Vec::new(),
};
assert!(names.len() > 50, "expected the full variable list, got {}", names.len());
let (mut prompted, mut read_only, mut commands) = (0, 0, 0);
for n in &names {
s.cancel();
let lower = n.to_ascii_lowercase();
let is_command = crate::cmd::find_command(&crate::cmd::resolve_alias(&lower)).is_some();
let result = s.cmdline(&lower);
if is_command {
// A real command keeps its name (it may refuse to run without parameters, as a bare
// CLAYER does); the variable is reached with SETVAR.
assert!(!setvar_prompt_open(&s), "{n} is a command but started SETVAR");
commands += 1;
} else if let Err(e) = result {
panic!("{n}: {e}");
} else if setvar_prompt_open(&s) {
prompted += 1;
} else {
// A read-only variable prints its value and ends.
assert!(s.current_prompt().is_none(), "{n}: neither SETVAR nor finished");
read_only += 1;
}
}
s.cancel();
assert!(prompted > 50 && read_only > 0, "prompted {prompted}, read-only {read_only}, commands {commands}");
}
#[test]
fn hostile_variable_like_input_never_panics() {
let mut s = Session::new();
let long = "a".repeat(1 << 20);
for t in [
"'",
"''",
"'''pickadd",
"\0pickadd",
"pickadd\0",
" ",
"\t",
"pick add",
"PICKADD\u{0}0",
"pickadd",
"pickadd;0",
"ÿ",
&long,
"pickadd\n0",
"'\u{202e}pickadd",
] {
s.cancel();
let _ = s.cmdline(t);
}
s.cancel();
// The session is still usable afterwards.
s.cmdline("pickadd").unwrap();
assert!(setvar_prompt_open(&s));
}
+53
View File
@@ -283,3 +283,56 @@ fn hostile_parameters_are_refused() {
assert!(s.execute("scalelistedit", &p).is_err(), "{p}");
}
}
#[test]
fn annotative_mtext_columns_follow_the_scale() {
// #708: at 1:2, two columns 10 wide with a gutter of 1 and a height of 20 on paper draw 20
// wide, 2 apart and 40 tall: the box is 42 × 40, not 41 × 20.
let mut s = Session::new();
setvar(&mut s, "CANNOSCALE", json!("1:2"));
let r = s
.execute(
"mtext",
&json!({ "at": [0, 0], "height": 1, "width": 10, "text": "a\\Pb\\Pc", "columns": { "type": "static", "count": 2, "gutter": 1, "height": 20 } }),
)
.unwrap();
let h = Handle::parse_hex(r["handle"].as_str().unwrap()).unwrap();
s.execute("properties.set", &json!({ "handles": [h.hex()], "annotative": true })).unwrap();
assert_eq!(scales(&s, h), ["1:2"]);
let v = |s: &mut Session, name: &str| s.execute("getvar", &json!({ "name": name })).unwrap()["value"].clone();
assert_eq!(v(&mut s, "EXTMIN"), json!([0.0, -40.0, 0.0]));
assert_eq!(v(&mut s, "EXTMAX"), json!([42.0, 0.0, 0.0]));
// Dynamic columns with manual heights, and the text of a multileader, scale the same way.
let mut e = entity(&s, h);
if let EntityKind::MText(t) = &mut e.kind
&& let Some(c) = &mut t.columns
{
c.heights = vec![3.0, 5.0];
}
let mtext = match &e.kind {
EntityKind::MText(t) => t.clone(),
k => panic!("{k:?}"),
};
let ml = Entity {
kind: EntityKind::MLeader(cadcraft_engine::doc::MLeader {
leaders: Vec::new(),
landing: Default::default(),
dogleg: 1.0,
text: Some(mtext),
style: "Standard".into(),
arrow_size: 1.0,
spline: false,
block: None,
}),
..e.clone()
};
for e in [e, ml] {
let t = match cadcraft_engine::doc::anno_scaled(&e, 2.0).kind {
EntityKind::MText(t) => t,
EntityKind::MLeader(m) => m.text.unwrap(),
k => panic!("{k:?}"),
};
let c = t.columns.unwrap();
assert_eq!((t.height, t.width, c.count, c.gutter, c.height, c.heights), (2.0, 20.0, 2, 2.0, 40.0, vec![6.0, 10.0]));
}
}
+47
View File
@@ -0,0 +1,47 @@
//! A reference's attributes take Lock position from their definitions; Verify and Preset stay
//! with the definitions (as AutoCAD saves them).
use std::sync::Arc;
use cadcraft_engine::Session;
use cadcraft_engine::doc::{Attrib, AttribModes, Block, Entity, EntityKind, Text};
use cadcraft_engine::geom::Vec3;
use serde_json::json;
#[test]
fn references_take_lock_position_from_the_definition() {
let mut d = cadcraft_engine::doc::Drawing::new_imperial();
let text = Text {
insert: Vec3::ZERO,
align_pt: None,
height: 2.0,
value: "X".into(),
rotation: 0.0,
width_factor: 1.0,
oblique: 0.0,
style: "Standard".into(),
halign: Default::default(),
valign: Default::default(),
};
let modes = AttribModes { verify: true, preset: true, lock_position: true };
let def = Attrib {
tag: "MARK".into(),
text,
invisible: false,
constant: false,
prompt: "Mark".into(),
props: Default::default(),
multiline: None,
modes,
};
let mut b = Block::new("TAG");
let h = d.new_handle();
b.entities.push(Entity::new(h, EntityKind::AttDef(def)));
d.blocks.insert("TAG".into(), Arc::new(b));
let mut s = Session::empty();
s.open_drawing(d, "tags", None);
let r = s.execute("insert", &json!({ "name": "TAG", "at": [10, 5] })).unwrap();
let h = cadcraft_engine::doc::Handle::parse_hex(r["handle"].as_str().unwrap()).unwrap();
let Some(EntityKind::Insert(i)) = s.doc().unwrap().entity(h).map(|e| e.kind.clone()) else { panic!("no reference") };
assert_eq!(i.attribs[0].modes, AttribModes { verify: false, preset: false, lock_position: true });
}
+52
View File
@@ -211,6 +211,58 @@ fn autosave_files_are_written_in_the_background_and_removed_on_exit() {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_failed_autosave_is_tried_again_without_another_change() {
hooks();
let root = temp_dir("retry");
// The folder can't be created while a file holds its name: the write fails (#717).
let out = root.join("out");
std::fs::write(&out, b"not a folder").unwrap();
let dir = out.to_string_lossy().to_string();
let mut s = Session::new();
draw(&mut s);
let mut a = Autosaver::new("31-e");
a.plan(&s, 0.0, 1, &dir);
let jobs = a.plan(&s, 60.0, 1, &dir);
let path = writes(&jobs).pop().unwrap();
a.perform(jobs);
// Once the write has failed, the unchanged drawing waits SAVETIME again.
let mut rearmed = false;
for _ in 0..500 {
a.reap();
assert!(writes(&a.plan(&s, 120.0, 1, &dir)).is_empty());
if a.next_due(120.0, 1) == Some(60.0) {
rearmed = true;
break;
}
std::thread::sleep(Duration::from_millis(10));
}
assert!(rearmed, "a failed autosave must be tried again");
std::fs::remove_file(&out).unwrap();
let jobs = a.plan(&s, 180.0, 1, &dir);
assert_eq!(writes(&jobs), vec![path.clone()]);
a.perform(jobs);
for _ in 0..500 {
if std::path::Path::new(&path).exists() {
break;
}
std::thread::sleep(Duration::from_millis(10));
}
assert!(std::fs::read(&path).unwrap().ends_with(b"EOF\n"));
// A write that succeeded is not repeated.
for _ in 0..50 {
a.reap();
assert!(writes(&a.plan(&s, 1e4, 1, &dir)).is_empty());
std::thread::sleep(Duration::from_millis(10));
}
assert_eq!(a.next_due(1e4, 1), None);
a.shutdown();
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn recover_as_copy_opens_an_unsaved_untitled_drawing() {
hooks();
+372
View File
@@ -0,0 +1,372 @@
//! The Block Editor (BEDIT, BSAVE, BSAVEAS, BCLOSE): a block is edited with the ordinary
//! commands, its base point at the origin, and saving it updates every reference in the drawing.
use cadcraft_engine::Session;
use cadcraft_engine::cmd::file::{IoHooks, set_io};
use cadcraft_engine::doc::{EntityKind, Line};
use cadcraft_engine::geom::Vec3;
use serde_json::{Value, json};
fn line(s: &mut Session, a: [f64; 2], b: [f64; 2]) -> Value {
s.execute("line", &json!({ "points": [a, b] })).unwrap()["handles"][0].clone()
}
/// A drawing with block "Door" (one line from (10,10) to (12,10), base point (10,10)) and two
/// references to it; returns the references.
fn door(s: &mut Session) -> (Value, Value) {
let l = line(s, [10.0, 10.0], [12.0, 10.0]);
let a = s.execute("block", &json!({ "name": "Door", "base": [10, 10], "handles": [l] })).unwrap()["insert"].clone();
let b = s.execute("insert", &json!({ "name": "Door", "at": [50, 0] })).unwrap()["handle"].clone();
(a, b)
}
fn lines_of(s: &Session, block: &str) -> Vec<Line> {
let b = s.doc().unwrap().block(block).unwrap().clone();
b.entities.iter().filter_map(|e| if let EntityKind::Line(l) = &e.kind { Some(l.clone()) } else { None }).collect()
}
fn editor(s: &Session) -> Option<String> {
s.state().unwrap().block_editor().map(str::to_string)
}
#[test]
fn saving_the_block_updates_every_reference() {
let mut s = Session::new();
let (a, b) = door(&mut s);
let model = s.doc().unwrap().model.clone();
s.execute("bedit", &json!({ "name": "door" })).unwrap();
assert_eq!(editor(&s).as_deref(), Some("Door"));
// The editor shows the block's objects with its base point at the origin.
let shown: Vec<_> = s.doc().unwrap().model.iter().map(|e| e.kind.clone()).collect();
assert!(matches!(&shown[..], [EntityKind::Line(l)] if l.a == Vec3::new(0.0, 0.0, 0.0) && l.b == Vec3::new(2.0, 0.0, 0.0)), "{shown:?}");
assert_eq!(s.execute("getvar", &json!({ "name": "BLOCKEDITOR" })).unwrap()["value"], json!(1));
line(&mut s, [0.0, 0.0], [0.0, 3.0]);
assert!(s.state().unwrap().block_unsaved() && s.state().unwrap().is_dirty());
let r = s.execute("bclose", &json!({})).unwrap();
assert_eq!(r, json!({ "block": "Door", "saved": true }));
assert_eq!(editor(&s), None);
// The new line is in the definition, in block coordinates; the drawing's own objects and both
// references are as they were.
let ls = lines_of(&s, "Door");
assert_eq!(ls.len(), 2);
assert_eq!((ls[1].a, ls[1].b), (Vec3::new(10.0, 10.0, 0.0), Vec3::new(10.0, 13.0, 0.0)));
let d = s.doc().unwrap();
assert!(d.model.same_as(&model) || d.model == model);
for h in [&a, &b] {
let h = cadcraft_engine::cmd::curves::handle_value(h).unwrap();
assert!(matches!(&d.entity(h).unwrap().kind, EntityKind::Insert(i) if i.block == "Door"));
}
// One undo step in the drawing takes the edit back.
s.execute("undo", &json!({})).unwrap();
assert_eq!(lines_of(&s, "Door").len(), 1);
}
#[test]
fn closing_without_saving_leaves_the_drawing_as_it_was() {
let mut s = Session::new();
door(&mut s);
let before = s.doc().unwrap().clone();
let undo = s.state().unwrap().undo.len();
s.execute("bedit", &json!({ "name": "Door" })).unwrap();
s.script("ERASE ALL\n\n").unwrap();
assert!(s.doc().unwrap().model.is_empty());
s.execute("bclose", &json!({ "save": false })).unwrap();
assert_eq!(*s.doc().unwrap(), before);
assert_eq!(s.state().unwrap().undo.len(), undo);
assert_eq!(lines_of(&s, "Door").len(), 1);
}
#[test]
fn unchanged_objects_keep_their_exact_coordinates() {
let mut s = Session::new();
// A base point whose coordinates don't survive x - b + b exactly.
let l = line(&mut s, [0.1, 0.7], [0.3, 0.9]);
s.execute("block", &json!({ "name": "B", "base": [0.7, 0.1], "handles": [l] })).unwrap();
let original = lines_of(&s, "B");
s.execute("bedit", &json!({ "name": "B" })).unwrap();
line(&mut s, [0.0, 0.0], [1.0, 0.0]);
s.execute("bsave", &json!({})).unwrap();
assert!(!s.state().unwrap().block_unsaved());
assert_eq!(editor(&s).as_deref(), Some("B"), "BSAVE keeps the editor open");
assert_eq!(lines_of(&s, "B")[0], original[0]);
s.execute("bclose", &json!({})).unwrap();
let ls = lines_of(&s, "B");
assert_eq!(ls[0], original[0]);
assert_eq!(ls.len(), 2);
}
#[test]
fn bsaveas_makes_a_new_block_and_keeps_the_old_one() {
let mut s = Session::new();
door(&mut s);
s.execute("bedit", &json!({ "name": "Door" })).unwrap();
line(&mut s, [0.0, 0.0], [0.0, 3.0]);
let r = s.execute("bsaveas", &json!({ "name": "Door2" })).unwrap();
assert_eq!(r["block"], "Door2");
assert_eq!(editor(&s).as_deref(), Some("Door2"));
assert!(s.execute("bsaveas", &json!({ "name": "Door" })).is_err(), "an existing block is redefined only with replace");
assert!(s.execute("bsaveas", &json!({ "name": "bad*name" })).is_err());
s.execute("bclose", &json!({})).unwrap();
assert_eq!(lines_of(&s, "Door").len(), 1);
let new = lines_of(&s, "Door2");
assert_eq!(new.len(), 2);
assert_eq!(s.doc().unwrap().block("Door2").unwrap().base, s.doc().unwrap().block("Door").unwrap().base);
// Redefining another block (typed in another case) keeps its name and takes the edited
// block's base point, so the objects keep their places relative to it.
let l = line(&mut s, [100.0, 100.0], [101.0, 100.0]);
s.execute("block", &json!({ "name": "Other", "base": [100, 100], "handles": [l] })).unwrap();
s.execute("bedit", &json!({ "name": "Door" })).unwrap();
s.execute("bsaveas", &json!({ "name": "OTHER", "replace": true })).unwrap();
s.execute("bclose", &json!({})).unwrap();
let d = s.doc().unwrap();
let other = d.block("Other").unwrap();
assert_eq!(other.name, "Other");
assert_eq!(other.base, d.block("Door").unwrap().base);
assert_eq!(lines_of(&s, "Other"), lines_of(&s, "Door"));
}
#[test]
fn what_the_block_editor_refuses() {
let mut s = Session::new();
door(&mut s);
assert!(s.execute("bedit", &json!({ "name": "Nope" })).is_err());
assert!(s.execute("bedit", &json!({})).is_err(), "no name and no block reference selected");
assert!(s.execute("bsave", &json!({})).is_err(), "not open");
assert!(s.execute("bclose", &json!({})).is_err(), "not open");
s.execute("bedit", &json!({ "name": "Door" })).unwrap();
assert!(s.execute("bedit", &json!({ "name": "Door" })).is_err(), "already open");
let r = s.execute("layout.set", &json!({ "name": "Layout1" }));
assert!(r.is_err_and(|e| e.to_string().contains("Block Editor")));
assert_eq!(s.layout_space(), cadcraft_engine::doc::Space::Model);
// A reference to the block itself can't be saved into it.
s.execute("insert", &json!({ "name": "Door", "at": [5, 5] })).unwrap();
assert!(s.execute("bsave", &json!({})).is_err_and(|e| e.to_string().contains("itself")));
assert!(s.execute("bclose", &json!({})).is_err());
assert!(editor(&s).is_some(), "a failed save keeps the editor open");
s.execute("bclose", &json!({ "save": false })).unwrap();
assert_eq!(lines_of(&s, "Door").len(), 1);
// Anonymous blocks are made by other objects.
let mut d = s.doc().unwrap().clone();
d.blocks.insert("*U1".into(), std::sync::Arc::new(cadcraft_engine::doc::Block::new("*U1")));
s.open_drawing(d, "anon", None);
assert!(s.execute("bedit", &json!({ "name": "*U1" })).is_err_and(|e| e.to_string().contains("anonymous")));
}
#[test]
fn hostile_params_never_panic() {
let mut s = Session::new();
door(&mut s);
for p in
[json!({ "name": 5 }), json!({ "handle": "zz" }), json!({ "handle": -1 }), json!({ "handle": "FFFFFFFFFFFFFFFFFF" }), json!(null), json!([])]
{
assert!(s.execute("bedit", &p).is_err(), "{p}");
}
s.execute("bedit", &json!({ "name": "Door" })).unwrap();
for p in [json!({ "name": "" }), json!({ "name": " " }), json!({ "name": 7 }), json!({ "name": "x".repeat(300) })] {
assert!(s.execute("bsaveas", &p).is_err(), "{p}");
}
assert!(s.execute("bclose", &json!({ "save": "maybe" })).is_ok());
}
#[test]
fn a_selected_reference_opens_its_block_and_bclose_asks_to_save() {
let mut s = Session::new();
let (a, _) = door(&mut s);
s.set_selection(vec![cadcraft_engine::cmd::curves::handle_value(&a).unwrap()]);
s.start("bedit").unwrap();
assert!(s.running.is_none());
assert_eq!(editor(&s).as_deref(), Some("Door"));
// Nothing changed: BCLOSE closes at once.
s.script("BCLOSE\n").unwrap();
assert_eq!(editor(&s), None);
// Typed name, a change, then BCLOSE No discards it.
s.script("BEDIT Door\nLINE 0,0 5,5\n\nBCLOSE\nN\n").unwrap();
assert_eq!(editor(&s), None);
assert_eq!(lines_of(&s, "Door").len(), 1);
// BCLOSE Yes saves it.
s.script("BEDIT Door\nLINE 0,0 5,5\n\nBCLOSE\n\n").unwrap();
assert_eq!(lines_of(&s, "Door").len(), 2);
assert_eq!(s.execute("getvar", &json!({ "name": "BLOCKEDITOR" })).map(|v| v["value"].clone()).unwrap_or(Value::Null), json!(0));
}
#[test]
fn a_file_save_writes_the_drawing_outside_the_editor() {
// The writer records how many objects the drawing's model space has, and block Door's.
set_io(IoHooks {
read: |_, _| Err("no reader in this test".into()),
write: |d, _| Ok(format!("{} {}", d.model.len(), d.block("Door").map_or(0, |b| b.entities.len())).into_bytes()),
plot: None,
});
let mut s = Session::new();
door(&mut s);
let path = std::env::temp_dir().join(format!("cadcraft-bedit-{}.dxf", std::process::id()));
let path = path.to_string_lossy().to_string();
s.execute("saveas", &json!({ "path": path })).unwrap();
assert!(!s.state().unwrap().is_dirty());
s.execute("bedit", &json!({ "name": "Door" })).unwrap();
line(&mut s, [0.0, 0.0], [0.0, 3.0]);
// Autosave keeps the editor's unsaved change in the block.
let rec = s.state().unwrap().recovery_drawing();
assert_eq!(rec.block("Door").unwrap().entities.len(), 2);
assert_eq!(rec.model.len(), 2);
s.execute("qsave", &json!({})).unwrap();
assert_eq!(std::fs::read_to_string(&path).unwrap(), "2 1", "the drawing, without the unsaved block change");
assert!(s.state().unwrap().is_dirty(), "the block change is still unsaved");
s.execute("bsave", &json!({})).unwrap();
s.execute("qsave", &json!({})).unwrap();
assert_eq!(std::fs::read_to_string(&path).unwrap(), "2 2");
assert!(!s.state().unwrap().is_dirty());
s.execute("bclose", &json!({})).unwrap();
assert!(!s.state().unwrap().is_dirty());
let _ = std::fs::remove_file(&path);
}
#[test]
fn a_dynamic_block_from_another_program_says_its_parameters_are_kept() {
use cadcraft_engine::doc::foreign::{Foreign, ForeignExtra, ForeignObject};
use cadcraft_engine::doc::{Handle, RawTag};
let mut s = Session::new();
door(&mut s);
// As a DXF from AutoCAD has it: the block record's extension dictionary holds ACAD_ENHANCEDBLOCK.
let mut f = Foreign::default();
let xdict = Handle(0xF00);
f.records.insert(("BLOCK_RECORD".into(), "DOOR".into()), ForeignExtra { xdict: Some(xdict), ..Default::default() });
let tag = |code, value: &str| RawTag { code, value: value.into() };
f.objects.push(ForeignObject { handle: xdict, kind: "DICTIONARY".into(), tags: vec![tag(3, "ACAD_ENHANCEDBLOCK"), tag(360, "F01")] });
let mut d = s.doc().unwrap().clone();
d.foreign = std::sync::Arc::new(f);
s.open_drawing(d, "dynamic", None);
s.execute("bedit", &json!({ "name": "Door" })).unwrap();
assert!(s.log.iter().any(|l| l.contains("dynamic block")), "{:?}", s.log);
// A plain block says nothing of the kind.
s.execute("bclose", &json!({})).unwrap();
let mut s = Session::new();
door(&mut s);
s.execute("bedit", &json!({ "name": "Door" })).unwrap();
assert!(!s.log.iter().any(|l| l.contains("dynamic block")), "{:?}", s.log);
}
#[test]
fn a_dynamic_reference_with_its_own_values_opens_its_dynamic_block() {
use cadcraft_engine::doc::foreign::{Foreign, ForeignExtra};
use cadcraft_engine::doc::{Block, Handle, RawTag};
let mut s = Session::new();
let (a, _) = door(&mut s);
// As AutoCAD leaves it: the reference shows anonymous block *U7, whose record's
// AcDbBlockRepBTag xdata names Door's record (file handle 1D).
let mut d = s.doc().unwrap().clone();
let door_block = d.block("Door").unwrap().clone();
d.blocks.insert("*U7".into(), std::sync::Arc::new(Block { name: "*U7".into(), anonymous: true, ..(*door_block).clone() }));
let h = cadcraft_engine::cmd::curves::handle_value(&a).unwrap();
d.model.modify(h, |e| {
if let EntityKind::Insert(i) = &mut e.kind {
i.block = "*U7".into();
}
});
let mut f = Foreign::default();
let tag = |code, value: &str| RawTag { code, value: value.into() };
f.records.insert(
("BLOCK_RECORD".into(), "*U7".into()),
ForeignExtra { xdata: vec![tag(1001, "AcDbBlockRepBTag"), tag(1070, "1"), tag(1005, "1D")], ..Default::default() },
);
f.targets.insert(Handle(0x1D), ("BLOCK_RECORD".into(), "DOOR".into()));
d.foreign = std::sync::Arc::new(f);
s.open_drawing(d, "dynamic", None);
// Selected (the right-click Block Editor) and by handle: both open Door.
s.set_selection(vec![h]);
s.start("bedit").unwrap();
assert_eq!(editor(&s).as_deref(), Some("Door"));
s.execute("bclose", &json!({})).unwrap();
s.execute("bedit", &json!({ "handle": h.hex() })).unwrap();
assert_eq!(editor(&s).as_deref(), Some("Door"));
s.execute("bclose", &json!({})).unwrap();
// Other anonymous blocks are still refused by name.
assert!(s.execute("bedit", &json!({ "name": "*U7" })).is_err());
}
#[test]
fn bedit_can_start_a_new_block() {
let mut s = Session::new();
door(&mut s);
// An existing name edits that block.
let r = s.execute("bedit", &json!({ "name": "door", "create": true })).unwrap();
assert_eq!(r, json!({ "block": "Door", "new": false }));
s.execute("bclose", &json!({})).unwrap();
// A new name starts an empty block; closing without saving creates nothing.
let r = s.execute("bedit", &json!({ "name": " Window ", "create": true })).unwrap();
assert_eq!(r, json!({ "block": "Window", "new": true }));
assert!(s.doc().unwrap().model.is_empty());
s.execute("bclose", &json!({ "save": false })).unwrap();
assert!(s.doc().unwrap().block("Window").is_none());
// Saving creates it, base point at the origin.
s.execute("bedit", &json!({ "name": "Window", "create": true })).unwrap();
line(&mut s, [0.0, 0.0], [3.0, 0.0]);
s.execute("bclose", &json!({})).unwrap();
let d = s.doc().unwrap();
let w = d.block("Window").unwrap();
assert_eq!((w.name.as_str(), w.entities.len(), w.base), ("Window", 1, Vec3::new(0.0, 0.0, 0.0)));
// Without `create` a missing block is an error; invalid new names are refused.
assert!(s.execute("bedit", &json!({ "name": "Skylight" })).is_err());
for bad in ["", "a*b", "*U9", "x<y"] {
assert!(s.execute("bedit", &json!({ "name": bad, "create": true })).is_err(), "{bad}");
}
assert!(s.state().unwrap().block_editor().is_none());
}
#[test]
fn the_current_drawing_opens_in_the_block_editor() {
use cadcraft_engine::blockedit::{CURRENT_DRAWING, has_authoring_elements};
use cadcraft_engine::doc::foreign::{Foreign, ForeignExtra, ForeignObject};
use cadcraft_engine::doc::{Handle, RawTag};
let mut s = Session::new();
line(&mut s, [0.0, 0.0], [10.0, 0.0]);
// A dynamic block's own drawing: model space's record carries ACAD_ENHANCEDBLOCK.
let mut d = s.doc().unwrap().clone();
assert!(!has_authoring_elements(&d));
let mut f = Foreign::default();
let tag = |code, value: &str| RawTag { code, value: value.into() };
f.records.insert(("BLOCK_RECORD".into(), "*MODEL_SPACE".into()), ForeignExtra { xdict: Some(Handle(0xF00)), ..Default::default() });
f.objects.push(ForeignObject { handle: Handle(0xF00), kind: "DICTIONARY".into(), tags: vec![tag(3, "ACAD_ENHANCEDBLOCK"), tag(360, "F01")] });
d.foreign = std::sync::Arc::new(f);
assert!(has_authoring_elements(&d));
s.open_drawing(d, "symbol", None);
// Opened on the drawing itself: its objects are there to edit, and it says it is dynamic.
let r = s.execute("bedit", &json!({ "name": "<current drawing>", "create": true })).unwrap();
assert_eq!(r, json!({ "block": CURRENT_DRAWING, "new": false }));
assert_eq!(editor(&s).as_deref(), Some(CURRENT_DRAWING));
assert_eq!(s.doc().unwrap().model.len(), 1);
assert!(s.log.iter().any(|l| l.contains("dynamic block")), "{:?}", s.log);
// A block of it can't be saved under another name here.
assert!(s.execute("bsaveas", &json!({ "name": "Copy" })).is_err());
// BCLOSE without saving puts the drawing back.
line(&mut s, [0.0, 5.0], [10.0, 5.0]);
s.execute("bclose", &json!({ "save": false })).unwrap();
assert_eq!(editor(&s), None);
assert_eq!(s.doc().unwrap().model.len(), 1);
// Saved, the change is the drawing's: one undo step takes it back.
s.execute("bedit", &json!({ "name": CURRENT_DRAWING })).unwrap();
line(&mut s, [0.0, 5.0], [10.0, 5.0]);
s.execute("bclose", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().model.len(), 2);
assert!(s.doc().unwrap().block(CURRENT_DRAWING).is_none(), "no block is made");
s.undo().unwrap();
assert_eq!(s.doc().unwrap().model.len(), 1);
}
@@ -0,0 +1,135 @@
//! BREAK, BREAKATPOINT and JOIN leave objects on locked layers alone (#727).
use cadcraft_engine::geom::Vec2;
use cadcraft_engine::{Input, Session};
use serde_json::{Value, json};
fn h(r: &Value) -> String {
r["handle"].as_str().or_else(|| r["handles"][0].as_str()).unwrap().to_string()
}
/// Everything in the drawing a locked-layer edit could disturb.
fn snapshot(s: &Session) -> String {
let d = s.doc().unwrap();
format!("{:?}", d.model.iter().map(|e| (e.handle, e.common.layer.clone(), e.kind.clone())).collect::<Vec<_>>())
}
fn set_lock(s: &mut Session, locked: bool) {
s.execute("layer.set", &json!({"name": "0", "locked": locked})).unwrap();
}
fn line(s: &mut Session, a: [f64; 2], b: [f64; 2]) -> String {
h(&s.execute("line", &json!({"points": [a, b]})).unwrap())
}
fn lines_ab(s: &mut Session) -> (String, String) {
(line(s, [0.0, 0.0], [10.0, 0.0]), line(s, [10.0, 0.0], [20.0, 0.0]))
}
#[test]
fn break_and_breakatpoint_refuse_a_locked_line() {
let mut s = Session::new();
let a = line(&mut s, [0.0, 0.0], [10.0, 0.0]);
let control = line(&mut s, [0.0, 5.0], [10.0, 5.0]);
set_lock(&mut s, true);
let before = snapshot(&s);
assert!(s.execute("break", &json!({"handle": a, "p1": [3, 0], "p2": [7, 0]})).is_err());
assert!(s.execute("breakatpoint", &json!({"handle": a, "at": [5, 0]})).is_err());
// Selection fallback reaches the same guard.
s.execute("select", &json!({"handles": [a]})).unwrap();
assert!(s.execute("break", &json!({"p1": [3, 0], "p2": [7, 0]})).is_err());
assert!(s.execute("breakatpoint", &json!({"at": [5, 0]})).is_err());
assert_eq!(snapshot(&s), before);
// Unlocked, the same edits work, and undo and redo them.
set_lock(&mut s, false);
let unlocked = snapshot(&s);
let r = s.execute("break", &json!({"handle": a, "p1": [3, 0], "p2": [7, 0]})).unwrap();
assert_eq!(r["handles"].as_array().unwrap().len(), 2);
assert!(s.doc().unwrap().entity(cadcraft_engine::doc::Handle::parse_hex(&a).unwrap()).is_none());
assert!(s.doc().unwrap().entity(cadcraft_engine::doc::Handle::parse_hex(&control).unwrap()).is_some());
let broken = snapshot(&s);
s.execute("undo", &json!({})).unwrap();
assert_eq!(snapshot(&s), unlocked);
s.execute("redo", &json!({})).unwrap();
assert_eq!(snapshot(&s), broken);
}
#[test]
fn break_refuses_a_locked_arc_and_circle() {
let mut s = Session::new();
let arc = h(&s.execute("arc", &json!({"center": [0, 0], "radius": 5, "start": 0, "end": 90})).unwrap());
let circle = h(&s.execute("circle", &json!({"center": [20, 0], "radius": 3})).unwrap());
set_lock(&mut s, true);
let before = snapshot(&s);
assert!(s.execute("break", &json!({"handle": arc, "p1": [5, 0], "p2": [0, 5]})).is_err());
assert!(s.execute("break", &json!({"handle": circle, "p1": [23, 0], "p2": [20, 3]})).is_err());
assert_eq!(snapshot(&s), before);
}
#[test]
fn join_refuses_when_any_line_is_locked() {
let mut s = Session::new();
let (a, b) = lines_ab(&mut s);
set_lock(&mut s, true);
let before = snapshot(&s);
assert!(s.execute("join", &json!({"handles": [a, b]})).is_err());
s.execute("select", &json!({"handles": [a, b]})).unwrap();
assert!(s.execute("join", &json!({})).is_err());
assert_eq!(snapshot(&s), before);
set_lock(&mut s, false);
let r = s.execute("join", &json!({"handles": [a, b]})).unwrap();
assert!(r["handle"].is_string());
assert_eq!(s.doc().unwrap().model.len(), 1);
}
#[test]
fn join_keeps_a_locked_original_in_a_mixed_layer_pair() {
for locked_first in [true, false] {
let mut s = Session::new();
s.execute("layer.new", &json!({"name": "L"})).unwrap();
// The first line goes on the soon-locked layer 0, the second on "L" (or the reverse).
let on_l = |s: &mut Session, a: [f64; 2], b: [f64; 2]| {
s.execute("layer.current", &json!({"name": "L"})).unwrap();
let r = line(s, a, b);
s.execute("layer.current", &json!({"name": "0"})).unwrap();
r
};
let (a, b) = if locked_first {
(line(&mut s, [0.0, 0.0], [10.0, 0.0]), on_l(&mut s, [10.0, 0.0], [20.0, 0.0]))
} else {
(on_l(&mut s, [0.0, 0.0], [10.0, 0.0]), line(&mut s, [10.0, 0.0], [20.0, 0.0]))
};
let locked = if locked_first { &a } else { &b };
set_lock(&mut s, true);
let before = snapshot(&s);
assert!(s.execute("join", &json!({"handles": [a, b]})).is_err());
assert_eq!(snapshot(&s), before, "locked_first = {locked_first}");
assert!(s.doc().unwrap().entity(cadcraft_engine::doc::Handle::parse_hex(locked).unwrap()).is_some());
}
}
#[test]
fn interactive_break_and_join_refuse_locked_lines() {
let mut s = Session::new();
let (a, b) = lines_ab(&mut s);
set_lock(&mut s, true);
let before = snapshot(&s);
// BREAK: pick the line, then the second break point.
s.start("break").unwrap();
s.input(Input::Point(Vec2::new(3.0, 0.0))).unwrap();
let _ = s.input(Input::Point(Vec2::new(7.0, 0.0)));
assert!(s.log.last().is_some_and(|m| m.contains("locked layer")), "{:?}", s.log.last());
assert_eq!(snapshot(&s), before);
// JOIN on a preselected pair reports the refusal on the command line.
s.cancel();
s.execute("select", &json!({"handles": [a, b]})).unwrap();
s.start("join").unwrap();
assert!(s.log.last().is_some_and(|m| m.contains("locked layer")), "{:?}", s.log.last());
assert_eq!(snapshot(&s), before);
// Unlocked, the interactive commands still edit.
set_lock(&mut s, false);
s.start("break").unwrap();
s.input(Input::Point(Vec2::new(3.0, 0.0))).unwrap();
s.input(Input::Point(Vec2::new(7.0, 0.0))).unwrap();
assert_ne!(snapshot(&s), before);
}
+1 -1
View File
@@ -46,7 +46,7 @@ fn loaded_spacing_is_used_by_getvar_settings_snapshots_and_grid_snap() {
s.settings.snapmode = true;
s.settings.osmode = 0;
s.settings.polarmode = false;
let q = CursorQuery { base: None, deferred: false, aperture: 0.1, tol: 0.1, now: 0.0, shift: false };
let q = CursorQuery { base: None, deferred: false, corner: false, aperture: 0.1, tol: 0.1, now: 0.0, shift: false };
assert_eq!(s.snap_cursor(Vec2::new(0.27, 0.15), &q).point, Vec2::new(0.25, 0.125));
}
+49
View File
@@ -44,6 +44,25 @@ fn spline_rejects_text_and_switches_method() {
assert!(sp.fit.is_empty() && sp.control.len() == 4);
}
#[test]
fn circle_radius_point_on_the_centre_is_refused() {
for input in ["circle 2,3 2,3", "circle 2,3 d 2,3"] {
let mut s = Session::new();
s.cmdline(input).unwrap();
assert_eq!(s.doc().unwrap().model.len(), 0, "{input}: no circle");
assert!(logged(&s, "Value must be positive and nonzero."), "{input}: {:?}", s.log);
assert!(s.running.is_some(), "{input}: still at the radius prompt");
s.cmdline("4").unwrap();
assert!(s.running.is_none(), "{input}: a positive value finishes");
assert_eq!(s.doc().unwrap().model.len(), 1, "{input}: one circle");
let want = if input.contains(" d ") { 2.0 } else { 4.0 };
assert!(
matches!(last(&s), EntityKind::Circle(c) if c.center.xy().near(Vec2::new(2.0, 3.0), 1e-9) && (c.radius - want).abs() < 1e-9),
"{input}: the centre is kept"
);
}
}
/// The two lowest vertices of a closed polyline share their y (the bottom edge is horizontal).
fn bottom_y(k: &EntityKind) -> Option<f64> {
let EntityKind::LwPolyline(p) = k else { return None };
@@ -65,6 +84,20 @@ fn polygon_typed_radius_has_a_horizontal_bottom_edge() {
}
}
#[test]
fn polygon_edge_endpoints_must_differ() {
let mut s = Session::new();
s.cmdline("polygon 4 e 1,1 1,1").unwrap();
assert_eq!(s.doc().unwrap().model.len(), 0, "no polygon");
assert!(logged(&s, "Edge endpoints must differ."), "{:?}", s.log);
assert!(s.running.is_some(), "still at the second endpoint prompt");
s.cmdline("3,1").unwrap();
assert!(s.running.is_none(), "a distinct endpoint finishes");
let EntityKind::LwPolyline(p) = last(&s) else { panic!("no polygon") };
assert!(p.closed && p.vertices.len() == 4);
assert!(p.vertices.iter().any(|v| v.p.near(Vec2::new(3.0, 1.0), 1e-9)), "the edge runs from 1,1 to 3,1");
}
#[test]
fn circle_center_prompt_rejects_garbage() {
let mut s = Session::new();
@@ -74,3 +107,19 @@ fn circle_center_prompt_rejects_garbage() {
s.cmdline("0,0 5").unwrap();
assert!(matches!(last(&s), EntityKind::Circle(c) if (c.radius - 5.0).abs() < 1e-9));
}
#[test]
fn polygon_radius_must_be_positive() {
for input in ["polygon 4 0,0 i 0", "polygon 4 0,0 c 0", "polygon 5 0,0 i -5", "polygon 5 0,0 c -5", "polygon 4 0,0 i 0,0", "polygon 4 0,0 c 0,0"]
{
let mut s = Session::new();
s.cmdline(input).unwrap();
assert_eq!(s.doc().unwrap().model.len(), 0, "{input}: no polygon");
assert!(logged(&s, "Value must be positive and nonzero."), "{input}: {:?}", s.log);
assert!(s.running.is_some(), "{input}: still at the radius prompt");
s.cmdline("2").unwrap();
assert!(s.running.is_none(), "{input}: a positive radius finishes");
assert_eq!(s.doc().unwrap().model.len(), 1, "{input}: one polygon");
assert!(matches!(last(&s), EntityKind::LwPolyline(p) if p.closed), "{input}");
}
}
+140
View File
@@ -0,0 +1,140 @@
//! `blocks.dynamic` and `blocks.list` report a dynamic block's parameters and actions as read
//! from another program's file (kept foreign data, built by hand here).
use std::sync::Arc;
use cadcraft_engine::Session;
use cadcraft_engine::doc::foreign::{Foreign, ForeignExtra, ForeignObject};
use cadcraft_engine::doc::{Handle, RawTag};
use serde_json::json;
fn obj(handle: u64, kind: &str, tags: &[(i32, &str)]) -> ForeignObject {
ForeignObject { handle: Handle(handle), kind: kind.into(), tags: tags.iter().map(|(c, v)| RawTag { code: *c, value: (*v).into() }).collect() }
}
/// A session whose block "Door" has a visibility parameter with two states.
fn session() -> Session {
let mut s = Session::new();
let l = s.execute("line", &json!({ "points": [[0, 0], [1, 0]] })).unwrap()["handles"][0].clone();
s.execute("block", &json!({ "name": "Door", "base": [0, 0], "handles": [l] })).unwrap();
let line = s.doc().unwrap().block("Door").unwrap().entities.handles()[0].hex();
let mut f = Foreign::default();
f.records.insert(("BLOCK_RECORD".into(), "DOOR".into()), ForeignExtra { xdict: Some(Handle(0xF00)), ..Default::default() });
f.objects.push(obj(0xF00, "DICTIONARY", &[(100, "AcDbDictionary"), (3, "ACAD_ENHANCEDBLOCK"), (360, "F01")]));
f.objects.push(obj(0xF01, "ACAD_EVALUATION_GRAPH", &[(100, "AcDbEvalGraph"), (91, "0"), (95, "1"), (360, "F02")]));
f.objects.push(obj(
0xF02,
"BLOCKVISIBILITYPARAMETER",
&[
(100, "AcDbEvalExpr"),
(90, "1"),
(100, "AcDbBlockElement"),
(300, "Visibility State"),
(100, "AcDbBlockParameter"),
(280, "1"),
(100, "AcDbBlock1PtParameter"),
(1010, "2"),
(1020, "3"),
(100, "AcDbBlockVisibilityParameter"),
(301, "Visibility1"),
(302, ""),
(93, "1"),
(331, &line),
(92, "2"),
(303, "Open"),
(94, "1"),
(332, &line),
(95, "0"),
(303, "Closed"),
(94, "0"),
(95, "0"),
],
));
let mut d = s.doc().unwrap().clone();
d.foreign = Arc::new(f);
s.open_drawing(d, "dynamic", None);
s
}
#[test]
fn dynamic_block_data_is_reported() {
let mut s = session();
let list = s.execute("blocks.list", &json!({})).unwrap();
let flags: Vec<_> = list["blocks"].as_array().unwrap().iter().map(|b| (b["name"].as_str().unwrap().to_string(), b["dynamic"].clone())).collect();
assert!(flags.contains(&("Door".into(), json!(true))), "{flags:?}");
let m = s.execute("blocks.dynamic", &json!({ "name": "DOOR" })).unwrap();
let p = &m["parameters"][0];
assert_eq!((p["node"].clone(), p["element"].clone(), p["showProperties"].clone()), (json!(1), json!("Visibility State"), json!(true)));
assert_eq!(p["kind"]["type"], "visibility");
assert_eq!(p["kind"]["label"], "Visibility1");
assert_eq!(p["kind"]["at"], json!({ "x": 2.0, "y": 3.0, "z": 0.0 }));
let states: Vec<_> =
p["kind"]["states"].as_array().unwrap().iter().map(|st| (st["name"].clone(), st["visible"].as_array().unwrap().len())).collect();
assert_eq!(states, [(json!("Open"), 1), (json!("Closed"), 0)]);
assert!(p["kind"]["controlled"][0].is_string(), "handles are hex strings");
assert_eq!(m["actions"], json!([]));
}
#[test]
fn blocks_without_dynamic_data_and_bad_names() {
let mut s = session();
let l = s.execute("line", &json!({ "points": [[0, 0], [1, 1]] })).unwrap()["handles"][0].clone();
s.execute("block", &json!({ "name": "Window", "base": [0, 0], "handles": [l] })).unwrap();
assert_eq!(s.execute("blocks.dynamic", &json!({ "name": "Window" })).unwrap(), json!(null));
assert_eq!(s.execute("blocks.dynamic", &json!({ "name": "*Model_Space" })).unwrap(), json!(null));
for p in [json!({}), json!({ "name": 3 }), json!({ "name": "Nope" }), json!(null)] {
assert!(s.execute("blocks.dynamic", &p).is_err(), "{p}");
}
}
#[test]
fn a_reference_to_a_dynamic_block_reports_its_custom_properties() {
let mut s = session();
// BLOCK made the line a reference to Door, which has Door's own values.
let insert = s.doc().unwrap().model.handles()[0].hex();
let r = s.execute("blocks.reference", &json!({ "handle": insert })).unwrap();
assert_eq!(r["definition"], "Door");
assert_eq!(r["values"], json!({}));
assert_eq!(r["properties"], json!([{ "name": "Visibility1", "value": "Open", "choices": ["Open", "Closed"], "editable": true }]));
// A plain block's reference, objects that aren't references and bad parameters.
let l = s.execute("line", &json!({ "points": [[0, 0], [1, 1]] })).unwrap()["handles"][0].clone();
let plain = s.execute("block", &json!({ "name": "Window", "base": [0, 0], "handles": [l.clone()] })).unwrap()["insert"].clone();
assert_eq!(s.execute("blocks.reference", &json!({ "handle": plain })).unwrap(), json!(null));
let l = s.execute("line", &json!({ "points": [[0, 0], [2, 2]] })).unwrap()["handles"][0].clone();
assert!(s.execute("blocks.reference", &json!({ "handle": l })).is_err());
for p in [json!({}), json!({ "handle": "zz" }), json!({ "handle": "FFFFFF" }), json!(null)] {
assert!(s.execute("blocks.reference", &p).is_err(), "{p}");
}
}
#[test]
fn a_visibility_state_is_changed_in_one_undo_step() {
let mut s = session();
let insert = s.doc().unwrap().model.handles()[0].hex();
let r = s.execute("blocks.reference.set", &json!({ "handle": insert, "name": "Visibility1", "value": "Closed" })).unwrap();
assert_eq!(r["properties"][0]["value"], "Closed");
let block = |s: &Session| match s.doc().unwrap().model.iter().next().map(|e| e.kind.clone()) {
Some(cadcraft_engine::doc::EntityKind::Insert(i)) => i.block,
other => panic!("{other:?}"),
};
assert!(block(&s).starts_with("*U"));
// Still a reference to Door, in its new state.
let r = s.execute("blocks.reference", &json!({ "handle": insert })).unwrap();
assert_eq!((r["definition"].clone(), r["properties"][0]["value"].clone()), (json!("Door"), json!("Closed")));
s.execute("undo", &json!({})).unwrap();
assert_eq!(block(&s), "Door");
let r = s.execute("blocks.reference", &json!({ "handle": insert })).unwrap();
assert_eq!(r["properties"][0]["value"], "Open");
for p in [
json!({ "handle": insert, "name": "Visibility1", "value": "Ajar" }),
json!({ "handle": insert, "name": "Visibility1" }),
json!({ "name": "Visibility1", "value": "Open" }),
json!({ "handle": insert, "value": "Open" }),
] {
assert!(s.execute("blocks.reference.set", &p).is_err(), "{p}");
}
}
+90
View File
@@ -0,0 +1,90 @@
//! Grips of dynamic block references: listed, dragged by `blocks.reference.grip` (one undo
//! step), and dragged interactively the way a click on a grip starts it, with a preview and a
//! typed distance. The drawing is the synthetic DOOR block of the io tests, with grips added.
use cadcraft_engine::doc::dynblock::GripPart;
use cadcraft_engine::doc::{Handle, Space};
use cadcraft_engine::geom::Vec2;
use cadcraft_engine::{Input, Session};
use serde_json::{Value, json};
/// DXF text from lines of `code value`.
fn dxf(src: &str) -> String {
src.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.map(|l| {
let (code, value) = l.split_once(' ').unwrap_or((l, ""));
format!("{code}\n{value}\n")
})
.collect()
}
/// A session with DOOR (Width's end grip 9 shown in the Open state, the visibility grip 10) and
/// a reference to it at (100, 0).
fn session() -> (Session, Handle) {
let grip = "0 BLOCKLINEARGRIP\n5 68\n330 61\n100 AcDbEvalExpr\n90 9\n98 33\n99 232\n100 AcDbBlockElement\n300 End Grip\n98 33\n99 232\n1071 0\n";
let src = include_str!("../../io/tests/data/dynblock_door.txt")
.replace("\r\n", "\n")
.replace("170 4\n91 0\n91 0\n", "170 4\n91 8\n91 9\n")
.replace("1030 0\n93 0\n170 0\n171 0\n100 AcDbBlockVisibilityParameter", "1030 0\n93 10\n170 0\n171 0\n100 AcDbBlockVisibilityParameter")
.replace("91 5\n93 32\n95 6\n360 67", "91 5\n93 32\n95 6\n360 67\n92 -1\n92 -1\n92 -1\n92 -1\n91 6\n93 32\n95 9\n360 68")
.replace("95 1\n333 63\n", "95 2\n333 63\n333 68\n")
.replace("0 BLOCKFLIPGRIP", &format!("{grip}0 BLOCKFLIPGRIP"));
let d = cadcraft_io::read_dxf(dxf(&src).as_bytes()).unwrap();
let mut s = Session::empty();
s.open_drawing(d, "door", None);
let r = s.execute("insert", &json!({ "name": "Door", "at": [100, 0] })).unwrap();
let h = Handle::parse_hex(r["handle"].as_str().unwrap()).unwrap();
assert!(s.doc().unwrap().space_of(h) == Some(Space::Model));
(s, h)
}
fn width(s: &mut Session, h: Handle) -> String {
let r = s.execute("blocks.reference", &json!({ "handle": h.hex() })).unwrap();
let p = r["properties"].as_array().unwrap().iter().find(|p| p["name"] == "Width").cloned().unwrap_or(Value::Null);
p["value"].as_str().unwrap_or_default().to_string()
}
#[test]
fn grips_are_listed_and_dragged_in_one_undo_step() {
let (mut s, h) = session();
let g = s.execute("blocks.reference.grips", &json!({ "handle": h.hex() })).unwrap();
let parts: Vec<(i64, &str, &str)> =
g.as_array().unwrap().iter().map(|g| (g["node"].as_i64().unwrap(), g["part"].as_str().unwrap(), g["shape"].as_str().unwrap())).collect();
assert_eq!(parts, [(1, "at", "list"), (2, "end", "linear")]);
assert_eq!(g[1]["at"], json!([136.0, -4.0]));
s.execute("blocks.reference.grip", &json!({ "handle": h.hex(), "node": 2, "part": "end", "to": [130.4, 7] })).unwrap();
assert_eq!(width(&mut s, h), "30.0000");
s.undo().unwrap();
assert_eq!(width(&mut s, h), "36.0000");
for bad in [
json!({ "handle": h.hex(), "node": 2, "part": "middle", "to": [130, 0] }),
json!({ "handle": h.hex(), "node": 2, "part": "end" }),
json!({ "handle": h.hex(), "part": "end", "to": [130, 0] }),
json!({ "handle": h.hex(), "node": 1, "part": "at", "to": [130, 0] }),
json!({ "handle": h.hex(), "node": 2, "part": "end", "to": [10, 0] }),
json!({ "handle": "FFFFF", "node": 2, "part": "end", "to": [130, 0] }),
] {
assert!(s.execute("blocks.reference.grip", &bad).is_err(), "{bad}");
}
assert_eq!(width(&mut s, h), "36.0000");
}
#[test]
fn a_grip_drag_previews_the_reference_and_takes_a_point_or_a_distance() {
let (mut s, h) = session();
s.start_dyn_grip(h, 2, GripPart::End).unwrap();
// The preview is the reference re-evaluated at the cursor, and the rubber band.
assert!(s.preview(Vec2::new(132.0, 0.0)).len() > 1);
s.input(Input::Point(Vec2::new(132.2, 3.0))).unwrap();
assert!(s.running.is_none());
assert_eq!(width(&mut s, h), "32.0000");
// A typed distance (snapped to the list).
s.start_dyn_grip(h, 2, GripPart::End).unwrap();
s.input(Input::Text("29".into())).unwrap();
assert_eq!(width(&mut s, h), "30.0000");
// A list grip offers its choices instead; a grip that isn't there is refused.
assert!(s.start_dyn_grip(h, 1, GripPart::At).is_err());
assert!(s.start_dyn_grip(h, 2, GripPart::Base).is_err());
}
+1 -1
View File
@@ -17,7 +17,7 @@ fn setvar(s: &mut Session, name: &str, value: Value) {
/// Move the cursor to `raw` as the canvas does.
fn hover(s: &mut Session, raw: Vec2, base: Option<Vec2>) -> Vec2 {
let q = CursorQuery { base, deferred: false, aperture: 0.5, tol: 0.1, now: 100.0, shift: false };
let q = CursorQuery { base, deferred: false, corner: false, aperture: 0.5, tol: 0.1, now: 100.0, shift: false };
let r = s.snap_cursor(raw, &q);
s.cursor = r.point;
r.point
+249
View File
@@ -0,0 +1,249 @@
//! The engine side of the Hatch and Gradient dialog: GRADIENT's name and centred, points and
//! objects in one HATCH call, HATCHEDIT's gradient, HATCHPICK and HPDLGMODE.
use cadcraft_engine::Session;
use cadcraft_engine::doc::{EntityKind, Handle, Hatch};
use serde_json::{Value, json};
fn rect(s: &mut Session, a: [f64; 2], b: [f64; 2]) -> String {
s.execute("rectang", &json!({ "p1": a, "p2": b })).unwrap()["handle"].as_str().unwrap().to_string()
}
/// The handle of the object added last.
fn last(s: &Session) -> String {
s.doc().unwrap().model.iter().last().unwrap().handle.hex()
}
fn hatch(s: &Session, h: &str) -> Hatch {
match &s.doc().unwrap().entity(Handle::parse_hex(h).unwrap()).unwrap().kind {
EntityKind::Hatch(x) => x.clone(),
k => panic!("not a hatch: {k:?}"),
}
}
fn hatches(s: &Session) -> Vec<(String, Hatch)> {
s.doc().unwrap().model.iter().filter_map(|e| if let EntityKind::Hatch(h) = &e.kind { Some((e.handle.hex(), h.clone())) } else { None }).collect()
}
fn count(s: &Session) -> usize {
s.doc().unwrap().model.len()
}
fn undo_depth(s: &Session) -> usize {
s.state().unwrap().undo.len()
}
fn handle(v: &Value) -> String {
v["handle"].as_str().unwrap().to_string()
}
#[test]
fn gradient_takes_a_name_and_centred() {
let mut s = Session::new();
rect(&mut s, [0.0, 0.0], [10.0, 10.0]);
let h = handle(
&s.execute("gradient", &json!({ "points": [[5, 5]], "name": "invspherical", "centered": false, "color1": "1", "color2": "3", "angle": 45 }))
.unwrap(),
);
let g = hatch(&s, &h).gradient.unwrap();
assert_eq!(g.name, "INVSPHERICAL");
assert!(!g.centered);
assert!((g.angle - 45f64.to_radians()).abs() < 1e-12);
// Linear and centred unless told otherwise.
let h = handle(&s.execute("gradient", &json!({ "points": [[5, 5]] })).unwrap());
let g = hatch(&s, &h).gradient.unwrap();
assert_eq!((g.name.as_str(), g.centered), ("LINEAR", true));
// An unknown name is refused and adds nothing.
let n = count(&s);
assert!(s.execute("gradient", &json!({ "points": [[5, 5]], "name": "RAINBOW" })).is_err());
assert_eq!(count(&s), n);
}
#[test]
fn hatch_takes_points_and_objects_in_one_call() {
let mut s = Session::new();
rect(&mut s, [0.0, 0.0], [10.0, 10.0]);
s.execute("circle", &json!({ "center": [30, 5], "radius": 3 })).unwrap();
let c = last(&s);
let depth = undo_depth(&s);
s.execute("hatch", &json!({ "points": [[5, 5]], "handles": [c], "pattern": "SOLID" })).unwrap();
let hs = hatches(&s);
assert_eq!(hs.len(), 1, "one hatch for both picks");
assert_eq!(hs[0].1.loops.len(), 2);
assert_eq!(undo_depth(&s), depth + 1, "one undo step");
// Separate: one hatch per point and one per object.
s.execute("hatch", &json!({ "points": [[5, 5]], "handles": [c], "separate": true })).unwrap();
assert_eq!(hatches(&s).len(), 3);
s.undo().unwrap();
assert_eq!(hatches(&s).len(), 1, "one undo removes both");
// Points alone still ignore the selection.
s.set_selection(vec![Handle::parse_hex(&c).unwrap()]);
let h = handle(&s.execute("hatch", &json!({ "points": [[5, 5]] })).unwrap());
assert_eq!(hatch(&s, &h).loops.len(), 1);
}
use cadcraft_engine::doc::color::{Color, Rgb};
#[test]
fn hatchedit_turns_a_pattern_into_a_gradient_and_back() {
let mut s = Session::new();
rect(&mut s, [0.0, 0.0], [10.0, 10.0]);
let h = handle(&s.execute("hatch", &json!({ "points": [[5, 5]], "pattern": "ANSI31" })).unwrap());
let depth = undo_depth(&s);
s.execute(
"hatchedit",
&json!({ "handles": [h], "gradient": { "name": "cylinder", "color1": "red", "color2": "255,128,0", "angle": 30, "centered": false } }),
)
.unwrap();
let x = hatch(&s, &h);
let g = x.gradient.clone().unwrap();
assert_eq!((g.name.as_str(), g.color1, g.color2, g.centered), ("CYLINDER", Color::Index(1), Color::True(Rgb(255, 128, 0)), false));
assert!((g.angle - 30f64.to_radians()).abs() < 1e-12);
assert!(x.solid);
assert_eq!(x.pattern, "SOLID");
assert_eq!(undo_depth(&s), depth + 1, "one undo step");
// Only the fields given change.
s.execute("hatchedit", &json!({ "handles": [h], "gradient": { "color2": "3" } })).unwrap();
let g = hatch(&s, &h).gradient.unwrap();
assert_eq!((g.name.as_str(), g.color1, g.color2), ("CYLINDER", Color::Index(1), Color::Index(3)));
// A pattern turns it back.
s.execute("hatchedit", &json!({ "handles": [h], "pattern": "ANSI31" })).unwrap();
let x = hatch(&s, &h);
assert!(x.gradient.is_none());
assert!(!x.solid);
s.undo().unwrap();
assert!(hatch(&s, &h).gradient.is_some());
}
#[test]
fn hatchedit_refuses_a_bad_gradient_and_changes_nothing() {
let mut s = Session::new();
rect(&mut s, [0.0, 0.0], [10.0, 10.0]);
let h = handle(&s.execute("hatch", &json!({ "points": [[5, 5]], "pattern": "ANSI31" })).unwrap());
let before = hatch(&s, &h);
for bad in [
json!("x"),
json!({ "name": "RAINBOW" }),
json!({ "name": 5 }),
json!({ "color1": "nope" }),
json!({ "angle": "x" }),
json!({ "centered": 1 }),
] {
assert!(s.execute("hatchedit", &json!({ "handles": [h], "gradient": bad })).is_err(), "{bad}");
assert_eq!(hatch(&s, &h), before, "{bad}");
}
assert!(s.execute("hatchedit", &json!({ "handles": [h], "pattern": "ANSI31", "gradient": { "name": "LINEAR" } })).is_err());
assert_eq!(hatch(&s, &h), before);
}
use cadcraft_engine::geom::Vec2;
use cadcraft_engine::{Input, PickMode};
fn h(hex: &str) -> Handle {
Handle::parse_hex(hex).unwrap()
}
#[test]
fn hatchpick_keeps_points_inside_boundaries_and_changes_nothing() {
let mut s = Session::new();
rect(&mut s, [0.0, 0.0], [10.0, 10.0]);
let (n, depth) = (count(&s), undo_depth(&s));
s.start("hatchpick").unwrap();
s.input(Input::Keyword("Points".into())).unwrap();
assert!(s.prompt_text().contains("Pick internal point"), "{}", s.prompt_text());
s.input(Input::Point(Vec2::new(5.0, 5.0))).unwrap();
assert!(!s.preview(Vec2::new(6.0, 6.0)).is_empty(), "the found boundary is previewed");
// Outside every boundary: reported, not kept.
s.input(Input::Point(Vec2::new(50.0, 50.0))).unwrap();
assert!(s.log.iter().any(|l| l.contains("Valid hatch boundary not found")), "{:?}", s.log);
s.input(Input::Point(Vec2::new(2.0, 2.0))).unwrap();
s.input(Input::Keyword("Undo".into())).unwrap();
s.input(Input::Enter).unwrap();
assert!(s.running.is_none());
let p = s.hatch_picks.clone().unwrap();
assert_eq!(p.mode, PickMode::Points);
assert_eq!(p.points, vec![Vec2::new(5.0, 5.0)]);
assert_eq!((count(&s), undo_depth(&s)), (n, depth), "nothing added, no undo step");
}
#[test]
fn hatchpick_keeps_closed_objects_only() {
let mut s = Session::new();
let r = rect(&mut s, [0.0, 0.0], [10.0, 10.0]);
s.execute("line", &json!({ "points": [[20, 0], [30, 0]] })).unwrap();
let l = last(&s);
s.start("hatchpick").unwrap();
s.input(Input::Keyword("Objects".into())).unwrap();
s.input(Input::Pick(vec![h(&r), h(&l)])).unwrap();
s.input(Input::Enter).unwrap();
let p = s.hatch_picks.clone().unwrap();
assert_eq!((p.mode, p.handles), (PickMode::Objects, vec![h(&r)]));
}
#[test]
fn hatchpick_origin_and_hatch_modes() {
let mut s = Session::new();
let r = rect(&mut s, [0.0, 0.0], [10.0, 10.0]);
let hx = handle(&s.execute("hatch", &json!({ "points": [[5, 5]] })).unwrap());
s.start("hatchpick").unwrap();
s.input(Input::Keyword("oRigin".into())).unwrap();
s.input(Input::Point(Vec2::new(1.0, 2.0))).unwrap();
assert!(s.running.is_none());
assert_eq!(s.hatch_picks.clone().unwrap().origin, Some(Vec2::new(1.0, 2.0)));
s.hatch_picks = None;
s.start("hatchpick").unwrap();
s.input(Input::Keyword("Hatch".into())).unwrap();
// Not a hatch: asked again.
s.input(Input::Pick(vec![h(&r)])).unwrap();
assert!(s.running.is_some());
s.input(Input::Pick(vec![h(&r), h(&hx)])).unwrap();
assert!(s.running.is_none());
let p = s.hatch_picks.clone().unwrap();
assert_eq!((p.mode, p.handles), (PickMode::Hatch, vec![h(&hx)]));
}
#[test]
fn hatchpick_typed_mode_and_escape_keeps_nothing() {
let mut s = Session::new();
rect(&mut s, [0.0, 0.0], [10.0, 10.0]);
s.cmdline("hatchpick points").unwrap();
assert!(s.prompt_text().contains("Pick internal point"), "{}", s.prompt_text());
s.input(Input::Point(Vec2::new(5.0, 5.0))).unwrap();
s.cancel();
assert!(s.running.is_none());
assert!(s.hatch_picks.is_none());
}
#[test]
fn hatchpick_json_form_validates_like_the_prompts() {
let mut s = Session::new();
let r = rect(&mut s, [0.0, 0.0], [10.0, 10.0]);
s.execute("line", &json!({ "points": [[20, 0], [30, 0]] })).unwrap();
let l = last(&s);
s.execute("hatchpick", &json!({ "points": [[5, 5]], "handles": [r] })).unwrap();
let p = s.hatch_picks.clone().unwrap();
assert_eq!((p.points, p.handles), (vec![Vec2::new(5.0, 5.0)], vec![h(&r)]));
let too_many: Vec<[f64; 2]> = vec![[5.0, 5.0]; 1001];
for bad in [
json!({}),
json!({ "points": [[50, 50]] }),
json!({ "points": [["a"]] }),
json!({ "handles": [l] }),
json!({ "handles": ["zz"] }),
json!({ "handles": "x" }),
json!({ "points": too_many }),
] {
s.hatch_picks = None;
assert!(s.execute("hatchpick", &bad).is_err(), "{bad}");
assert!(s.hatch_picks.is_none(), "{bad}");
}
}
#[test]
fn hpdlgmode_defaults_to_1_and_takes_0_or_1() {
let mut s = Session::new();
assert_eq!(cadcraft_engine::sysvars::get(&s, "HPDLGMODE"), Some(json!(1)));
s.execute("setvar", &json!({ "name": "HPDLGMODE", "value": 0 })).unwrap();
assert_eq!(cadcraft_engine::sysvars::get(&s, "HPDLGMODE"), Some(json!(0)));
assert!(s.execute("setvar", &json!({ "name": "HPDLGMODE", "value": 2 })).is_err());
}
@@ -0,0 +1,86 @@
//! HATCHEDIT refuses an explicit bad `scale` or `angle` before changing anything: no partial
//! edit, no undo step, redo history kept.
use cadcraft_engine::Session;
use cadcraft_engine::doc::EntityKind;
use serde_json::{Value, json};
fn fixture() -> (Session, String) {
let mut s = Session::new();
s.execute("rectang", &json!({"p1": [0, 0], "p2": [10, 10]})).unwrap();
let handle = s.execute("hatch", &json!({"points": [[5, 5]]})).unwrap()["handle"].as_str().unwrap().to_owned();
// One redo entry, which a rejected edit must leave alone.
s.execute("circle", &json!({"center": [20, 20], "radius": 1})).unwrap();
s.undo().unwrap();
assert_eq!(s.state().unwrap().redo.len(), 1);
(s, handle)
}
fn hatch_props(s: &Session) -> (f64, f64) {
let hatches: Vec<(f64, f64)> = s
.doc()
.unwrap()
.model
.iter()
.filter_map(|e| if let EntityKind::Hatch(h) = &e.kind { Some((h.scale, h.angle.to_degrees())) } else { None })
.collect();
assert_eq!(hatches.len(), 1);
hatches[0]
}
fn assert_props(s: &Session, scale: f64, angle: f64) {
let (sc, an) = hatch_props(s);
assert!((sc - scale).abs() < 1e-9 && (an - angle).abs() < 1e-9, "scale {sc}, angle {an}");
}
fn edit(s: &mut Session, handle: &str, extra: Value) -> cadcraft_engine::Result<Value> {
let mut params = json!({ "handles": [handle] });
for (k, v) in extra.as_object().unwrap() {
params[k] = v.clone();
}
s.execute("hatchedit", &params)
}
#[test]
fn bad_scale_or_angle_is_refused_and_changes_nothing() {
let cases = [
(json!({"scale": 0}), "scale"),
(json!({"scale": -1}), "scale"),
(json!({"scale": "x"}), "scale"),
(json!({"angle": "x"}), "angle"),
(json!({"scale": 0, "angle": 45}), "scale"),
(json!({"scale": 2, "angle": "x"}), "angle"),
(json!({"scale": 0, "recreate": "polyline"}), "scale"),
(json!({"angle": "x", "recreate": "polyline"}), "angle"),
];
for (extra, key) in cases {
let (mut s, handle) = fixture();
let before = s.doc().unwrap().clone();
let (undo, redo, revision) = {
let st = s.state().unwrap();
(st.undo.len(), st.redo.len(), st.revision)
};
let err = edit(&mut s, &handle, extra.clone()).expect_err(&format!("{extra} should be refused"));
assert!(err.to_string().contains(&format!("`{key}`")), "{extra}: {err}");
assert_eq!(s.doc().unwrap(), &before, "{extra}: the drawing is untouched");
let st = s.state().unwrap();
assert_eq!((st.undo.len(), st.redo.len(), st.revision), (undo, redo, revision), "{extra}: history is untouched");
}
}
#[test]
fn valid_omitted_and_null_values_still_edit() {
let (mut s, handle) = fixture();
edit(&mut s, &handle, json!({"scale": 2, "angle": 45})).unwrap();
assert_props(&s, 2.0, 45.0);
// Omitted or null numeric fields keep what the hatch has while another property changes.
edit(&mut s, &handle, json!({"style": "outer"})).unwrap();
assert_props(&s, 2.0, 45.0);
edit(&mut s, &handle, json!({"scale": null, "angle": null, "style": "normal"})).unwrap();
assert_props(&s, 2.0, 45.0);
// Zero and negative angles are angles.
edit(&mut s, &handle, json!({"angle": 0})).unwrap();
assert_props(&s, 2.0, 0.0);
edit(&mut s, &handle, json!({"angle": -30})).unwrap();
assert_props(&s, 2.0, -30.0);
}
+1 -1
View File
@@ -42,7 +42,7 @@ fn on_ellipse(e: &cadcraft_doc::Ellipse, p: Vec2) -> bool {
}
fn cursor(s: &mut Session, raw: Vec2, base: Option<Vec2>) -> Vec2 {
s.snap_cursor(raw, &CursorQuery { base, deferred: false, aperture: 1e-6, tol: 1e-6, now: 0.0, shift: false }).point
s.snap_cursor(raw, &CursorQuery { base, deferred: false, corner: false, aperture: 1e-6, tol: 1e-6, now: 0.0, shift: false }).point
}
#[test]
+239
View File
@@ -0,0 +1,239 @@
//! The AutoLISP conformance corpus (#648): each case's routine runs in a new drawing and must
//! leave exactly the expected entities, layers and printed text. The corpus lives in its own
//! repository; point `CADCRAFT_LISP_CORPUS` at its `cases` directory (or the repository) to run
//! it. Without it the test only says so.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::path::{Path, PathBuf};
use cadcraft_engine::Session;
use cadcraft_engine::cmd::file::{EntityCodec, set_entity_codec};
use cadcraft_engine::doc::Entity;
use serde_json::{Value, json};
fn cases_dir() -> Option<PathBuf> {
let root = PathBuf::from(std::env::var_os("CADCRAFT_LISP_CORPUS")?);
let cases = root.join("cases");
Some(if cases.is_dir() { cases } else { root })
}
/// An entity's DXF groups as (code, value) pairs.
fn groups(d: &cadcraft_engine::doc::Drawing, e: &Entity) -> (String, Vec<(i32, Value)>) {
let (kind, tags) = cadcraft_io::entity_groups(d, e);
let v = tags
.into_iter()
.map(|t| {
let v = match t.value {
cadcraft_dxf::Value::Str(s) | cadcraft_dxf::Value::Hex(s) => json!(s),
cadcraft_dxf::Value::Real(r) => json!(r),
cadcraft_dxf::Value::Int(i) => json!(i),
cadcraft_dxf::Value::Bool(b) => json!(i32::from(b)),
};
(t.code, v)
})
.collect();
(kind, v)
}
fn num(g: &[(i32, Value)], code: i32) -> Option<f64> {
g.iter().find(|(c, _)| *c == code).and_then(|(_, v)| v.as_f64())
}
fn point(g: &[(i32, Value)], code: i32) -> Value {
json!([num(g, code).unwrap_or(0.0), num(g, code + 10).unwrap_or(0.0), num(g, code + 20).unwrap_or(0.0)])
}
/// The fields the corpus checks, read from an entity's groups.
fn actual(d: &cadcraft_engine::doc::Drawing, e: &Entity) -> Value {
let (kind, g) = groups(d, e);
let s = |code: i32| g.iter().find(|(c, _)| *c == code).and_then(|(_, v)| v.as_str().map(str::to_string)).unwrap_or_default();
let mut o = json!({ "type": kind, "layer": s(8) });
let m = o.as_object_mut().unwrap();
match kind.as_str() {
"LINE" => {
m.insert("start".into(), point(&g, 10));
m.insert("end".into(), point(&g, 11));
}
"CIRCLE" | "ARC" => {
m.insert("center".into(), point(&g, 10));
m.insert("radius".into(), json!(num(&g, 40).unwrap_or(0.0)));
if kind == "ARC" {
m.insert("start_angle".into(), json!(num(&g, 50).unwrap_or(0.0)));
m.insert("end_angle".into(), json!(num(&g, 51).unwrap_or(0.0)));
}
}
"POINT" => {
m.insert("location".into(), point(&g, 10));
}
"LWPOLYLINE" => {
let xs: Vec<f64> = g.iter().filter(|(c, _)| *c == 10).filter_map(|(_, v)| v.as_f64()).collect();
let ys: Vec<f64> = g.iter().filter(|(c, _)| *c == 20).filter_map(|(_, v)| v.as_f64()).collect();
let verts: Vec<Value> = xs.iter().zip(&ys).map(|(x, y)| json!([x, y])).collect();
m.insert("closed".into(), json!(num(&g, 70).unwrap_or(0.0) as i64 & 1 == 1));
m.insert("vertices".into(), json!(verts));
}
"TEXT" => {
m.insert("insert".into(), point(&g, 10));
m.insert("height".into(), json!(num(&g, 40).unwrap_or(0.0)));
m.insert("rotation".into(), json!(num(&g, 50).unwrap_or(0.0)));
m.insert("text".into(), json!(s(1)));
}
"DIMENSION" => {
let k = num(&g, 70).unwrap_or(0.0) as i64 & 7;
m.insert(
"kind".into(),
json!(if k == 0 {
"linear"
} else if k == 1 {
"aligned"
} else {
"other"
}),
);
m.insert("xline1".into(), point(&g, 13));
m.insert("xline2".into(), point(&g, 14));
m.insert("rotation".into(), json!(num(&g, 50).unwrap_or(0.0)));
m.insert("measurement".into(), json!(num(&g, 42).unwrap_or(0.0)));
}
_ => {}
}
o
}
fn close(a: &Value, b: &Value, tol: f64) -> bool {
match (a, b) {
(Value::Number(x), Value::Number(y)) => (x.as_f64().unwrap_or(f64::NAN) - y.as_f64().unwrap_or(f64::NAN)).abs() <= tol,
(Value::Array(x), Value::Array(y)) => {
// A 2D point compares with z = 0.
let pad = |v: &Vec<Value>| -> Vec<Value> {
let mut v = v.clone();
if v.len() == 2 && v.iter().all(Value::is_number) {
v.push(json!(0.0));
}
v
};
let (x, y) = (pad(x), pad(y));
x.len() == y.len() && x.iter().zip(&y).all(|(p, q)| close(p, q, tol))
}
_ => a == b,
}
}
/// Closed polylines match as the same cyclic sequence in either direction.
fn same_ring(have: &[Value], want: &[Value], tol: f64) -> bool {
let n = want.len();
if have.len() != n {
return false;
}
if n == 0 {
return true;
}
for start in 0..n {
for dir in [1isize, -1] {
let ok = (0..n).all(|i| {
let j = (start as isize + dir * i as isize).rem_euclid(n as isize) as usize;
close(&have[j], &want[i], tol)
});
if ok {
return true;
}
}
}
false
}
fn matches(have: &Value, want: &Value, tol: f64) -> bool {
let Some(w) = want.as_object() else { return false };
w.iter().all(|(k, v)| {
let h = have.get(k).unwrap_or(&Value::Null);
if k == "vertices" && have.get("closed") == Some(&json!(true)) {
return match (h, v) {
(Value::Array(h), Value::Array(v)) => same_ring(h, v, tol),
_ => false,
};
}
close(h, v, tol)
})
}
fn run_case(dir: &Path) -> Result<(), String> {
let routine = std::fs::read_to_string(dir.join("routine.lsp")).map_err(|e| e.to_string())?;
let expected: Value =
serde_json::from_str(&std::fs::read_to_string(dir.join("expected.json")).map_err(|e| e.to_string())?).map_err(|e| e.to_string())?;
let mut s = Session::new();
if let Some(vars) = expected["preconditions"]["sysvars"].as_object() {
for (k, v) in vars {
cadcraft_engine::sysvars::set(&mut s, k, v).map_err(|e| format!("setvar {k}: {e}"))?;
}
}
let from = s.log.len();
match s.lisp_load("routine.lsp", &routine, true) {
Ok(Some(Err(e))) => return Err(format!("LISP error: {e}\n{:#?}", &s.log[from..])),
Err(e) => return Err(e.to_string()),
_ => {}
}
let exp = &expected["expect"];
let tol = exp["tolerance"].as_f64().unwrap_or(1e-9);
let d = s.doc().map_err(|e| e.to_string())?;
let mut have: Vec<Value> = d.model.iter().map(|e| actual(d, e)).collect();
let want = exp["entities"].as_array().cloned().unwrap_or_default();
let mut missing = Vec::new();
for w in &want {
match have.iter().position(|h| matches(h, w, tol)) {
Some(i) => {
have.remove(i);
}
None => missing.push(w.clone()),
}
}
if !missing.is_empty() || !have.is_empty() {
return Err(format!(
"missing {}:\n{}\nunexpected {}:\n{}\nlog:\n{}",
missing.len(),
serde_json::to_string_pretty(&missing).unwrap_or_default(),
have.len(),
serde_json::to_string_pretty(&have).unwrap_or_default(),
s.log[from..].join("\n")
));
}
for l in exp["layers"].as_array().cloned().unwrap_or_default() {
let name = l["name"].as_str().unwrap_or("");
let layer = d.layer(name).ok_or_else(|| format!("layer {name} missing"))?;
if let Some(c) = l["color"].as_i64() {
let have = match layer.color {
cadcraft_color::Color::Index(i) => i64::from(i),
_ => -1,
};
if have != c {
return Err(format!("layer {name}: colour {have}, expected {c}"));
}
}
}
if let Some(p) = exp["printed"].as_str() {
let out = s.log[from..].join("\n");
if !out.contains(p.trim_start_matches('\n')) {
return Err(format!("printed {p:?} not found in:\n{out}"));
}
}
Ok(())
}
#[test]
fn autolisp_corpus() {
set_entity_codec(EntityCodec { groups: cadcraft_io::entity_groups, entity: cadcraft_io::entity_from_groups });
let Some(dir) = cases_dir() else {
eprintln!("CADCRAFT_LISP_CORPUS is not set: the AutoLISP corpus is skipped");
return;
};
let mut cases: Vec<PathBuf> =
std::fs::read_dir(&dir).unwrap().filter_map(|e| e.ok().map(|e| e.path())).filter(|p| p.join("routine.lsp").is_file()).collect();
cases.sort();
assert!(!cases.is_empty(), "no cases in {}", dir.display());
let mut failed = Vec::new();
for c in &cases {
if let Err(e) = run_case(c) {
failed.push(format!("== {}\n{e}", c.file_name().and_then(|n| n.to_str()).unwrap_or("?")));
}
}
assert!(failed.is_empty(), "{} of {} cases failed:\n{}", failed.len(), cases.len(), failed.join("\n\n"));
}
+41
View File
@@ -197,6 +197,27 @@ fn lengthen_open_splines() {
assert!(s.execute("lengthen", &json!({ "handle": sp, "pick": [9, 0], "delta": -1e9 })).is_err());
}
#[test]
fn shortening_a_tightly_bent_spline_reaches_the_requested_length() {
// #705: a fixed quadrature left this cubic 0.137 longer than asked, whatever the mode.
let control = json!([[2.164, -9.484], [8.964, -13.012], [-16.428, -1.276], [18.58, -18.08]]);
let half = 26.708_124_545_291_4 / 2.0;
for mode in [json!({ "total": half }), json!({ "delta": -half }), json!({ "percent": 50 })] {
let mut s = Session::new();
let sp = h(&s.execute("spline", &json!({ "degree": 3, "control": control })).unwrap());
let before = spline(&s, &sp);
assert!((length(&mut s, &sp) - 2.0 * half).abs() < 1e-6, "{mode}");
let mut q = mode.clone();
q["handle"] = json!(sp);
s.execute("lengthen", &q).unwrap();
let got = length(&mut s, &sp);
assert!((got - half).abs() < 1e-6, "{mode}: {got}");
let now = spline(&s, &sp);
assert!(ends(&now).0.near(ends(&before).0, 1e-9), "{mode}");
assert!(dist_to(&before, ends(&now).1) < 1e-9, "{mode}: the new end lies on the old curve");
}
}
#[test]
fn lengthen_at_the_command_line_on_an_elliptical_arc() {
let mut s = Session::new();
@@ -241,6 +262,26 @@ fn join_merges_elliptical_arcs_on_one_ellipse() {
assert_eq!(count(&s), 1);
}
#[test]
fn join_names_an_elliptical_arc_twice_without_losing_it() {
// The same arc twice is one object: nothing to join, and the arc stays.
let mut s = Session::new();
let a = arc_on_ellipse(&mut s, 0.0, 90.0);
let before = ellipse(&s, &a);
assert!(s.execute("join", &json!({ "handles": [a, a] })).is_err());
assert_eq!(count(&s), 1);
assert_eq!(ellipse(&s, &a), before);
// Repeating the source next to a second arc still merges the two into the source.
let mut s = Session::new();
let a = arc_on_ellipse(&mut s, 0.0, 90.0);
let b = arc_on_ellipse(&mut s, 90.0, 180.0);
let r = s.execute("join", &json!({ "handles": [a, a, b] })).unwrap();
assert_eq!(h(&r), a);
let m = ellipse(&s, &a);
assert!(m.start.abs() < 1e-9 && (m.end - PI).abs() < 1e-9, "{m:?}");
assert_eq!(count(&s), 1);
}
#[test]
fn join_makes_one_spline_from_lines_arcs_polylines_and_splines() {
let mut s = Session::new();
+48 -2
View File
@@ -1,7 +1,7 @@
//! MTEXT columns and paragraph formatting from the command line, scripts and the JSON form
//! (#532).
use cadcraft_doc::{EntityKind, MText, MTextColumnType};
use cadcraft_doc::{EntityKind, MLeader, MText, MTextColumnType, MTextColumns};
use cadcraft_engine::Session;
use serde_json::json;
@@ -46,7 +46,7 @@ fn dynamic_columns_and_defaults() {
#[test]
fn json_columns_and_lists() {
let mut s = Session::new();
s.execute("mtext", &json!({"at": [0, 0], "text": "one\ntwo\nthree", "width": 5, "list": "number", "columns": {"type": "static", "count": 2, "gutter": 1, "height": 2}}))
s.execute("mtext", &json!({"at": [0, 0], "text": "one\ntwo\nthree", "height": 1, "width": 5, "list": "number", "columns": {"type": "static", "count": 2, "gutter": 1, "height": 2}}))
.unwrap();
let t = last_mtext(&s);
assert_eq!(t.contents, "\\pxi-3,l3,t3;1.\tone\\P2.\ttwo\\P3.\tthree");
@@ -79,3 +79,49 @@ fn columns_shape_the_drawn_text_and_its_extents() {
let b = cadcraft_doc::entity_bounds(d, e, 0);
assert!((b.width() - 34.0).abs() < 1e-9 && (b.height() - 6.0).abs() < 1e-9, "{b:?}");
}
#[test]
fn scale_scales_multileader_text_width_and_columns() {
// #709: SCALE 2 on a multileader's text doubles its width and column lengths like it does
// for a standalone MTEXT, not only its height.
let text = MText {
insert: Default::default(),
height: 2.0,
width: 10.0,
attach: 1,
rotation: 0.0,
style: "Standard".into(),
contents: "hello world".into(),
line_spacing: 1.0,
line_spacing_exact: false,
columns: Some(MTextColumns {
kind: MTextColumnType::Static,
count: 2,
gutter: 1.0,
height: 5.0,
auto_height: true,
heights: vec![3.0, 4.0],
flow_reversed: false,
}),
};
let mut s = Session::new();
let ml = MLeader {
leaders: Vec::new(),
landing: Default::default(),
dogleg: 1.0,
text: Some(text),
style: "Standard".into(),
arrow_size: 1.0,
spline: false,
block: None,
};
let h = s.add_entity(EntityKind::MLeader(ml)).unwrap();
s.execute("scale", &json!({ "handles": [h.hex()], "base": [0, 0], "factor": 2 })).unwrap();
let t = match &s.doc().unwrap().entity(h).unwrap().kind {
EntityKind::MLeader(m) => m.text.clone().unwrap(),
k => panic!("{k:?}"),
};
let c = t.columns.unwrap();
assert_eq!((t.height, t.width), (4.0, 20.0));
assert_eq!((c.kind, c.count, c.gutter, c.height, c.heights), (MTextColumnType::Static, 2, 2.0, 10.0, vec![6.0, 8.0]));
}
Loaded 100 of 241 files, more files were not shown because too many files have changed in this diff. Show more