Author SHA1 Message Date
Dylan M. Taylor fdcd9610f2 security: enforce HTTPS on curl downloads (SonarCloud S6506)
Add --proto '=https' to curl invocations so redirects cannot downgrade to
plaintext HTTP. Resolves SonarCloud S6506 (shell/docker/githubactions).
2026-10-21 17:06:31 -04:00
Dylan M. Taylor 54d96834d5 ci: add sonar-project.properties for CI-based analysis
Marks the project ineligible for SonarCloud Automatic Analysis, resolving the
CI-vs-automatic-analysis conflict so the SonarCloud workflow succeeds.
2026-10-21 16:00:22 -04:00
Dylan M. Taylor 47a8fb02e7 ci: add SonarCloud scan via shared reusable workflow
Calls storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
with secrets: inherit. Adds SonarCloud badge to README where missing and
removes the redundant sonar-project.properties (config now passed by the
reusable workflow).
2026-10-21 15:46:46 -04:00
Dylan M. Taylor 896a390b42 ci: add SonarCloud scan workflow and config 2026-10-21 13:57:30 -04:00
Dylan M. Taylor 345eb60ef3 test 2026-10-21 13:57:10 -04:00
Dylan M. Taylor 0cf3bdea83 Merge pull request #1398 from dylanmtaylor/ci/shared-nix-workflow
ci: switch to shared nix workflow from craft-common-actions
2026-10-21 03:40:18 -04:00
Brandon ThomasandClaude Opus 5.5 7fef8cbe2f Skip CodeRabbit reviews on Dependabot PRs (#1727)
Adds .coderabbit.yaml so CodeRabbit no longer reviews or comments on PRs opened by dependabot[bot].

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-20 11:39:39 -04:00
Dylan M. Taylor f2b473dc51 docs: add SonarCloud and CodeRabbit badges to README (#1566)
* docs: add SonarCloud and CodeRabbit badges to README

* docs: put SonarCloud and CodeRabbit badges on their own line
2026-10-20 11:32:50 -04:00
Brandon ThomasandClaude Opus 5.5 e86eacf094 Release 0.10.0
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 22:18:26 -04:00
Brandon ThomasandClaude Opus 5.5 0b32c2e6a1 i18n(hu): the ViewCube pill's key is now WCS (after #1558)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 03:02:53 -04:00
323f4629eb Add Greek (el) interface localization (#1307)
Co-authored-by: Camthalion77 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-19 03:02:49 -04:00
c02027abc8 Bound CAL unary minus/plus and ^ chain recursion (#1586)
Found while testing crates/engine/src/cmd/utility/calc.rs:124-140 (Parser::unary recursion, MAX_DEPTH only counted in expr() and '[')

Co-authored-by: Claude Haiku 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-19 02:57:52 -04:00
8c249aa722 Menu bar switches to the hovered menu once one is open (fixes #1567) (#1571)
With a menu of the in-window bar open, pointing at another title now
closes the open menu and opens that one. With no menu open, pointing
still opens nothing. Clicking the open title, clicking elsewhere, Esc
or choosing an item close the menu as before.

The bar now draws its titles first, then opens the hovered title's menu
if another bar menu is open, then draws the menus. egui 0.36's MenuBar
has no hover switching of its own.

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-19 02:57:40 -04:00
a0aa0863bf Turkish interface (Türkçe) (#1529)
Add a Turkish catalog (tr.tsv, 1,330 rows): the union of the existing
catalogs, four menu headings (Ölçülendirme for the Dimension menu, Ölçü for
the object) and single-form counts, since a Turkish noun stays singular after
a number. No marked UI literal falls back to English. Prompts read "… girin"
for Enter and "… belirtin" for Specify; clickable keywords (Top, Zero, Bottom,
_tan to, nX/nXP) keep the spelling the engine accepts.

Register the language (Preference::Turkish, `tr`, Türkçe) and add locale,
preference, count, menu-heading, live-switch, dialog and font tests. Docs,
README and ATTRIBUTION list it; the translation is AI-assisted, the running
app was reviewed by a native speaker, and line-by-line proofreading is still
to do.

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-19 02:57:28 -04:00
b4509d2368 DWG: leave out orphaned DGN line style components, so DGN-bloated drawings open (fixes #1482) (#1501)
* DWG: leave out orphaned DGN line style components, so DGN-bloated drawings open (fixes #1482)

A DWG that has been through MicroStation can carry hundreds of thousands of
DGN line style components no line style definition uses. In an 82.8 MB site
plan they were 888,483 of 889,053 and made the DXF rendition 1.2 GB, over the
1 GB limit, so the file didn't open. After the failsafe read, components no
LSDEFINITION reaches (through compound and point components) are left out with
their dictionary entries, and a read note says how many.

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

* contributors: add my real name for the About window credits

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-19 02:57:16 -04:00
13a3ebc0d1 AREA: an ellipse's perimeter is its exact length, not a closed-form estimate (#1471)
AREA's Object option reported a full ellipse's perimeter with
Ramanujan's closed-form estimate, which falls short as the ellipse
flattens: 46.0256 for a 10 x 4 ellipse (exactly 46.0262), 39.8741 for a
10 x 0.1 one (exactly 40.0110, 0.34% short). It now integrates the
perimeter with the geometry crate's ellipse arc length, the one LENGTHEN
measures with, so both report the same length for the same ellipse.

Ellipse::arc_length_from halves a panel where it needs it (the same
adaptive Gauss-Legendre as spline arc lengths): with fixed panels a
100:1 ellipse came out 4e-8 long, as it turns sharply at its ends.

Tests check four ellipses (round to 100:1) against the
arithmetic-geometric mean to 1e-9, and the printed AREA line.

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-19 02:57:04 -04:00
41e0860725 ALIASEDIT opens the Command Aliases dialog (#1381) (#1391)
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-19 02:56:52 -04:00
0d23cc3cb3 MOCORO: move, copy, rotate and scale a selection from one base point until eXit (#1388)
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-19 02:56:40 -04:00
81acc9b8e4 Print signed two-digit exponent for scientific units (LUNITS 1) (#1386)
Found while testing crates/engine/src/units.rs:13

Co-authored-by: Claude Haiku 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-19 02:56:28 -04:00
397b8e156a MLEDIT Merged Tee: the first multiline's inner elements run on to the second's (#1375)
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-19 02:56:16 -04:00
12567f14f2 BURST: explode block references with their attribute values as text (#1345)
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-19 02:56:04 -04:00
95e86caea5 TXT2MTXT: selected TEXT combined into one MTEXT top-down, or one MTEXT per TEXT, with SEttings (#1343)
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-19 02:55:53 -04:00
7adf8d89cd BREAKLINE: a polyline with a break symbol between two points, with Block, Size and Extension (#1340)
Tools > Express Tools > Break-line Symbol draws one polyline from the first point to the second with a zigzag break symbol at a picked location (the midpoint on Enter), as AutoCAD's Express Tool does. Size scales the symbol (times DIMSCALE), Extension runs the line past both points, and Block takes a block of the drawing whose two points on the Defpoints layer mark where the line meets the symbol. The built-in symbol is our own geometry. One undo step; the options are remembered for the session.

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-19 02:55:41 -04:00
006f71f650 Keep Z and elevation of the pieces left by BREAK (#1270)
Found while testing crates/engine/src/cmd/modify.rs:1457 (break_entity; helpers `line()`/`arc()`/`lwpoly()` in crates/engine/src/cmd/helpers.rs:10-23)

Co-authored-by: Claude Haiku 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-19 02:55:28 -04:00
b0ed57f1c7 i18n(sv): translate the 95 strings added since the Swedish catalog (#1258)
Covers the Hatch and Gradient dialog, the Block Editor, REFEDIT, multileader
editing, the Options selection settings, the MTEXT editor and recent files.
The Swedish catalog now has every key of the Ukrainian reference catalog.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-19 02:55:14 -04:00
30db7296c4 선택 객체 조회 필터와 MCP 연동 추가 (#1253)
## 변경 내용
- entities 명령에 selected 불리언 필터를 추가해 현재 공간의 선택 객체만 조회
- 기존 종류·레이어·영역 필터와 결합하고 필터 적용 후 페이지 처리
- MCP query_entities 스키마와 명령·제어 프로토콜 문서에 옵션 공개
- 빈 선택, 중복·오래된 핸들, 다른 공간 객체, 잘못된 입력 및 조회 전후 상태 보존 회귀 테스트 추가

## 검증
- cargo xtask ci 6단계 통과: fmt, clippy, 테스트 2,012개, assets, layers, wasm
- 별도 .NET 클라이언트와 실제 헤드리스 엔진의 localhost 통합 검증 통과
- 실제 데스크톱 UI 조작 및 고객 DWG 왕복 검증은 수행하지 않음

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-19 02:55:02 -04:00
a76775bf2b Holding Shift overrides ortho while it is held, as in AutoCAD (#1198)
At a point prompt with a base point, holding Shift turns ortho on when
ORTHOMODE is off and off when it is on; releasing it restores the setting.
ORTHOMODE itself is never changed. While the override turns ortho on, polar
tracking is off, as with ortho on.

The UI already passed the Shift state to Session::snap_cursor (for POLARMODE
bit 8's Shift to acquire), so the change is in the engine only: snap_cursor
applies the override to its copy of the settings. Window corners (ZOOM
Window) and prompts without a base point are unaffected.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-19 02:54:51 -04:00
6576cac1a3 JOIN with a single arc or elliptical arc offers cLose: the arc becomes its circle, the elliptical arc its full ellipse (fixes #1175) (#1197)
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-19 02:54:39 -04:00
4125b351cd Add HATCHEDIT boundary add and remove (#1156)
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-19 02:54:28 -04:00
6d15a40f43 Selection cycling: a click over overlapping objects lists them, Shift+Space steps through them (#1149)
* Selection cycling: a click over overlapping objects lists them, Shift+Space steps through them (fixes #1143, fixes #1145)

* canvas: keep viewport_content's doc comment on it

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-19 02:54:16 -04:00
5f390e6e6a Constraints: the settings checkboxes show or hide all, dropping per-object exceptions (part of #1134) (#1144)
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-19 02:54:03 -04:00
00f521858a Dynamic blocks: a grip drag previews the reference in its own colours (#1124)
* Dynamic blocks: a grip drag previews the reference in its own colours

The preview of a dynamic grip drag is the reference re-evaluated at the
cursor. It was drawn in the preview colour, fills left out, so on a filled
label the text was white on white. Commands can now give preview objects
with their own properties (`Interactive::preview_objects`, drawn under the
rubber band); the grip drag gives the block's objects with the properties
they draw with in the reference (layer 0 on its layer, ByBlock its
colour, linetype, lineweight and transparency) and its attributes with their
definitions', fills included in the block's order.

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

* Preview: the rubber band is grip blue over objects previewed in their own colours

Over a block previewed in its own colours the rubber band and tick marks,
in the current layer's colour, could vanish on its fill (white on white).
They are drawn in the grip blue 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-19 02:53:50 -04:00
bcd0b3745e fix(render): stabilize dashed highlights of filled objects (#1046)
* fix(render): keep filled highlight dash anchors stable

* outline test: as_chunks for clippy

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-19 02:53:38 -04:00
ba27f36b58 Inquiry: SHAPES reports closed-shape geometry facts (#1507)
* Inquiry: SHAPES reports closed-shape geometry facts

`shapes` is a read-only command that reports, for every closed shape in the
current space (closed polylines, circles, full ellipses, closed splines),
its perimeter, area, centroid, winding, first self-intersection and
coincident vertices, exact with arcs and bulges. It filters and pages like
`entities` and counts other matches under `skipped` with the reason.

`Polyline::centroid` and `polygon_centroid` give the area centroid.

Fixes #1436.

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

* shapes: cap the quadratic self-intersection test at 4000 edges

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-19 02:53:10 -04:00
9f7a2422ea feat(engine): support AUDITCTL system variable and .adt log generation (gaps F4) (#1504)
* feat(engine): support AUDITCTL system variable and .adt log generation (gaps F4)

* audit: write the .adt log only beside a saved drawing

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-19 02:53:04 -04:00
148091aa93 Properties: a dimension's text height, arrow size, overall scale and precision (#1259) (#1473)
* Properties: a dimension's text height, arrow size, overall scale and precision (#1259)

The Properties palette showed only a dimension's text override and style,
so the only way to change its text height was a new dimension style.
It now shows Text height (DIMTXT), Arrow size (DIMASZ), Dim scale overall
(DIMSCALE) and Precision (DIMDEC) with the values the dimension draws
with. Editing one sets a per-dimension override (DIMOVERRIDE) on the
selected dimensions, as AutoCAD's Properties palette does; the style is
left alone. properties.set takes textHeight, arrowSize, dimScale and
precision for dimensions and refuses bad values before changing anything.

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

* props: keep DIMOVERRIDE's doc comment on dim_override

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-19 02:52:59 -04:00
c82d6228ff i18n:Add missing translations for the print dialog and page settings. (#1289)
* i18n:Add missing translations for the print dialog and page settings.

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

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

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-19 02:52:53 -04:00
786c8c46e4 Wrap format_angle to zero when rounding reaches a full turn (#1256)
* Wrap format_angle to zero when rounding reaches a full turn

Found while testing crates/engine/src/units.rs:63-66,87-88,102

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

* units: wrap a full turn by the formatted value, so ties round as {:.*} does

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

---------

Co-authored-by: Claude Haiku 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-19 02:52:48 -04:00
NetterSilverandClaude Haiku 5.5 62329310d7 Reject non-hex characters such as '+' in Rgb::parse_hex (#1601)
Found while testing crates/color/src/lib.rs:23-29

Co-authored-by: Claude Haiku 5.5 <[email protected]>
2026-10-19 02:33:35 -04:00
Antoine Boxho 6bd09ea468 docs: customizing and automating CADCraft (for #1561) (#1569)
A short guide for people porting add-ons: what runs today (LISP routines
and startup files, .scr scripts, .pgp alias files, the JSON control
channel, MCP, cadcraft-cli), what doesn't yet (Visual LISP ActiveX, DCL,
reactors, compiled plug-ins), and where each is documented. The README's
automation section links to it.
2026-10-19 02:33:28 -04:00
FilforandClaude Opus 5.5 0440fa77ff fix(viewcube): draw the WCS pill's arrow instead of a "⌄" glyph (fixes #1541) (#1558)
The pill under the ViewCube was the text "WCS ⌄". U+2304 is missing from some
UI fonts, so it showed as a box ("WCS □"). The pill now paints the translated
label and a down chevron drawn as lines, in the pill's ink, so it no longer
depends on the font. The catalog key becomes "WCS" in every language; Korean,
which spelled the arrow out as "WCS 선택", is now "WCS" next to the chevron.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-19 02:33:21 -04:00
Gert Pellin - GPE 471478868d cadcraft-cli: --help / -h / help exit 0 and print the usage to stdout (#1526)
A help request is not a failure: --version already goes to stdout with
exit 0, and now --help, -h and help do the same. Bare cadcraft-cli
keeps reporting on stderr with exit 1, like git.

Fixes #1525
2026-10-19 02:33:15 -04:00
aldozarandClaude Opus 5.5 0f833358b9 ID, DIST and LIST report Z (#1509)
ID always printed Z = 0, DIST's Delta Z and angle from the XY plane were
0, and LIST printed Z = 0 for lines, circles, arcs and polyline vertices,
though the drawing kept every Z: a topographic drawing's levels could not
be read in CADCraft.

Points reach these commands in 2D, so the Z is that of what is drawn at
the point, as AutoCAD reports a snapped point's: the object a pick finds
there (a point, a 3D line or 3D polyline interpolated, a polyline's
elevation, down Z for a mirrored one, a circle, arc, text, block or the
plane of a 3D face), else the current elevation (ELEVATION). A Z given
with a JSON point wins. DIST gives the 3D distance, Delta Z and the angle
from the XY plane; LIST the Z of lines, circles, arcs and text, a line's
3D length and deltas, and a polyline's elevation.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-19 02:33:10 -04:00
mattlafyandClaude Opus 5.5 64e55f903c Options: an interface scale preference (#1508)
interfaceScale (0.5-3, default 1) in the OPTIONS JSON form and the Display
tab scales every menu, palette, dialog and the command line through egui's
zoom factor, and is kept in the profile.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-19 02:33:04 -04:00
NetterSilverandClaude Haiku 5.5 94aad92f1d Scale SVG export precision and point size to the drawing's extents (#1495)
Found while testing crates/io/src/svg.rs:50-60 (export_list: viewBox, stroke-width, circle r and coordinates all formatted with {:.6})

Co-authored-by: Claude Haiku 5.5 <[email protected]>
2026-10-19 02:32:58 -04:00
NetterSilverandClaude Haiku 5.5 1738d3805e Offset elliptical arcs towards the picked side (#1475)
Found while testing crates/engine/src/cmd/modify.rs:584-597 (offset_kind, EntityKind::Ellipse branch)

Co-authored-by: Claude Haiku 5.5 <[email protected]>
2026-10-19 02:32:52 -04:00
Rusu Alex bf6d9fd46a Make Properties layer menu scrollable (#1467) 2026-10-19 02:32:47 -04:00
Rusu Alex f17be3d7ad Fix Properties palette color swatch (#1466) 2026-10-19 02:32:39 -04:00
NetterSilverandClaude Haiku 5.5 582d9535b6 Strip the UTF-8 BOM when reading a data link CSV file (#1457)
Found while testing crates/engine/src/cmd/table/breaks.rs:167 (parse_csv), fed by read_link at :236 (String::from_utf8_lossy, no BOM removal)

Co-authored-by: Claude Haiku 5.5 <[email protected]>
2026-10-19 02:32:33 -04:00
Marco Arnulfo 2a3b0c2626 A wide polyline thinner than a pixel keeps every side on screen (fixes #1434) (#1453) 2026-10-19 02:32:27 -04:00
NetterSilverandClaude Haiku 5.5 abce3a718b Reject lone '-' or '"' in parse_distance instead of reading 0 (#1444)
Found while testing crates/engine/src/units.rs:107-132

Co-authored-by: Claude Haiku 5.5 <[email protected]>
2026-10-19 02:32:21 -04:00
Kostykandkostyk348 acb28313c6 Layer Off: stay active until Enter or Esc (#1418) (#1426)
LAYOFF ended after a single pick, so turning off five layers took five command
invocations. AutoCAD's Layer Off keeps prompting: you pick object after object
and the command ends on Enter or Esc only.

`SelectRun` grows a `repeat` mode next to the existing single-pick and plain
select modes: `SelectRun::repeat(..)` acts on every pick, seeds the next prompt
straight away, and finishes on Enter (or Esc through the usual cancel path).
The non-repeating paths are untouched — a repeating run must not let one pick
end the command, so `sel.single` is disabled for it and Enter is handled before
the selection machine's own keywords.

The whole run stays a single undo step, because the command body is still
called once per pick but the running command records one step, as before.

Tests: `crates/engine/tests/layoff_repeat.rs`
  cargo test -p cadcraft-engine --test layoff_repeat   -> 4 passed
  - keeps asking until Enter, two layers off, one undo takes both back
  - Esc ends it and keeps what was already turned off
  - an empty pick ("0 found") keeps it running
  - pickfirst and JSON calls are unchanged

Co-authored-by: kostyk348 <[email protected]>
2026-10-19 02:32:14 -04:00
william-wwh 2be9d00796 fix(arc): hold Ctrl to switch the arc direction (#1422)
ARC advertised "hold Ctrl to switch direction" but the engine never saw modifier
keys: the UI dropped `mods` when building `Input::Point`. PR #407 recorded this as
a known limit.

The UI now writes `Session::ctrl_down` every frame (next to the cursor update, not
only while a command prompts, so previews see it too) and the two ARC paths read it:

- Continue: negating the start tangent makes `arc_sed` pick the mirror arc through
  the same two endpoints.
- Center,Start,End: swapping the start/end angles walks the other way around the
  same circle; its end-point prompt now carries the same Ctrl hint.

Tests: crates/engine/tests/arc_ctrl_direction.rs.
2026-10-19 02:32:08 -04:00
NetterSilverandClaude Haiku 5.5 286d3de0aa Drop minus sign from dimension text that rounds to zero (#1403)
Found while testing crates/render/src/units.rs:139-160 (format_linear, unit 3 and 4 arms) and feet_inches at :101

Co-authored-by: Claude Haiku 5.5 <[email protected]>
2026-10-19 02:32:01 -04:00
NetterSilverandClaude Haiku 5.5 cd3e8478d6 Fix AutoLISP real printing of subnormals and mantissa round-up (#1334)
Found while testing crates/engine/src/lisp/value.rs:401-410 (real_text)

Co-authored-by: Claude Haiku 5.5 <[email protected]>
2026-10-19 02:31:56 -04:00
NetterSilverandClaude Haiku 5.5 12d0235b2c Fix table formula range shifting for one-cell and reversed ranges (#1328)
Found while testing crates/engine/src/cmd/table/formula.rs:660

Co-authored-by: Claude Haiku 5.5 <[email protected]>
2026-10-19 02:31:50 -04:00
NetterSilverandClaude Haiku 5.5 1ac2ebaad2 Keep line and arc elevation when lengthening with LENGTHEN (#1313)
Found while testing crates/engine/src/cmd/modify2.rs:764

Co-authored-by: Claude Haiku 5.5 <[email protected]>
2026-10-19 02:31:44 -04:00
wstockandClaude Fable 5.1 a3b3e1281f render: the even-odd fill cuts a slab at the crossings inside it (#927) (#1308)
Slabs ran between vertex rows and paired the active edges by their
order at the slab's middle, so two edges crossing inside a slab (a
bowtie, two loops crossing) were paired across the crossing and the
trapezoids covered the wrong side of it. A slab with such a crossing is
now cut again at that y and each part paired on its own, under a cut
budget so hostile input stays bounded. Tests check coverage against an
even-odd parity oracle for a bowtie, an off-centre bowtie, two crossing
loops and the plain cases; the three crossing cases fail on main.


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

Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-10-19 02:31:39 -04:00
Galeno ca5a34be12 UI: let the Chinese and Arabic language gates accept an embedded craft-fonts face (#1294)
can_show only asked the installed-font lookups for those two languages, and
both lookups hard-code None on wasm32, where there are no system fonts. The
Korean gate already falls back to a craft-fonts face (korean_font), but the
web build embeds a Jpan CJK face and an Arabic face precisely as the web's
only fonts, so a Chinese or Arabic interface could never be selected on it,
however complete the catalog; the embedded Arabic face was also never added
to the UI font families (the embedded filter only kept Jpan/Han faces).

Give both gates the embedded-face path the Korean one has:
- can_show("zh"/"ar") answers true when this build embeds a Regular face of
  Hans/Hant or Arab, in addition to an installed font.
- install_fonts picks embedded faces via embedded_ui_faces: every CJK face
  still loads in every language for CJK drawing names (#513), the face of a
  zh interface leads (a Japanese face lacks the simplified-only 图 园 测),
  and an Arabic interface appends its Arabic face.

Nothing changes for a build without CRAFT_FONTS_DIR (CRAFT_FONTS empty):
Chinese still rides on an installed CJK font and Arabic on an installed one.
2026-10-19 02:31:34 -04:00
c400d6d48c Properties: a rotation shows and is typed as the drawing measures directions (#1275)
AutoCAD's Properties shows a block reference's or text's rotation as it shows
any direction: from ANGBASE, clockwise when ANGDIR is 1 (a block turned 2.23°
shows as 87.77 in a drawing measured clockwise from north). CADCraft showed
the raw angle from +X. Properties and Quick Properties now show it that way
and read a typed rotation back the same way (`AngleSettings::shown`,
`unshown`); properties.set still takes the angle from +X counterclockwise.

Co-authored-by: Cody Vogt <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-19 02:31:29 -04:00
NetterSilverandClaude Haiku 5.5 e635248b1e Carry engineering distance rounding into the next foot (#1255)
Found while testing crates/engine/src/units.rs:14-20

Co-authored-by: Claude Haiku 5.5 <[email protected]>
2026-10-19 02:31:24 -04:00
Marco Arnulfo 739e083f4b Drafting Settings: polar increment angle no longer shows 15 decimals (fixes #1237) (#1239) 2026-10-19 02:31:18 -04:00
3fa7275e4f DXF: $ANGBASE is in degrees (#1234)
AutoCAD writes $ANGBASE in degrees (a drawing whose angles run clockwise from
north has 90.0); CADCraft read and wrote it as radians, so such a drawing's
angle base came in as 90 radians (about 116.6 degrees), turning typed angles,
polar tracking and angles shown in Properties. The reader now converts to
radians (the variable's unit), the writer back, and the DWG reader's hand-off
of the value in DXF form gives degrees.

Co-authored-by: Cody Vogt <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-19 02:31:12 -04:00
Marco Arnulfo aba903dbb3 CLI: convert to an image warns about the entities it can't draw (#1212)
* CLI: convert to an image warns about the entities it can't draw (fixes #1208)

* CLI: the undrawn-entities warning lists its counts like the skipped-records one
2026-10-19 02:31:06 -04:00
Marco Arnulfo 493addc2dd DXF files that declare no units stay unitless when saved again (fixes #1209) (#1210) 2026-10-19 02:31:01 -04:00
Marco Arnulfo a2bee8b49e LTSCALE typed at the command line asks for the scale factor (fixes #1194) (#1199) 2026-10-19 02:30:56 -04:00
Marco Arnulfo c68420221c New metric drawings get metric Standard multileader and table styles (fixes #1171) (#1195) 2026-10-19 02:30:51 -04:00
Marco Arnulfo a5fa5ca064 Select objects accepts keywords with a leading underscore (_L, _ALL, _W…) (fixes #1172) (#1193) 2026-10-19 02:30:46 -04:00
Marco Arnulfo ef599e58e0 BREAK opens a closed polyline instead of refusing it (fixes #1173) (#1191) 2026-10-19 02:30:41 -04:00
Marco Arnulfo 27fd074f54 AutoLISP entity lists hold angles in radians, as entget does in AutoCAD (fixes #1165) (#1190) 2026-10-19 02:30:36 -04:00
Marco Arnulfo 2db6ab069a DIMRADIUS/DIMDIAMETER: a location outside the circle puts the text at that point (fixes #1170) (#1189) 2026-10-19 02:30:31 -04:00
Marco Arnulfo d33a6e634a AREA, Object: an open polyline's perimeter is its length, without the closing side (fixes #1168) (#1188) 2026-10-19 02:30:26 -04:00
Antoine Boxho 0bfe609567 Add Antoine Boxho to contributors list with GitHub sync (#1186) 2026-10-19 02:30:21 -04:00
Antoine BoxhoandClaude Fable 5.1 7e830044ab BLOCK: a keep value other than convert, retain or delete is an error (fixes #935) (#1160)
make_block erased the selected objects for every `keep` value except
"retain" and only converted them for "convert", so a misspelt value such
as "retian" removed the objects from the drawing without a reference to
the new block.

`keep` is now parsed into an enum before anything is touched; a value
that is not documented is an error naming the three accepted values. The
documented values are accepted in any case. BLOCK and -BLOCK share the
check.

Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-10-19 02:30:15 -04:00
Marco Arnulfo 6546f0eecd Parameters Manager: double-click a name to rename the parameter and every expression that uses it (part of #1134) (#1142) 2026-10-19 02:30:10 -04:00
Marco Arnulfo 15b5395c06 Angular constraint between two lines holds the angle it draws, whichever way the lines run (part of #1134) (#1140) 2026-10-19 02:30:05 -04:00
Marco Arnulfo cdc61f32b5 A fixed point stays put when its grip is dragged: Fix locks the position it had before the edit (part of #1134) (#1139) 2026-10-19 02:29:59 -04:00
Marco Arnulfo d4bacce885 Tool Sets: the Parametric category offers Fix, Symmetric, the dimensional constraints and Delete Constraints (part of #1134) (#1138) 2026-10-19 02:29:54 -04:00
Marco Arnulfo 9aea9b16c4 Dynamic Input: the CIRCLE radius prompt shows one distance field, not X/Y (fixes #1135) (#1137) 2026-10-19 02:29:48 -04:00
Marco Arnulfo 9e3f584a68 Status bar toggles don't cancel the running command, as F7-F12 don't (fixes #1108) (#1111) 2026-10-19 02:29:43 -04:00
Marco Arnulfo 3bd7510f2b ARC continuation starts from the last picked end of a clockwise arc (fixes #1107) (#1110) 2026-10-19 02:29:37 -04:00
Marco Arnulfo b464a947ec MEASUREGEOM typed at the command line asks for the option, then the points (fixes #1103) (#1104) 2026-10-19 02:29:31 -04:00
Marco Arnulfo f76ca751cc Collapsed Tool Sets leave a strip on the left edge that expands them again (fixes #1095) (#1101) 2026-10-19 02:29:26 -04:00
Marco Arnulfo e0ad0ef33e DWG dimensions: text at its default position unless it was moved, and bit 32 kept (fixes #1094) (#1096) 2026-10-19 02:29:19 -04:00
56d73ceb21 Dynamic blocks: show a distance's grip only at an end an action follows, and the grips of parameters a visibility state lists (#1090)
* Dynamic blocks: a distance's grip shows only at an end an action follows

A linear parameter's grip at an end no action is attached to moves nothing,
and AutoCAD doesn't show it on the reference: a stretch that follows only the
end's change (EndXDelta, EndYDelta) leaves the base without a grip, and the
other way round. A connection to the distance itself follows both ends, as
does a distance measured from its midpoint; a parameter no action is attached
to keeps the grips the block gives it. (A block saved with one grip has no
base grip at all, which was already honoured.)

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

* Dynamic blocks: a visibility state that lists a parameter shows its grips

AutoCAD shows a parameter's grips in a visibility state that lists the
parameter, though the state doesn't list the grips themselves (a label block's
Label state lists Position1 and Distance2 but not their grips, and AutoCAD
shows the square and the arrow). A grip now shows when the state lists it or
its parameter.

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]>
2026-10-19 02:29:13 -04:00
aldozarandClaude Opus 5.5 691bdbe0b8 Layouts in millimetres plot in millimetres whatever MEASUREMENT says (#1058) (#1085)
Paper-space units followed the drawing's MEASUREMENT only, so a layout
whose page setup is in millimetres (plot paper units 1), common in
drawings from AutoCAD with MEASUREMENT 0, was taken as inches: its PDF
drew everything 25.4 times too large, off the page.

PageSetup gains `paper_mm`, read from LAYOUT group 72 = 1; 72 = 0 still
leaves the units to MEASUREMENT, since earlier CADCraft versions wrote 0
for every layout. The layout's sheet, its PDF/raster plots, PREVIEW and
the pens of what its viewports show use the layout's units, and the
writer stores 72 = 1 for layouts in millimetres (it wrote 0 always).

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-19 02:29:03 -04:00
aldozarandClaude Opus 5.5 162d5c6820 DWG: entities of a second layout stay on it in AutoCAD (#1057) (#1080)
acadrust writes every paper-space entity with entity mode 1 ("paper
space"), including those of *Paper_Space0, *Paper_Space1… and relies on
each block's entity list; AutoCAD reads mode 1 as the *Paper_Space
block's, so what was drawn on a second layout (and its viewports, in
drawings from AutoCAD with several layouts) landed on the first one, and
the second stayed empty. The DWG bridge now writes those entities owned
by their block (entity mode 0 with the owner handle), as the public DWG
specification describes; the first layout's entities stay mode 1.

The io tests read CADCraft's DWG back with acadrust (a dev-dependency,
the same version as cadcraft-dwg's) to check the modes as written.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-19 02:28:58 -04:00
aldozarandClaude Opus 5.5 632c2d4f1f DIMASSOC: reference polyline vertices by segment, as AutoCAD does (#1049) (#1064)
* DIMASSOC: no standard reference to a polyline's vertex or segment (#1049)

A dimension on a polyline vertex was written with an AcDbOsnapPointRef
naming the polyline but no subentity (groups 73 and 91 both 0); AutoCAD
then resolved it to the polyline's first segment when it regenerated
the dimension, and the value changed without warning (210 became 5).
Such points (LWPOLYLINE, 3D polyline, MLINE, also as the second object
of an intersection) are now left out of the DIMASSOC object, as the
report suggests: AutoCAD sees them as non-associative and the values
stay right. CADCraft keeps every link through its own xdata, and
references to single-curve objects (lines, arcs, circles, points) are
written as before.

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

* DIMASSOC: reference polyline vertices by segment, as AutoCAD does

Following the black-box observation of AutoCAD 2026 in the PR review: a
polyline vertex is referenced as an end of one of its segments,
subentity type 73 = 2 (edge) and the 1-based segment in 91 (segment k
runs from vertex k-1 to vertex k; a closed polyline's closing segment is
the last), with osnap 1 / parameter 1.0 for a segment's end and 13 / 0.0
for its start. Vertex 0 is written as the start of segment 1, vertex k
as the end of segment k, falling back to the other neighbour when that
one is an arc; a vertex between two arcs (where AutoCAD writes an angle
parameter, not yet tested) still gets no reference, nor do 3D polylines
and multilines.

Reading uses the segment and osnap for polylines: AutoCAD leaves the
reference point at 0,0, so the nearest-vertex fallback attached
AutoCAD's associative dimensions on polylines to the vertex nearest the
origin.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-19 02:28:52 -04:00
aldozarandClaude Opus 5.5 f0e4fef003 AutoCAD interop: justified text, DIMPOST, current dimension style, *D block colours (#1062)
* AutoCAD interop: justified text, DIMPOST, current dimension style, *D block colours

What AutoCAD takes from CADCraft's DWG/DXF as it is:

- Justified TEXT/ATTRIB/ATTDEF (#1050): group 10 is now where the
  baseline starts, from the alignment point and the text's width, not
  the alignment point itself; AutoCAD drew centred and right-aligned
  text from DWG starting at its alignment point. A stored point close to
  CADCraft's own (AutoCAD's, exact for its fonts) is kept.
- DIMPOST (#1053) is written even when empty: the DWG bridge defaulted a
  missing one to "<>", and AutoCAD then dropped the "R" of radius
  dimensions when it regenerated them.
- The current dimension style (#1051): the DXF header now carries its
  DIM* variables (AutoCAD draws new and updated dimensions from them);
  the DWG bridge points $DIMSTYLE at the style by name (its handle was
  renumbered, so the writer fell back to Standard), and reading a DWG
  gives the current dimension and text styles their names back.
- *D blocks (#1052): lines, arrows, extension lines and text take the
  style's DIMCLRD/DIMCLRE/DIMCLRT instead of ByBlock.

#1054 (group 42 of a rotated linear dimension) was already fixed on
main by #855; acad_interop.rs keeps it covered.

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

* Test: angular dimensions store their angle in radians (group 42)

Three-point (60°, 174°) and two-line (120°) angular dimensions, read back
from the DWG as DXF. Already right on main since #855; this guards it.

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

* Justified text made here: Left Middle and Left Bottom write their baseline start too

text_insert kept a stored insertion point that was within 0.1 x an
estimated width + 0.5 x height of CADCraft's baseline start, taking it for
AutoCAD's own. The width was estimated from the alignment point's offset,
which for Left Middle is only h/2 (vertical), so a text made here, whose
stored point is the alignment point, passed and kept it: AutoCAD drew it
half a height high (Left Bottom: a third). Reported with a test by
@lee-cyrille-maskell.

A stored point equal to the alignment point is now always stale. Otherwise
the offset is split along and across the baseline: along it the same slack
as before (a tenth of the text's width plus half its height, as fonts and
their substitutes differ most in width), across it a fifth of the height.
Multiline attribute values keep the distance-only check, since their lines
are placed by the embedded MTEXT.

On 12 drawings made in AutoCAD (1,055 justified texts) the insertion points
written are the same as before.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-19 02:28:47 -04:00
leonardopbatista 7164b6b5b1 feat(options): save a two-click selection window preference (#1032) 2026-10-19 02:28:41 -04:00
Bihacsy László 38fdfa4ac4 i18n: add Hungarian (hu) interface translation (#1027)
A complete 1,326-entry Hungarian catalog: every key of the Ukrainian reference catalog and the
four menu headings (Méretezés, Rajzolás, Módosítás, Nézet). Written from the meaning of
CADCraft's English strings in general technical drawing vocabulary; no other product's
localization and no other catalog was used. The interface addresses the user informally;
command-line keywords, system variables and osnap tokens stay English.

- Preference::Hungarian (hu); hu-HU and hu_HU.UTF-8 select it. Counts take one form, as a
  Hungarian numeral is followed by the singular (plural_ko, plural_forms: 1).
- hu in REPORTED; tests for locales, saved preference and plurals, menu headings, a live
  switch, dialogs and the Hungarian letters (ő, ű) in the bundled fonts. No gaps are reported
  for Hungarian.
- ATTRIBUTION, README, docs/localization.md, docs/localization-parity.md,
  docs/control-protocol.md and the options doc comment.
2026-10-19 02:28:35 -04:00
Gert Pellin - GPE baa9c00fb7 SVG import: name what was left out instead of dropping it silently (#627) (#1016)
Text, raster images, <use> copies, <style> sheets and rounded rectangle
corners aren't read yet. They were dropped without a word; the reader now
counts them and adds one read note (the channel DWG reading already uses),
which OPEN echoes and returns as a warning. Content that isn't drawn (in
<defs> or hidden) isn't counted.
2026-10-19 02:28:29 -04:00
Dylan M. Taylor 0cc66ad306 ci: configure Dependabot for Cargo and GitHub Actions
Co-authored-by: Dylan M. Taylor <[email protected]>
2026-10-18 12:30:54 -06:00
Dylan M. Taylor 3370a91fe9 docs: drop references to competing commercial software (#1545) 2026-10-18 10:39:39 -04:00
Brandon ThomasandClaude Opus 5.5 b23d362f6a Release 0.9.0
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 21:43:25 -04:00
Brandon ThomasandClaude Opus 5.5 714111d528 Release 0.8.0
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 17:20:02 -04:00
skylar taylor-barrick ff6cc8b3fc Merge pull request #1518 from skylartaylor/control-input
Bound control-port input, and follow stored paths only on this computer or the drawing's share
2026-10-17 12:14:01 -06:00
skylar taylor-barrick 98d22f0c68 Build: take craft-docpath from the merged craft-libs rev
craft-libs#169 is merged, so the fork pin is no longer needed. Pinned to
storytold/craft-libs 82a24b006eefa036b43c09a67fb9a3fbd17ffe63 (main after the merge) rather than the older 12281d07,
which predates resolve_absolute and the Windows colon refusal.
2026-10-17 12:04:10 -06:00
aiongg ba02572b31 Build: use craft-docpath from craft-libs 2026-10-17 02:20:57 +00:00
aiongg 8fe7c421ea Xrefs and data links: follow stored paths only on this computer or the drawing's share
Xref paths saved in a drawing (on open, on Reload, in the External
References list, XOPEN, nested xrefs) and table data-link paths re-read by
DATALINKUPDATE go through doc_path::resolve (craft-docpath): relative
paths resolve beside the drawing that stores them, and paths to other
computers, devices and URLs are not opened. Such an xref is Unresolved
with the reason, unless a file of its name is beside the host drawing.
Paths given to XATTACH, -XREF Path and DATALINK are used as given.

One load of xrefs reads at most 1 GB from referenced files in total.
2026-10-16 17:18:34 +00:00
aiongg 972ae7888c Close control connections on the first line that is not a request; bound idle time, writes and queued requests
A line that is not a JSON object with a string method, including an HTTP
request line, gets one error reply and the connection closes before
anything after it is read. Connections time out after 10 minutes idle or
30 seconds blocked on a write, and at most 64 requests wait for the app;
more are answered busy.
2026-10-16 16:07:25 +00:00
Dylan M. Taylor ea6d7beca2 ci: switch to shared nix workflow from craft-common-actions 2026-10-15 23:58:01 -04:00
skylar taylor-barrick 9b2308b138 Merge pull request #1382 from dylanmtaylor/ci/add-platform-checks
ci: add shared FreeBSD, Windows ARM64, and nix checks
2026-10-15 21:26:48 -06:00
Dylan M. Taylor 69fc442ea6 ci: add shared FreeBSD, Windows ARM64, and nix checks 2026-10-15 20:47:50 -04:00
Marco Arnulfo be3ce73895 fix(engine): preserve rectangle area with ortho enabled (#1117) 2026-10-15 18:34:36 -06:00
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
340 changed files with 49091 additions and 1780 deletions

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
reviews:
auto_review:
# Dependabot bumps are checked by CI; skip CodeRabbit reviews on them.
ignore_usernames:
- "dependabot[bot]"
+30
View File
@@ -0,0 +1,30 @@
version: 2
updates:
# Maintain Rust / Cargo dependencies
- package-ecosystem: "cargo"
directory: "/"
schedule:
interval: "daily"
time: "06:00"
timezone: "America/New_York"
# Consolidate updates into a single PR rather than one PR per crate
groups:
cargo-dependencies:
patterns:
- "*"
commit-message:
prefix: "chore(deps)"
# Maintain GitHub Actions workflows
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "daily"
time: "06:00"
timezone: "America/New_York"
groups:
actions:
patterns:
- "*"
commit-message:
prefix: "ci(deps)"
+29
View File
@@ -0,0 +1,29 @@
# Builds and tests the workspace on FreeBSD, inside a FreeBSD VM on a Linux runner
# (GitHub has no FreeBSD runners). GPU tests skip themselves when the VM has no adapter.
name: FreeBSD
on:
push:
branches: [release]
pull_request:
paths:
- .github/workflows/freebsd.yml
- packaging/freebsd/**
- packaging/env.sh
schedule:
- cron: "27 3 * * *"
workflow_dispatch:
concurrency:
group: freebsd-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
freebsd:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
with:
craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
+23
View File
@@ -0,0 +1,23 @@
# Evaluates the Nix flake (no build, about a minute): `nix build` / `nix develop` stop at evaluation
# when Cargo.lock gains something nix/package.nix can't vendor, and nothing else would notice.
name: Nix
on:
push:
branches: [main]
paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"]
pull_request:
paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: nix-${{ github.ref }}
cancel-in-progress: true
jobs:
nix:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/nix.yml@main
+20
View File
@@ -0,0 +1,20 @@
name: SonarCloud
on:
push:
branches: [main]
pull_request:
types: [opened, synchronize, reopened]
permissions:
contents: read
concurrency:
group: sonarcloud-${{ github.ref }}
cancel-in-progress: true
jobs:
sonarcloud:
# Tracked at @main so org-wide CI improvements propagate without PR churn.
uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
secrets: inherit
+30
View File
@@ -0,0 +1,30 @@
# Windows on ARM: package the ARM64 release the way release.yml does (cross-compiled on an x64
# runner, unsigned here), then install that MSI on a Windows 11 ARM64 runner, check the installed
# binaries are ARM64, run the CLI natively, and uninstall.
name: Windows ARM64
on:
push:
branches: [release]
pull_request:
paths:
- .github/workflows/windows-arm64.yml
- packaging/windows/**
schedule:
- cron: "41 3 * * 1"
workflow_dispatch:
concurrency:
group: windows-arm64-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
windows-arm64:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main
with:
app-name: cadcraft
app-dir: CADCraft
+7
View File
@@ -64,3 +64,10 @@ 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 |
| `crates/ui-egui/src/i18n/hu.tsv` | @Skaywalker | Original Hungarian translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
| `crates/ui-egui/src/i18n/tr.tsv` | @ogzhnklvz (AI-assisted) | Original Turkish translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied; written from the English strings, no other catalog used as a source; the running app reviewed by a native speaker |
| `crates/ui-egui/src/i18n/el.tsv` | @Camthalion77 with ChatGPT (AI-assisted) | Original Greek translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied; independent native proofreading pending |
Generated
+61 -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.10.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.10.0"
dependencies = [
"cadcraft-engine",
"cadcraft-io",
@@ -577,14 +580,14 @@ dependencies = [
[[package]]
name = "cadcraft-color"
version = "0.6.2"
version = "0.10.0"
dependencies = [
"serde",
]
[[package]]
name = "cadcraft-constraints"
version = "0.6.2"
version = "0.10.0"
dependencies = [
"cadcraft-doc",
"cadcraft-geom",
@@ -594,7 +597,7 @@ dependencies = [
[[package]]
name = "cadcraft-doc"
version = "0.6.2"
version = "0.10.0"
dependencies = [
"cadcraft-color",
"cadcraft-geom",
@@ -605,14 +608,14 @@ dependencies = [
[[package]]
name = "cadcraft-dwg"
version = "0.6.2"
version = "0.10.0"
dependencies = [
"acadrust",
]
[[package]]
name = "cadcraft-dxf"
version = "0.6.2"
version = "0.10.0"
dependencies = [
"encoding_rs",
"log",
@@ -621,7 +624,7 @@ dependencies = [
[[package]]
name = "cadcraft-engine"
version = "0.6.2"
version = "0.10.0"
dependencies = [
"cadcraft-color",
"cadcraft-constraints",
@@ -631,6 +634,7 @@ dependencies = [
"cadcraft-geom",
"cadcraft-io",
"cadcraft-render",
"craft-docpath",
"log",
"rstar",
"serde",
@@ -640,7 +644,7 @@ dependencies = [
[[package]]
name = "cadcraft-fonts"
version = "0.6.2"
version = "0.10.0"
dependencies = [
"cadcraft-geom",
"harfrust",
@@ -650,15 +654,16 @@ dependencies = [
[[package]]
name = "cadcraft-geom"
version = "0.6.2"
version = "0.10.0"
dependencies = [
"serde",
]
[[package]]
name = "cadcraft-io"
version = "0.6.2"
version = "0.10.0"
dependencies = [
"acadrust",
"cadcraft-color",
"cadcraft-doc",
"cadcraft-dwg",
@@ -671,11 +676,12 @@ dependencies = [
"serde",
"serde_json",
"thiserror 2.0.21",
"tiny-skia",
]
[[package]]
name = "cadcraft-mcp"
version = "0.6.2"
version = "0.10.0"
dependencies = [
"cadcraft-engine",
"cadcraft-io",
@@ -686,7 +692,7 @@ dependencies = [
[[package]]
name = "cadcraft-render"
version = "0.6.2"
version = "0.10.0"
dependencies = [
"cadcraft-color",
"cadcraft-doc",
@@ -700,11 +706,12 @@ dependencies = [
[[package]]
name = "cadcraft-ui-egui"
version = "0.6.2"
version = "0.10.0"
dependencies = [
"cadcraft-color",
"cadcraft-doc",
"cadcraft-engine",
"cadcraft-fonts",
"cadcraft-geom",
"cadcraft-render",
"craft-ui",
@@ -721,7 +728,7 @@ dependencies = [
[[package]]
name = "cadcraft-web"
version = "0.6.2"
version = "0.10.0"
dependencies = [
"cadcraft-engine",
"cadcraft-io",
@@ -922,6 +929,11 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48"
[[package]]
name = "craft-docpath"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63"
[[package]]
name = "craft-ui"
version = "0.1.0"
@@ -2707,6 +2719,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 +2821,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 +2837,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 +2848,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 +2858,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 +4811,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.6.2"
version = "0.10.0"
dependencies = [
"serde_json",
]
+3 -1
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
# default-members
[workspace.package]
version = "0.6.2"
version = "0.10.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
@@ -24,6 +24,8 @@ cadcraft-io = { path = "crates/io" }
cadcraft-engine = { path = "crates/engine" }
cadcraft-ui-egui = { path = "crates/ui-egui" }
cadcraft-mcp = { path = "crates/mcp" }
# Which paths stored in a drawing (xrefs, data links) may be opened.
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
# foundation
thiserror = "2"
+26 -5
View File
@@ -10,11 +10,11 @@
<h1 align="center">CADCraft</h1>
<p align="center">
<b>Computer-aided design and drafting; an open-source, clean-room reimplementation of Autodesk AutoCAD, rebuilt in pure Rust.</b>
<b>Computer-aided design and drafting; an open-source, modern 2D and 3D CAD application, built in pure Rust.</b>
</p>
<p align="center">
A fast, open-source take on the AutoCAD workflow: the command line, object snaps, layers,
A fast, open-source CAD workflow: the command line, object snaps, layers,
dimensions, hatches, blocks and DXF drawings you already know. It runs natively on macOS,
Windows, Linux and FreeBSD, and in the browser via WebAssembly.<br>
<i>By the ArtCraft team.</i>
@@ -26,6 +26,9 @@
<img alt="License: MIT OR Apache-2.0" src="https://img.shields.io/badge/license-MIT%20%2F%20Apache--2.0-0b6f88?style=flat-square">
<img alt="Agent-drivable over MCP" src="https://img.shields.io/badge/agents-MCP-14a3c7?style=flat-square">
<img alt="Status: early development" src="https://img.shields.io/badge/status-early%20development-f07a3a?style=flat-square">
<br>
<a href="https://sonarcloud.io/project/overview?id=storytold_cadcraft"><img alt="Scanned by SonarCloud" src="https://img.shields.io/badge/Scanned_by-SonarCloud-4b9fd5?style=flat-square&logo=sonarqubecloud&logoColor=white"></a>
<a href="https://coderabbit.ai"><img alt="Reviewed by CodeRabbit" src="https://img.shields.io/coderabbit/prs/github/storytold/cadcraft?style=flat-square&label=Reviewed%20by%20CodeRabbit&logo=coderabbit"></a>
</p>
<p align="center">
@@ -111,7 +114,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), Arabic (العربية, with an installed Arabic font), Hungarian (Magyar) and Turkish (Türkçe), 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
@@ -225,6 +228,9 @@ MCP tools include `command_line` (type at the prompt), `execute` (any command wi
`screenshot` and `ui_click`. The desktop app's JSON control channel is documented in
[docs/control-protocol.md](docs/control-protocol.md).
To run LISP routines, scripts and alias files, or to drive CADCraft from your own programs, see
[docs/customization.md](docs/customization.md).
## Architecture
```text
@@ -252,8 +258,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.
@@ -377,8 +400,6 @@ ArtCraft Team and are not covered by this license. They may be used only unmodif
part of this repository and CADCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt).
Forks and modified versions must remove them.
<sub>Autodesk, AutoCAD and DWG are trademarks or registered trademarks of Autodesk, Inc. in the United States and/or other countries. CADCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Autodesk, Inc.; these names are used only to describe the workflows and file formats it is compatible with.</sub>
<p align="center">
<a href="https://getartcraft.com/"><img alt="ArtCraft" src="docs/brand/artcraft-mark.svg" width="28"></a><br>
<sub>Made by the <a href="https://getartcraft.com/">ArtCraft</a> team and community.</sub>
+10 -3
View File
@@ -2,7 +2,7 @@
**Stage: pre-alpha** · next: alpha, ~2 points (38% → 40% ready for real work) and ~125–190 Opus 5.5 hours away (the four failing core workflows of the [alpha gate](docs/roadmap.md#alpha-gate))
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (hours to ~95% for each readiness number) · **Target:** Autodesk AutoCAD 2027
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (HATCHEDIT boundary add/remove progress) · **Target:** Autodesk AutoCAD 2027
CADCraft targets full parity with AutoCAD (2D drafting first, then annotation, layouts and
plotting, DWG, parametrics and 3D), plus what AutoCAD doesn't have: agent control over MCP, a
@@ -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.
@@ -94,11 +94,12 @@ Ukrainian and German. Detail: [localization-parity.md](docs/localization-parity.
| Portuguese | none | 0% |
| Indonesian | none | 0% |
| Japanese | none | 0% |
| Greek | complete current catalog | 1,330 current source keys; independent proofreading pending |
| German | partial | ≈ 100% of the catalog (989 rows) |
| 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 +119,9 @@ Ranked; detail and the full beta checklist in [docs/roadmap.md](docs/roadmap.md)
| Date | Entry |
|---|---|
| 2026-10-14 | HATCHEDIT adds boundaries from internal points or closed objects and removes selected loops in the dialog, command line and JSON command; associative links and undo are covered by tests. |
| 2026-10-12 | TRIM: a pick after a closed polyline's last cut keeps only the complementary run instead of an extra circuit of the outline (#726) |
| 2026-10-12 | 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 +135,9 @@ Ranked; detail and the full beta checklist in [docs/roadmap.md](docs/roadmap.md)
| Date | Change | Summary |
|---|---|---|
| 2026-10-14 | minor | Progress log: HATCHEDIT boundary add/remove |
| 2026-10-12 | trivial | Progress log: closed-polyline TRIM (#726) |
| 2026-10-12 | trivial | Progress log: 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 |
+75 -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,48 @@ 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}");
}
if cadcraft_io::is_image(o)
&& let Some(m) = undrawn_message(s.doc().map_err(|e| e.to_string())?, i)
{
eprintln!("{m}");
}
let window = export_window(&s, framing, None)?;
export_framed(&s, o, window)
}
/// The warning for the model-space entities of `path` an image can't show, by type (those kept
/// as unknown objects, `Proxy` in `info`), or `None` when there are none.
fn undrawn_message(d: &cadcraft_engine::doc::Drawing, path: &str) -> Option<String> {
let mut types: std::collections::BTreeMap<&str, usize> = std::collections::BTreeMap::new();
for e in d.model.iter() {
if let cadcraft_engine::doc::EntityKind::Unknown(u) = &e.kind {
let n = types.entry(u.dxf_type.as_str()).or_insert(0);
*n = n.saturating_add(1);
}
}
let n = types.values().fold(0usize, |a, n| a.saturating_add(*n));
if n == 0 {
return None;
}
let list: Vec<String> = types.iter().map(|(t, n)| format!("{t}: {n}")).collect();
let entities = if n == 1 { "entity" } else { "entities" };
Some(format!("{path}: {n} {entities} not drawn ({})", list.join(", ")))
}
/// The warning for the records of `path` the reader could not place, or `None` when there are none.
fn skipped_message(d: &cadcraft_engine::doc::Drawing, path: &str) -> Option<String> {
let n = d.skipped_records.values().fold(0usize, |a, n| a.saturating_add(*n));
if n == 0 {
return None;
}
let list: Vec<String> = d.skipped_records.iter().map(|(section, n)| format!("{section}: {n}")).collect();
let records = if n == 1 { "record" } else { "records" };
Some(format!("{path}: {n} {records} could not be read and are left out ({})", list.join(", ")))
}
fn run(args: &[String]) -> Result<(), String> {
let mut s = Session::empty();
let mut it = args.iter();
@@ -524,37 +567,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 +633,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]
@@ -631,6 +690,10 @@ fn cli() -> ExitCode {
outln!("cadcraft-cli {}", env!("CARGO_PKG_VERSION"));
Ok(())
}
Some("--help" | "-h" | "help") => {
outln!("{USAGE}");
Ok(())
}
_ => Err(USAGE.into()),
};
match r {
+23
View File
@@ -0,0 +1,23 @@
//! `--help` is a request, not a failure (#1525): it goes to stdout with exit 0, like `--version`.
//! Bare `cadcraft-cli` keeps reporting on stderr with exit 1.
use std::process::Command;
#[test]
fn help_flags_print_usage_to_stdout_and_exit_zero() {
for args in [&["--help"][..], &["-h"], &["help"]] {
let out = Command::new(env!("CARGO_BIN_EXE_cadcraft-cli")).args(args).output().expect("run cadcraft-cli");
assert!(out.status.success(), "{args:?}: {:?}", out.status);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.starts_with("usage:"), "{args:?}: {stdout:?}");
assert!(String::from_utf8_lossy(&out.stderr).is_empty(), "{args:?}: stderr should be empty");
}
}
#[test]
fn bare_invocation_still_reports_on_stderr() {
let out = Command::new(env!("CARGO_BIN_EXE_cadcraft-cli")).output().expect("run cadcraft-cli");
assert_eq!(out.status.code(), Some(1));
assert!(out.stdout.is_empty(), "bare invocation keeps its usage on stderr");
assert!(String::from_utf8_lossy(&out.stderr).starts_with("usage:"));
}
@@ -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);
}
@@ -0,0 +1,53 @@
//! `convert` to an image says how many model-space entities it can't draw, by type, instead of
//! leaving them out silently (#1208). 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, two foreign REGIONs and a PDF underlay: the last three are kept as unknown entities.
const PROXIES: &str = "0\nSECTION\n2\nENTITIES\n\
0\nLINE\n8\n0\n10\n0\n20\n0\n11\n5\n21\n5\n\
0\nREGION\n8\n0\n70\n1\n1\nabc\n\
0\nREGION\n8\n0\n70\n1\n1\ndef\n\
0\nPDFUNDERLAY\n8\n0\n10\n0\n20\n0\n41\n1\n42\n1\n43\n1\n\
0\nENDSEC\n0\nEOF\n";
#[test]
fn convert_to_an_image_warns_about_entities_it_cannot_draw() {
let dir = std::env::temp_dir().join(format!("cc-cli-undrawn-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let input = dir.join("proxies.dxf");
std::fs::write(&input, PROXIES).unwrap();
let input = input.to_str().unwrap();
let out = cli(&["info", input]);
let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
assert_eq!(v["counts"]["Proxy"], 3, "{v}");
for ext in ["svg", "png", "pdf"] {
let image = dir.join(format!("out.{ext}"));
let out = cli(&["convert", input, image.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("not drawn")).collect();
assert_eq!(warnings.len(), 1, "{ext}: {err}");
assert_eq!(warnings[0], format!("{input}: 3 entities not drawn (PDFUNDERLAY: 1, REGION: 2)"), "{ext}");
}
// A DXF copy keeps them, so there is nothing to warn about.
let copy = dir.join("copy.dxf");
let out = cli(&["convert", input, copy.to_str().unwrap()]);
assert!(out.status.success());
assert!(!String::from_utf8_lossy(&out.stderr).contains("not drawn"), "{}", String::from_utf8_lossy(&out.stderr));
// A drawing without them: no warning.
let sample = dir.join("sample.dxf");
assert!(cli(&["sample", "floorplan", sample.to_str().unwrap()]).status.success());
let out = cli(&["convert", sample.to_str().unwrap(), dir.join("sample.svg").to_str().unwrap()]);
assert!(out.status.success());
assert!(!String::from_utf8_lossy(&out.stderr).contains("not drawn"), "{}", 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"] }
+239 -61
View File
@@ -1,41 +1,75 @@
//! Loopback JSON-lines control server: one request per line, one reply per line.
//! This is the transport the MCP server (`cadcraft-cli mcp --connect`) wraps.
//!
//! Lines are bounded ([`MAX_LINE`]): a longer one is refused and the connection closed, without
//! buffering the rest. So is the number of connections served at once ([`MAX_CONNECTIONS`]).
//! The server only reads requests: a line that is not a JSON object with a string `method` (an
//! HTTP request line, a stray `nc`, binary data) gets one error reply and the connection is
//! closed, so nothing sent after it runs. Every resource a client can hold is bounded
//! ([`Limits`]): simultaneous connections, request line length, queued requests, and how long a
//! socket may sit idle or blocked on a write.
use std::io::{BufRead, BufReader, Read, Write};
use std::io::{BufRead, BufReader, ErrorKind, Read, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::mpsc::{Receiver, Sender, channel};
use std::sync::mpsc::{Receiver, SyncSender, TrySendError, sync_channel};
use std::time::Duration;
use cadcraft_ui_egui::ControlRequest;
use serde_json::{Value, json};
/// Longest accepted request line (bytes, without the newline). Generous, so requests that carry
/// whole drawings or images as base64 still fit.
const MAX_LINE: usize = 256 * 1024 * 1024;
/// Connections served at once; further ones get an error line and are closed.
const MAX_CONNECTIONS: usize = 16;
/// Resource bounds for the control server.
#[derive(Clone, Copy, Debug)]
struct Limits {
/// Connections served at once; more get an error line and are closed.
max_clients: usize,
/// Longest request line in bytes (newline excluded); a longer one closes the connection.
max_line: usize,
/// Requests waiting for the app at once; more are answered `busy`.
queue: usize,
/// How long a connection may go without sending a byte before it is closed.
idle_timeout: Duration,
/// How long a reply write may block before the connection is closed.
write_timeout: Duration,
/// How long a request waits for the app's answer.
reply_timeout: Duration,
}
impl Default for Limits {
fn default() -> Self {
Self {
max_clients: 16,
// Generous, so requests that carry whole drawings or images as base64 still fit.
max_line: 256 * 1024 * 1024,
queue: 64,
idle_timeout: Duration::from_secs(600),
write_timeout: Duration::from_secs(30),
reply_timeout: Duration::from_secs(60),
}
}
}
pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
let (tx, rx) = channel::<ControlRequest>();
let listener = match TcpListener::bind(("127.0.0.1", port)) {
Ok(l) => l,
match TcpListener::bind(("127.0.0.1", port)) {
Ok(l) => {
log::info!("control server listening on 127.0.0.1:{port}");
serve_listener(l, Limits::default(), ctx)
}
Err(e) => {
log::error!("control server failed to bind 127.0.0.1:{port}: {e}");
return rx;
sync_channel::<ControlRequest>(0).1
}
};
log::info!("control server listening on 127.0.0.1:{port}");
std::thread::spawn(move || accept_loop(listener, tx, ctx));
}
}
/// Accept connections on `listener` under `limits`; requests arrive on the returned receiver.
fn serve_listener(listener: TcpListener, limits: Limits, ctx: egui::Context) -> Receiver<ControlRequest> {
let (tx, rx) = sync_channel::<ControlRequest>(limits.queue.max(1));
std::thread::spawn(move || accept_loop(listener, tx, ctx, limits));
rx
}
/// A connection's place among [`MAX_CONNECTIONS`], given back when the connection ends (also when
/// its thread can't be started, or panics).
/// A connection's place among [`Limits::max_clients`], given back when the connection ends (also
/// when its thread can't be started, or panics).
struct Slot(Arc<AtomicUsize>);
impl Drop for Slot {
@@ -44,10 +78,11 @@ impl Drop for Slot {
}
}
fn accept_loop(listener: TcpListener, tx: Sender<ControlRequest>, ctx: egui::Context) {
fn accept_loop(listener: TcpListener, tx: SyncSender<ControlRequest>, ctx: egui::Context, limits: Limits) {
let open = Arc::new(AtomicUsize::new(0));
for mut stream in listener.incoming().flatten() {
if open.fetch_add(1, Ordering::SeqCst) >= MAX_CONNECTIONS {
let _ = stream.set_write_timeout(Some(limits.write_timeout));
if open.fetch_add(1, Ordering::SeqCst) >= limits.max_clients {
open.fetch_sub(1, Ordering::SeqCst);
let _ = writeln!(stream, "{}", json!({"ok": false, "error": "too many control connections"}));
continue;
@@ -57,7 +92,7 @@ fn accept_loop(listener: TcpListener, tx: Sender<ControlRequest>, ctx: egui::Con
let ctx = ctx.clone();
let _ = std::thread::Builder::new().spawn(move || {
let _slot = slot;
serve(stream, tx, ctx);
serve(stream, &tx, &ctx, limits);
});
}
}
@@ -68,7 +103,11 @@ fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec<u8>, max: usize) -
buf.clear();
// `take` bounds the bytes buffered for one line: a peer can't make us allocate more than `max`.
let limit = u64::try_from(max).unwrap_or(u64::MAX).saturating_add(1);
let n = reader.take(limit).read_until(b'\n', buf).map_err(|e| e.to_string())?;
let n = reader.take(limit).read_until(b'\n', buf).map_err(|e| match e.kind() {
// The idle timeout ran out (Windows reports it as TimedOut, Unix as WouldBlock).
ErrorKind::TimedOut | ErrorKind::WouldBlock => "request line not completed in time; closing the connection".to_string(),
_ => e.to_string(),
})?;
if n == 0 {
return Ok(false);
}
@@ -86,48 +125,96 @@ fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec<u8>, max: usize) -
Ok(true)
}
fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context) {
/// One request line, parsed.
enum Line {
Request {
id: Value,
method: String,
params: Value,
},
Blank,
/// Not a request: reply with this error and close the connection.
Reject(String),
}
fn parse_line(bytes: &[u8]) -> Line {
let Ok(text) = std::str::from_utf8(bytes) else {
return Line::Reject("not a control request (invalid UTF-8); closing the connection".into());
};
let text = text.trim();
if text.is_empty() {
return Line::Blank;
}
if is_http_request_line(text) {
return Line::Reject("HTTP is not served here: this port takes JSON lines; closing the connection".into());
}
let msg = match serde_json::from_str::<Value>(text) {
Ok(v) => v,
Err(e) => return Line::Reject(format!("bad JSON ({e}); expected one JSON request object per line; closing the connection")),
};
let Some(obj) = msg.as_object() else {
return Line::Reject("not a control request (expected a JSON object); closing the connection".into());
};
let Some(method) = obj.get("method").and_then(Value::as_str) else {
return Line::Reject("not a control request (missing string `method`); closing the connection".into());
};
Line::Request {
id: obj.get("id").cloned().unwrap_or(Value::Null),
method: method.to_string(),
params: obj.get("params").cloned().unwrap_or(json!({})),
}
}
/// An HTTP/1 request line (`POST / HTTP/1.1`) or the HTTP/2 preface (`PRI * HTTP/2.0`).
fn is_http_request_line(text: &str) -> bool {
const METHODS: [&str; 10] = ["GET", "HEAD", "POST", "PUT", "DELETE", "CONNECT", "OPTIONS", "TRACE", "PATCH", "PRI"];
text.split_once(' ').is_some_and(|(method, _)| METHODS.contains(&method))
|| text.rsplit_once(' ').is_some_and(|(_, version)| version.starts_with("HTTP/"))
}
fn serve(stream: TcpStream, tx: &SyncSender<ControlRequest>, ctx: &egui::Context, limits: Limits) {
if stream.set_read_timeout(Some(limits.idle_timeout)).is_err() || stream.set_write_timeout(Some(limits.write_timeout)).is_err() {
return;
}
let Ok(read) = stream.try_clone() else { return };
let mut out = stream;
let mut reader = BufReader::new(read);
let mut buf = Vec::new();
loop {
match read_bounded_line(&mut reader, &mut buf, MAX_LINE) {
Ok(true) => {}
let reject = match read_bounded_line(&mut reader, &mut buf, limits.max_line) {
Ok(false) => break,
Err(e) => {
let _ = writeln!(out, "{}", json!({"ok": false, "error": e}));
break;
}
}
// Bytes that aren't UTF-8 end the connection, as `BufRead::lines` did.
let Ok(line) = std::str::from_utf8(&buf) else { break };
if line.trim().is_empty() {
continue;
}
let reply = match serde_json::from_str::<Value>(line) {
Ok(msg) => {
let id = msg.get("id").cloned().unwrap_or(Value::Null);
let method = msg.get("method").and_then(Value::as_str).unwrap_or("").to_string();
let params = msg.get("params").cloned().unwrap_or(json!({}));
let (req, pending) = ControlRequest::new(method, params);
if tx.send(req).is_err() {
break;
// Nothing of a line arrived (idle timeout, or the peer is gone): close without a word.
// A line nobody asked for would be read as the reply to the client's next request.
Err(_) if buf.is_empty() => break,
Err(e) => e,
Ok(true) => match parse_line(&buf) {
Line::Blank => continue,
Line::Reject(e) => e,
Line::Request { id, method, params } => {
let (req, pending) = ControlRequest::new(method, params);
let mut r = match tx.try_send(req) {
Ok(()) => {
ctx.request_repaint();
// On timeout the request is cancelled if the app hasn't started it, and
// the reply's `state` says whether it can still have run.
pending.wait(limits.reply_timeout)
}
Err(TrySendError::Full(_)) => json!({"ok": false, "error": "busy: too many queued requests", "state": "not-run"}),
Err(TrySendError::Disconnected(_)) => break,
};
if let Some(o) = r.as_object_mut() {
o.insert("id".into(), id);
}
if writeln!(out, "{r}").is_err() {
break;
}
continue;
}
ctx.request_repaint();
// On timeout the request is cancelled if the app hasn't started it, and the reply's
// `state` says whether it can still have run.
let mut r = pending.wait(Duration::from_secs(60));
if let Some(o) = r.as_object_mut() {
o.insert("id".into(), id);
}
r
}
Err(e) => json!({"ok": false, "error": format!("bad JSON: {e}")}),
},
};
if writeln!(out, "{reply}").is_err() {
break;
}
// The connection closes either way; a peer that can't read the reason loses nothing.
let _ = writeln!(out, "{}", json!({"ok": false, "error": reject}));
break;
}
}
@@ -158,12 +245,26 @@ mod tests {
assert_eq!(input.len(), 100 - 11, "the rest of the line is left unread");
}
/// The control server on a loopback port, with a stand-in UI that echoes each method.
fn serve_echo() -> std::net::SocketAddr {
fn short() -> Limits {
Limits {
queue: 1,
idle_timeout: Duration::from_millis(300),
write_timeout: Duration::from_millis(300),
reply_timeout: Duration::from_millis(300),
..Limits::default()
}
}
/// The control server on a loopback port under `limits`; requests arrive on the receiver.
fn serve_with(limits: Limits) -> (std::net::SocketAddr, Receiver<ControlRequest>) {
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
let addr = listener.local_addr().unwrap();
let (tx, rx) = channel::<ControlRequest>();
std::thread::spawn(move || accept_loop(listener, tx, egui::Context::default()));
(addr, serve_listener(listener, limits, egui::Context::default()))
}
/// The control server on a loopback port, with a stand-in UI that echoes each method.
fn serve_echo() -> std::net::SocketAddr {
let (addr, rx) = serve_with(Limits::default());
std::thread::spawn(move || {
for req in rx {
let _ = req.reply.send(json!({"ok": true, "method": req.method}));
@@ -172,6 +273,83 @@ mod tests {
addr
}
/// Send `input` on a new connection and read reply lines until the server closes it.
fn exchange(addr: std::net::SocketAddr, input: &[u8]) -> Vec<Value> {
let mut c = TcpStream::connect(addr).unwrap();
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
c.write_all(input).unwrap();
let mut replies = Vec::new();
for line in BufReader::new(c).lines() {
// A read timeout here means the server kept the connection open.
let line = line.unwrap();
replies.push(serde_json::from_str(&line).unwrap());
}
replies
}
/// A browser can POST to the port: its request line closes the connection before the JSON
/// line in its body is read, so nothing is dispatched.
#[test]
fn an_http_request_closes_the_connection_before_its_body_is_dispatched() {
let (addr, rx) = serve_with(short());
let http = b"POST / HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: text/plain\r\n\r\n\
{\"id\":1,\"method\":\"cmdline.input\",\"params\":{\"text\":\"circle 0,0 5\"}}\n";
let replies = exchange(addr, http);
assert_eq!(replies.len(), 1, "{replies:?}");
assert_eq!(replies[0]["ok"], false);
assert!(replies[0]["error"].as_str().unwrap().contains("closing the connection"), "{replies:?}");
assert!(rx.try_recv().is_err(), "the body was dispatched");
}
#[test]
fn a_line_that_is_not_a_request_closes_the_connection() {
for junk in [&b"hello\n"[..], b"[1,2]\n", b"{\"id\":1}\n", b"{\"method\":5}\n", b"\xff\xfe\n", b"GET /x HTTP/1.1\n"] {
let addr = serve_echo();
let mut input = junk.to_vec();
input.extend_from_slice(b"{\"id\":2,\"method\":\"ping\"}\n");
let replies = exchange(addr, &input);
assert_eq!(replies.len(), 1, "{replies:?}");
assert_eq!(replies[0]["ok"], false);
}
assert!(is_http_request_line("OPTIONS * HTTP/1.1"));
assert!(is_http_request_line("PRI * HTTP/2.0"));
assert!(!is_http_request_line("{\"method\":\"GET /\"}"));
}
#[test]
fn an_idle_connection_is_closed() {
let (addr, _rx) = serve_with(short());
let mut c = TcpStream::connect(addr).unwrap();
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
let t = std::time::Instant::now();
let mut got = Vec::new();
c.read_to_end(&mut got).unwrap();
assert!(got.is_empty(), "{}", String::from_utf8_lossy(&got));
assert!(t.elapsed() < Duration::from_secs(4));
}
#[test]
fn a_full_queue_answers_busy() {
let (addr, rx) = serve_with(short());
// No UI drains the one-place queue: the first request fills it, the second finds it full.
let first = std::thread::spawn(move || exchange(addr, b"{\"id\":1,\"method\":\"m\"}\n"));
let deadline = std::time::Instant::now() + Duration::from_secs(4);
let busy = loop {
let mut c = TcpStream::connect(addr).unwrap();
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
c.write_all(b"{\"id\":2,\"method\":\"m\"}\n").unwrap();
let mut line = String::new();
BufReader::new(c).read_line(&mut line).unwrap();
let v: Value = serde_json::from_str(&line).unwrap();
if v["error"].as_str().is_some_and(|e| e.starts_with("busy")) || std::time::Instant::now() > deadline {
break v;
}
};
assert_eq!((busy["id"].as_i64(), busy["state"].as_str()), (Some(2), Some("not-run")), "{busy}");
assert_eq!(first.join().unwrap()[0]["state"], "not-run");
drop(rx);
}
#[test]
fn requests_are_answered_with_their_ids() {
let addr = serve_echo();
@@ -191,7 +369,7 @@ mod tests {
fn extra_connections_are_refused() {
let addr = serve_echo();
// Idle connections are accepted in order and held open.
let held: Vec<TcpStream> = (0..MAX_CONNECTIONS).map(|_| TcpStream::connect(addr).unwrap()).collect();
let held: Vec<TcpStream> = (0..Limits::default().max_clients).map(|_| TcpStream::connect(addr).unwrap()).collect();
let mut extra = TcpStream::connect(addr).unwrap();
extra.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
let mut line = String::new();
+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);
}
}
+25
View File
@@ -48,3 +48,28 @@ 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
[people.C00kieMonsta]
real_name = "Antoine Boxho"
display_name = "Antoine Boxho"
sync_github_name = true
[people.Silvertree2010]
display_name = "Netter"
sync_github_name = false
[people.gurparvez]
real_name = "Gurparvez"
+9 -1
View File
@@ -22,7 +22,7 @@ impl Rgb {
}
pub fn parse_hex(s: &str) -> Option<Rgb> {
let s = s.trim().trim_start_matches('#');
if s.len() != 6 || !s.is_ascii() {
if s.len() != 6 || !s.chars().all(|c| c.is_ascii_hexdigit()) {
return None;
}
u32::from_str_radix(s, 16).ok().map(Rgb::from_u32)
@@ -225,6 +225,14 @@ pub fn display_rgb(c: Rgb, aci7: bool, background: Rgb) -> Rgb {
mod tests {
use super::*;
#[test]
fn parse_hex_rejects_non_hex_digits() {
assert_eq!(Rgb::parse_hex("+12345"), None);
assert_eq!(Rgb::parse_hex("-12345"), None);
assert_eq!(Color::parse("#+12345"), None);
assert_eq!(Rgb::parse_hex("#012345"), Some(Rgb(1, 35, 69)));
}
#[test]
fn palette_known_entries() {
assert_eq!(aci_rgb(1), Rgb(255, 0, 0));
+44
View File
@@ -185,6 +185,43 @@ pub fn names(e: &Expr, out: &mut Vec<String>) {
}
}
/// `text` with every whole-name use of `old` replaced by `new`; numbers (`1e3`), other names
/// (`d10` for `d1`) and spacing are left as written.
pub fn rename(text: &str, old: &str, new: &str) -> String {
let b = text.as_bytes();
let mut out = String::with_capacity(text.len());
let mut i = 0;
while let Some(&c) = b.get(i) {
let start = i;
if c.is_ascii_digit() || c == b'.' {
while b.get(i).is_some_and(|c| c.is_ascii_digit() || *c == b'.') {
i += 1;
}
if b.get(i).is_some_and(|c| *c == b'e' || *c == b'E') {
let mut j = i + 1;
if b.get(j).is_some_and(|c| *c == b'+' || *c == b'-') {
j += 1;
}
if b.get(j).is_some_and(u8::is_ascii_digit) {
i = j;
while b.get(i).is_some_and(u8::is_ascii_digit) {
i += 1;
}
}
}
} else if c.is_ascii_alphabetic() || c == b'_' {
while b.get(i).is_some_and(|c| c.is_ascii_alphanumeric() || *c == b'_') {
i += 1;
}
} else {
i += text.get(i..).and_then(|r| r.chars().next()).map_or(1, char::len_utf8);
}
let token = text.get(start..i).unwrap_or_default();
out.push_str(if token == old { new } else { token });
}
out
}
/// Evaluate with a variable lookup.
pub fn eval_with(e: &Expr, lookup: &mut dyn FnMut(&str) -> Result<f64, ExprError>) -> Result<f64, ExprError> {
let v = match e {
@@ -314,6 +351,13 @@ mod tests {
assert!(chain.value("p0").is_err());
}
#[test]
fn rename_replaces_whole_names_only() {
assert_eq!(rename("d1+d10*d1 / (d1_x+2e1)", "d1", "w"), "w+d10*w / (d1_x+2e1)");
assert_eq!(rename("1e2+e", "e", "x"), "1e2+x");
assert_eq!(rename("é+a", "a", "b"), "é+b");
}
#[test]
fn names_valid() {
assert!(valid_name("d1") && valid_name("_w") && !valid_name("1d") && !valid_name("a-b") && !valid_name("") && !valid_name("pi"));
+67
View File
@@ -428,6 +428,46 @@ pub fn residuals(d: &Drawing) -> Vec<(u32, Option<f64>)> {
.collect()
}
/// Put back what each Fix locks where `before` had it, and report whether anything moved.
///
/// A Fix keeps no position of its own: the solver locks the parameters at their current values.
/// Without this, an edit that moves a fixed point (a grip drag) would carry the lock along with
/// it. Only Fixes that already existed in `before`, on objects of the same shape, are restored.
pub fn restore_fixed(d: &mut Drawing, before: &Drawing) -> bool {
let refs: Vec<GeomRef> = d
.constraints
.iter()
.filter(|c| c.kind == ConstraintKind::Fix && before.constraints.contains(c))
.flat_map(|c| c.refs.iter().copied())
.collect();
if refs.is_empty() {
return false;
}
let handles: Vec<Handle> = refs.iter().map(|r| r.handle).collect();
let (now, old) = (Model::build(d, &handles), Model::build(before, &handles));
let mut x = now.x0.clone();
let mut changed = false;
for r in &refs {
let same_shape = matches!((now.slot(r.handle), old.slot(r.handle)), (Some(a), Some(b)) if a.shape == b.shape);
let (Some(i), Some(j)) = (now.lock_indices(r), old.lock_indices(r)) else { continue };
if !same_shape {
continue;
}
for (i, j) in i.into_iter().zip(j) {
if let (Some(slot), Some(&v)) = (x.get_mut(i), old.x0.get(j))
&& *slot != v
{
*slot = v;
changed = true;
}
}
}
if changed {
now.write_back(d, &x);
}
changed
}
/// True when every interpretable constraint holds at the current geometry.
pub fn all_satisfied(d: &Drawing) -> bool {
let handles: Vec<Handle> = d.constraints.iter().flat_map(|c| c.refs.iter().map(|r| r.handle)).collect();
@@ -659,6 +699,33 @@ pub fn set_parameter(d: &mut Drawing, name: &str, expr_text: &str, opts: &SolveO
Ok(report)
}
/// Rename a parameter (dimensional constraint name or user parameter) and every expression that
/// uses it. Geometry doesn't change. On error the drawing is unchanged.
pub fn rename_parameter(d: &mut Drawing, old: &str, new: &str) -> Result<(), SolveError> {
if !name_in_use(d, old) {
return Err(SolveError::Other(format!("no parameter `{old}`")));
}
if !expr::valid_name(new) {
return Err(SolveError::Other(format!("invalid parameter name `{new}`")));
}
if new != old && name_in_use(d, new) {
return Err(SolveError::Other(format!("`{new}` is already in use")));
}
for c in d.constraints.iter_mut().filter(|c| c.kind.is_dimensional()) {
if c.name == old {
c.name = new.to_string();
}
c.expr = expr::rename(&c.expr, old, new);
}
for p in &mut d.parametric.parameters {
if p.name == old {
p.name = new.to_string();
}
p.expr = expr::rename(&p.expr, old, new);
}
Ok(())
}
/// Where to draw a constraint's glyphs (one anchor per reference).
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
+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 => {
+21 -7
View File
@@ -40,6 +40,11 @@ pub(crate) enum Form {
C1,
/// Arc sweep (angular on an arc).
ArcSweep,
/// Two lines measured along the rays from their intersection to each line's far end, the
/// angle the dimension draws; `rev` marks a line whose far end is its start point.
Rays {
rev: [bool; 2],
},
/// Fix of a derived point (residual to an anchor).
Anchor(Vec2),
/// Fix handled by locking parameters (no rows).
@@ -92,6 +97,10 @@ fn dir_n(a: Vec2, b: Vec2) -> Vec2 {
if l > 1e-12 { d * (1.0 / l) } else { d }
}
fn ray(a: Vec2, b: Vec2, rev: bool) -> Vec2 {
if rev { dir_n(b, a) } else { dir_n(a, b) }
}
fn sgn(v: f64) -> f64 {
if v < 0.0 { -1.0 } else { 1.0 }
}
@@ -284,10 +293,15 @@ impl Built {
}
K::Angular => {
b.rows = 1;
if n == 2 && ln(0).is_some() && ln(1).is_some() {
b.form = Form::LL;
let (Some((a1, b1)), Some((a2, b2))) = (ln(0), ln(1)) else { return need("two lines") };
let (d1, d2) = (dir_n(a1, b1), dir_n(a2, b2));
if let (2, Some((a1, b1)), Some((a2, b2))) = (n, ln(0), ln(1)) {
// Start-to-end directions would measure the supplement when a line is drawn
// towards the vertex, so use the rays the dimension draws.
let rev = match cadcraft_geom::line_line_infinite(a1, b1, a2, b2) {
Some((_, t, u)) => [t > 0.5, u > 0.5],
None => [false, false],
};
b.form = Form::Rays { rev };
let (d1, d2) = (ray(a1, b1, rev[0]), ray(a2, b2, rev[1]));
b.sign = sgn(d1.cross(d2).atan2(d1.dot(d2)));
} else if n == 1 && m.arc_sweep(x, r_at(c, 0).ok_or_else(|| "Angular needs references".to_string())?).is_some() {
b.form = Form::ArcSweep;
@@ -297,7 +311,7 @@ impl Built {
b.target = target.to_radians();
// Two lines measure a signed angle in [-180, 180] and the sign is re-read from the geometry, so a larger
// target would flip the sign once solved and never be satisfied; only an arc sweep can reach a full turn.
let max = if b.form == Form::LL { std::f64::consts::PI } else { std::f64::consts::TAU };
let max = if matches!(b.form, Form::Rays { .. }) { std::f64::consts::PI } else { std::f64::consts::TAU };
if !b.target.is_finite() || b.target.abs() > max {
return Err("angle out of range".into());
}
@@ -461,9 +475,9 @@ impl Built {
(K::Angular, Form::ArcSweep) => {
out.push(m.arc_sweep(x, r(0)?)? - self.target);
}
(K::Angular, _) => {
(K::Angular, Form::Rays { rev }) => {
let ((a1, b1), (a2, b2)) = (ln(0)?, ln(1)?);
let (d1, d2) = (dir_n(a1, b1), dir_n(a2, b2));
let (d1, d2) = (ray(a1, b1, rev[0]), ray(a2, b2, rev[1]));
out.push(wrap(d1.cross(d2).atan2(d1.dot(d2)) - self.sign * self.target));
}
(K::Radius, _) => out.push(ci(0)?.1 - self.target),
+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);
}
}
}
+215
View File
@@ -0,0 +1,215 @@
//! 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::{DynamicBlock, 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>,
}
/// Which ends of two-point parameter `node` the block's actions follow, as (base, end): a
/// connection to one point's change (BaseXDelta, EndYDelta, UpdatedEnd…) follows that end, one to
/// the value itself (a distance, an angle) follows both. None when no action is attached to the
/// parameter: its grips are all kept, as the block has them.
fn ends_used(m: &DynamicBlock, node: i32) -> Option<(bool, bool)> {
let mut used: Option<(bool, bool)> = None;
for (n, name) in m.actions.iter().flat_map(|a| a.connections.iter()) {
if *n != node {
continue;
}
let c = name.to_ascii_lowercase();
let (base, end) = used.unwrap_or((false, false));
used = Some(if c.starts_with("base") || c.starts_with("updatedbase") {
(true, end)
} else if c.starts_with("end") || c.starts_with("updatedend") {
(base, true)
} else {
(true, true)
});
}
used
}
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;
}
}
// A grip shows in the current visibility state when the state lists it, or lists its
// parameter (AutoCAD shows a listed parameter's grips though the grips aren't listed).
let param_handle: HashMap<i32, Handle> = m.parameters.iter().map(|p| (p.node, p.handle)).collect();
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)) || param_handle.get(&node).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, from_midpoint, .. } => {
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) };
// An end no action follows has no grip (dragging it would change nothing); a
// distance from its midpoint moves both ends.
let used = match ends_used(&m, p.node) {
Some((base, end)) if !*from_midpoint => [base, end],
_ => [true, true],
};
for (i, part, at, way) in [(0, GripPart::Base, b, u * -1.0), (1, GripPart::End, e, u)] {
if used[i] && 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()
}
}
+38 -1
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 {
@@ -110,12 +142,14 @@ impl Drawing {
d.header = Header::imperial();
d
}
/// An empty metric drawing (millimetres, ISO-25 dimensions).
/// An empty metric drawing (millimetres, ISO-25 dimensions, multileaders and tables to match).
pub fn new_metric() -> Self {
let mut d = Drawing::bare();
d.header = Header::metric();
d.scales = metric_scales();
d.dim_styles.push(DimStyle::iso25());
d.mleader_styles = vec![MLeaderStyle::metric()];
d.table_styles = vec![TableStyle::metric()];
d.header.set_str("DIMSTYLE", "ISO-25");
d
}
@@ -148,6 +182,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;
}
}
+22
View File
@@ -616,6 +616,13 @@ impl Default for MLeaderStyle {
}
}
impl MLeaderStyle {
/// The Standard style for metric drawings: text and arrows as high as ISO-25's.
pub fn metric() -> Self {
MLeaderStyle { arrow_size: 2.5, text_height: 2.5, landing_gap: 0.625, dogleg: 5.0, ..Default::default() }
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct TableStyle {
@@ -636,6 +643,13 @@ impl Default for TableStyle {
}
}
impl TableStyle {
/// The Standard style for metric drawings: text as high as ISO-25's.
pub fn metric() -> Self {
TableStyle { text_height: 2.5, margin: 1.0, ..Default::default() }
}
}
mod cellstyle;
mod tablebreak;
pub use cellstyle::*;
@@ -747,6 +761,12 @@ 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,
/// The layout's paper units are millimetres (plot paper units 1 in DXF) whatever the
/// drawing's MEASUREMENT says; otherwise paper units follow MEASUREMENT (#1058).
pub paper_mm: bool,
}
impl Default for PageSetup {
@@ -765,6 +785,8 @@ impl Default for PageSetup {
center: false,
lineweights: true,
show_plot_styles: false,
plot_transparency: false,
paper_mm: false,
}
}
}
+115
View File
@@ -16,6 +16,23 @@ fn new_drawing_has_standard_tables() {
assert!(m.dim_style("ISO-25").is_some());
}
#[test]
fn new_metric_drawing_has_metric_multileader_and_table_styles() {
let m = Drawing::new_metric();
let iso = m.dim_style("ISO-25").unwrap();
let ml = m.mleader_styles.iter().find(|s| s.name == "Standard").unwrap();
assert_eq!((ml.text_height, ml.arrow_size), (iso.text_height, iso.arrow_size));
assert_eq!(ml.dogleg, 5.0);
let ts = m.table_styles.iter().find(|s| s.name == "Standard").unwrap();
assert_eq!(ts.text_height, iso.text_height);
assert_eq!(iso.text_height, 2.5);
let i = Drawing::new_imperial();
assert_eq!(i.mleader_styles, vec![MLeaderStyle::default()]);
assert_eq!(i.table_styles, vec![TableStyle::default()]);
assert_eq!((i.mleader_styles[0].text_height, i.table_styles[0].text_height), (0.18, 0.18));
}
#[test]
fn add_and_extents() {
let mut d = Drawing::new_imperial();
@@ -79,6 +96,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 +339,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 +456,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 +570,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"
+506 -6
View File
@@ -61,14 +61,39 @@ 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 mut notes = read_notes(&outcome.stats);
let mut doc = outcome.document;
current_style_names(&mut doc);
table_merges(&mut doc);
table_style_rows(&mut doc, false);
dimension_flags(&mut doc, false);
let orphans = drop_orphaned_dgn_components(&mut doc);
if orphans > 0 {
let (s, were) = if orphans == 1 { ("", "was") } else { ("s", "were") };
notes.push(format!("DWG: {orphans} DGN line style component{s} that no line style definition uses {were} left out"));
}
let mut out = CappedWriter { buf: Vec::new(), limit: limits.max_dxf_bytes, exceeded: false };
let written = std::panic::catch_unwind(AssertUnwindSafe(|| acadrust::DxfWriter::new(&doc).write_to_writer(&mut out)))
.map_err(|_| "the DWG→DXF conversion failed on this file".to_string())?;
@@ -81,7 +106,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,11 +272,127 @@ mod native {
}
}
/// The DWG dimension flags byte. Its bit 0 is the opposite of DXF group 70's bit 128 (set: the
/// text is at its default position) and its bit 1 is group 70's bit 32 (ODA DWG
/// specification, common dimension data), while acadrust 0.6.4 reads and writes bit 0 as
/// `text_user_positioned` itself and keeps no bit 32 from DXF. Without this, every dimension
/// of a DWG CADCraft writes is user-positioned for AutoCAD (#1094), and every dimension of an
/// AutoCAD DWG is read as user-positioned. `to_dwg` sets the fields up for the writer:
/// bit 32 as CADCraft's DXF writer always sets it, and bit 0 through the inverted field;
/// otherwise the field is read from bit 0 after a DWG is read.
fn dimension_flags(doc: &mut acadrust::CadDocument, to_dwg: bool) {
for e in doc.entities_mut() {
let acadrust::entities::EntityType::Dimension(d) = e else { continue };
let b = d.base_mut();
if to_dwg {
b.dwg_flags_byte |= 0x02;
b.text_user_positioned = !b.text_user_positioned;
} else {
b.text_user_positioned = b.dwg_flags_byte & 0x01 == 0;
}
}
}
/// Leave out DGN line style components (`LS*COMPONENT`) that no line style definition uses
/// (#1482). A drawing that once came from MicroStation can carry hundreds of thousands of
/// them, orphaned copies piled up by earlier DGN imports and copy-pastes (AutoCAD's PURGE
/// removes them as orphaned data): in a real 83 MB site plan they were 888,483 of 889,053
/// components and made its DXF rendition 1.2 GB, over [`Limits::DEFAULT`], so the file
/// didn't open at all. No definition that was read refers to them (a definition the reader
/// skipped can't use them either) and CADCraft doesn't draw DGN line styles, so nothing
/// visible is lost.
/// Every component a definition reaches (directly, or through compound and point
/// components) is kept, as are the definitions; dictionary entries that named a dropped
/// component go with it. Returns how many were left out.
fn drop_orphaned_dgn_components(doc: &mut acadrust::CadDocument) -> usize {
use acadrust::objects::{DgnLineStyleData, ObjectType};
use std::collections::{HashMap, HashSet};
// Each component's references, and the definitions' root components to start from.
let mut refs: HashMap<acadrust::Handle, Vec<acadrust::Handle>> = HashMap::new();
let mut stack: Vec<acadrust::Handle> = Vec::new();
for (h, o) in &doc.objects {
let ObjectType::DgnLineStyle(ls) = o else { continue };
match &ls.data {
DgnLineStyleData::Definition { root_component, .. } => stack.push(*root_component),
DgnLineStyleData::Component { component, .. } => {
refs.insert(*h, component.references());
}
DgnLineStyleData::Registered { .. } => {}
}
}
if refs.is_empty() {
return 0;
}
// Each component is visited once, so cycles and shared subtrees end.
let mut used: HashSet<acadrust::Handle> = HashSet::new();
while let Some(h) = stack.pop() {
if let Some(r) = refs.get(&h)
&& used.insert(h)
{
stack.extend(r.iter().copied());
}
}
let orphans: HashSet<acadrust::Handle> = refs.into_keys().filter(|h| !used.contains(h)).collect();
if orphans.is_empty() {
return 0;
}
doc.objects.retain(|h, _| !orphans.contains(h));
doc.dgn_ls_components.retain(|h, _| !orphans.contains(h));
for o in doc.objects.values_mut() {
let ObjectType::Dictionary(d) = o else { continue };
if d.entries.iter().any(|(_, e)| orphans.contains(e)) {
d.entries.retain(|(_, e)| !orphans.contains(e));
let keys: HashSet<&str> = d.entries.iter().map(|(k, _)| k.as_str()).collect();
d.hard_owner_entries.retain(|k| keys.contains(k.as_str()));
}
}
orphans.len()
}
/// 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] {
[
("$ANGBASE", 50, h.angle_base.to_string()),
// DWG holds radians; `$ANGBASE` is in degrees.
("$ANGBASE", 50, h.angle_base.to_degrees().to_string()),
("$ANGDIR", 70, h.angle_direction.to_string()),
("$FILLETRAD", 40, h.fillet_radius.to_string()),
("$CHAMFERA", 40, h.chamfer_distance_a.to_string()),
@@ -225,6 +470,30 @@ mod native {
convert(dxf, Some(v))
}
/// The current dimension and text styles' names from their handles: a DWG stores the
/// handles, and the reader leaves the names at "Standard", so a drawing reopened from DWG
/// lost its current dimension style (#1051).
fn current_style_names(doc: &mut acadrust::CadDocument) {
let h = doc.header.current_dimstyle_handle;
if let Some(n) = doc.dim_styles.iter().find(|ds| ds.handle == h && !h.is_null()).map(|ds| ds.name.clone()) {
doc.header.current_dimstyle_name = n;
}
let h = doc.header.current_text_style_handle;
if let Some(n) = doc.text_styles.iter().find(|ts| ts.handle == h && !h.is_null()).map(|ts| ts.name.clone()) {
doc.header.current_text_style_name = n;
}
}
/// The current dimension style (`$DIMSTYLE`) by its name: the DXF gives the name, and the
/// handle read with it needn't be the style's in the document, in which case the DWG writer
/// fell back to Standard and AutoCAD opened the drawing with Standard current (#1051).
fn current_dimstyle(doc: &mut acadrust::CadDocument) {
let name = doc.header.current_dimstyle_name.clone();
if let Some(h) = doc.dim_styles.iter().find(|ds| ds.name.eq_ignore_ascii_case(&name)).map(|ds| ds.handle) {
doc.header.current_dimstyle_handle = h;
}
}
fn convert(dxf: &[u8], version: Option<acadrust::DxfVersion>) -> Result<Vec<u8>, String> {
let data = dxf.to_vec();
let mut doc = std::panic::catch_unwind(move || acadrust::DxfReader::from_reader(Cursor::new(data)).and_then(|r| r.read()))
@@ -238,15 +507,44 @@ mod native {
None if doc.version < MIN_DWG_VERSION => doc.version = MIN_DWG_VERSION,
None => {}
}
other_layout_owners(&mut doc);
table_style_rows(&mut doc, true);
dimension_flags(&mut doc, true);
drop_unwritable(&mut doc);
current_dimstyle(&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}"))
}
/// Entities of a layout other than the one in `*Paper_Space` are written owned by their block
/// (entity mode 0, with its handle). acadrust writes every paper-space entity with mode 1,
/// "paper space", which AutoCAD reads as the `*Paper_Space` block's: everything drawn on a
/// second layout landed on the first one, and the second stayed empty (#1057).
fn other_layout_owners(doc: &mut acadrust::CadDocument) {
let others: std::collections::HashSet<acadrust::Handle> = doc
.block_records
.iter()
.filter(|br| {
let n = br.name.to_ascii_uppercase();
n.len() > 12 && n.starts_with("*PAPER_SPACE") && n[12..].bytes().all(|b| b.is_ascii_digit())
})
.map(|br| br.handle)
.collect();
if others.is_empty() {
return;
}
for e in doc.entities_mut() {
let c = e.common_mut();
if others.contains(&c.owner_handle) {
c.entity_mode = Some(0);
}
}
}
}
#[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 +558,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())
}
@@ -288,4 +590,202 @@ mod tests {
assert!(dwg_to_dxf(b"AC1032 this is not a dwg file at all").is_err());
assert!(dwg_to_dxf(b"").is_err());
}
/// A DXF with one DIMENSION per (text, group 70, subclass, points) row.
#[cfg(not(target_arch = "wasm32"))]
fn dims_dxf(dims: &[(&str, i64, &str, &[(i32, [f64; 2])])]) -> String {
let mut s = String::from(" 0\nSECTION\n 2\nHEADER\n 9\n$ACADVER\n 1\nAC1032\n 0\nENDSEC\n 0\nSECTION\n 2\nENTITIES\n");
for (text, flags, sub, pts) in dims {
s += " 0\nDIMENSION\n 8\n0\n100\nAcDbEntity\n100\nAcDbDimension\n 10\n0\n 20\n0\n 30\n0\n";
s += &format!(" 11\n50\n 21\n-20\n 31\n0\n 70\n{flags}\n 1\n{text}\n 3\nStandard\n100\n{sub}\n");
for (code, [x, y]) in pts.iter() {
s += &format!("{code:>3}\n{x}\n{:>3}\n{y}\n{:>3}\n0\n", code + 10, code + 20);
}
}
s + " 0\nENDSEC\n 0\nEOF\n"
}
/// DWG stores DXF group 70's bit 128 inverted, in bit 0 of the dimension flags byte (set:
/// the text is at its default position), and bit 32 in bit 1 (ODA DWG specification, common
/// dimension data). The bytes are read back raw, so a reader that inverts bit 0 the same way
/// as the writer can't hide a wrong byte.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn dimension_text_position_flags_reach_the_dwg_as_autocad_reads_them() {
let linear: &[(i32, [f64; 2])] = &[(13, [0.0, 0.0]), (14, [100.0, 0.0])];
let radius: &[(i32, [f64; 2])] = &[(15, [10.0, 0.0])];
let angular: &[(i32, [f64; 2])] = &[(13, [0.0, 0.0]), (14, [10.0, 0.0]), (15, [0.0, 0.0]), (16, [0.0, 10.0])];
let kinds = [
(0, "AcDbAlignedDimension", linear),
(1, "AcDbAlignedDimension", linear),
(4, "AcDbRadialDimension", radius),
(2, "AcDb2LineAngularDimension", angular),
];
let mut dims = Vec::new();
for (ty, sub, pts) in kinds {
dims.push(("HOME", ty | 32, sub, pts));
dims.push(("MOVED", ty | 32 | 128, sub, pts));
}
let dwg = dxf_to_dwg(dims_dxf(&dims).as_bytes()).unwrap();
let doc = acadrust::DwgReader::from_stream(std::io::Cursor::new(&dwg)).read().unwrap();
let mut seen = Vec::new();
for e in doc.entities() {
let acadrust::entities::EntityType::Dimension(d) = e else { continue };
let (text, byte) = (d.base().text_override().unwrap_or_default(), d.base().dwg_flags_byte);
seen.push((text.to_string(), byte & 0x03));
assert_eq!(byte & 0x02, 0x02, "{text} {:?}: bit 32 of group 70 is lost (flags byte {byte:#04x})", d.base().dimension_type);
let default_pos = text == "HOME";
assert_eq!(byte & 0x01 != 0, default_pos, "{text} {:?}: flags byte {byte:#04x}", d.base().dimension_type);
}
for text in ["HOME", "MOVED"] {
assert_eq!(seen.iter().filter(|(t, _)| t == text).count(), 4, "{seen:?}");
}
// And CADCraft reads its own DWG back as it wrote it: 128 only on the moved texts.
let dxf = String::from_utf8(dwg_to_dxf(&dwg).unwrap()).unwrap();
let lines: Vec<&str> = dxf.lines().map(str::trim).collect();
let mut flags = Vec::new();
for rec in lines.chunks(2).collect::<Vec<_>>().split(|p| p.first() == Some(&"0")) {
let value = |code: &str| rec.iter().find(|p| p.first() == Some(&code)).and_then(|p| p.get(1).copied());
if let (Some(text @ ("HOME" | "MOVED")), Some(v)) = (value("1"), value("70")) {
flags.push((text, v.parse::<i64>().unwrap_or(-1) & 128));
}
}
assert_eq!(flags.len(), 8, "{flags:?}");
assert!(flags.iter().all(|(l, f)| (*f == 128) == (*l == "MOVED")), "{flags:?}");
}
/// The handles a DXF text refers to or gives its records (groups 5, 105 and 330–369).
#[cfg(not(target_arch = "wasm32"))]
fn dxf_handles(dxf: &str) -> std::collections::HashSet<u64> {
let lines: Vec<&str> = dxf.lines().collect();
lines
.chunks(2)
.filter_map(|p| match p {
[c, v] if matches!(c.trim().parse::<i32>(), Ok(5 | 105 | 330..=369)) => u64::from_str_radix(v.trim(), 16).ok(),
_ => None,
})
.collect()
}
/// Issue #1482: a DWG that once came from MicroStation can carry hundreds of thousands of DGN
/// line style components no definition uses (a real 83 MB site plan: 888,483 of 889,053, a
/// 1.2 GB DXF rendition over the limit, so it didn't open). They're left out with a note, and
/// their dictionary entries with them; the definition and every component it reaches —
/// through compound and point components, around a cycle — are kept.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn orphaned_dgn_line_style_components_are_left_out() {
use acadrust::objects::*;
let mut doc = acadrust::CadDocument::new();
let container = doc.allocate_handle();
let nod = doc.header.named_objects_dict_handle;
let Some(ObjectType::Dictionary(root)) = doc.objects.get_mut(&nod) else { panic!("no named object dictionary") };
root.entries.push(("ACAD_DGNLINESTYLECOMP".into(), container));
let pattern = || DgnLsStrokePattern {
has_iteration_limit: false,
is_single_segment: false,
iteration_limit: 0,
auto_phase: 0.0,
phase: 0.0,
phase_mode: DgnLsPhaseMode::Distance,
strokes: vec![DgnLsStroke {
is_dash: true,
bypass_corner: false,
can_be_scaled: true,
invert_at_origin: false,
invert_at_end: false,
length: 2.5,
start_width: 0.0,
end_width: 0.0,
width_mode: 0,
cap_mode: 0,
}],
};
let kind = |c: &DgnLsComponentData| match c {
DgnLsComponentData::Symbol(_) => DgnLsComponentType::Symbol,
DgnLsComponentData::Compound(_) => DgnLsComponentType::Compound,
DgnLsComponentData::Stroke(_) => DgnLsComponentType::Stroke,
DgnLsComponentData::Point(_) => DgnLsComponentType::Point,
DgnLsComponentData::Internal(_) => DgnLsComponentType::Internal,
};
let compound = |of: &[acadrust::Handle]| {
DgnLsComponentData::Compound(DgnLsCompoundComponent {
entries: of.iter().map(|&component| DgnLsCompoundEntry { component, offset: 0.0 }).collect(),
})
};
let mut entries = Vec::new();
let mut put = |doc: &mut acadrust::CadDocument, h: acadrust::Handle, data: DgnLineStyleData| {
entries.push((format!("{:X}", h.value()), h));
let reactors = vec![container];
doc.objects
.insert(h, ObjectType::DgnLineStyle(DgnLineStyleObject { handle: h, owner: container, reactors, xdictionary_handle: None, data }));
};
let component = |c: DgnLsComponentData| DgnLineStyleData::Component {
kind: kind(&c),
description: "dash".into(),
version: 1,
component_uid: [7; 16],
scale: 1.0,
property_flags: 0,
component: c,
properties: vec![],
};
let h: Vec<acadrust::Handle> = (0..9).map(|_| doc.allocate_handle()).collect();
let [def, root, stroke, point, back, orphan, orphan_cycle, internal, shared] = h[..] else { panic!("nine handles") };
put(
&mut doc,
def,
DgnLineStyleData::Definition {
description: "{ dash }".into(),
version: 1,
style_number: 1,
component_uid: [1; 16],
is_continuous: false,
unit_definition: 1.0,
unit_scale: 1.0,
units_type: 0,
is_element: false,
is_physical: false,
is_scale_independent: false,
is_snappable: false,
root_component: root,
properties: vec![],
},
);
// Used: def → root → {stroke, point → shared, back → root (a cycle)}.
put(&mut doc, root, component(compound(&[stroke, point, back])));
put(&mut doc, stroke, component(DgnLsComponentData::Stroke(pattern())));
put(&mut doc, point, component(DgnLsComponentData::Point(DgnLsPointComponent { stroke_component: shared, symbols: vec![] })));
put(&mut doc, back, component(compound(&[root])));
put(&mut doc, shared, component(DgnLsComponentData::Stroke(pattern())));
// Orphans: an internal component, and two compounds that refer to each other and to a
// used component (which stays).
let pattern_internal =
DgnLsInternalComponent { pattern: pattern(), internal_version: 0, hardware_style: 0, is_hardware_style: false, line_code: 0 };
put(&mut doc, internal, component(DgnLsComponentData::Internal(pattern_internal)));
put(&mut doc, orphan, component(compound(&[orphan_cycle, shared])));
put(&mut doc, orphan_cycle, component(compound(&[orphan])));
let mut dict = Dictionary::new();
dict.handle = container;
dict.owner = nod;
dict.entries = entries;
doc.objects.insert(container, ObjectType::Dictionary(dict));
let dwg = acadrust::DwgWriter::write_to_vec(&doc).unwrap();
let (dxf, notes) = dwg_to_dxf_noted(&dwg, Limits::DEFAULT).unwrap();
let dxf = String::from_utf8(dxf).unwrap();
let handles = dxf_handles(&dxf);
for kept in [def, root, stroke, point, back, shared] {
assert!(handles.contains(&kept.value()), "{:X} was left out", kept.value());
}
for gone in [internal, orphan, orphan_cycle] {
assert!(!handles.contains(&gone.value()), "{:X} was kept or is still referred to", gone.value());
}
let lines: Vec<&str> = dxf.lines().collect();
let ls: Vec<&str> = lines.chunks(2).filter_map(|p| p.get(1).filter(|_| p[0].trim() == "0" && p[1].starts_with("LS")).copied()).collect();
assert_eq!(ls.iter().filter(|t| t.ends_with("COMPONENT")).count(), 5, "{ls:?}");
assert!(ls.contains(&"LSDEFINITION") && !ls.contains(&"LSINTERNALCOMPONENT"), "{ls:?}");
assert_eq!(notes, ["DWG: 3 DGN line style components that no line style definition uses were left out"]);
}
}
+1
View File
@@ -16,6 +16,7 @@ cadcraft-dxf = { workspace = true }
cadcraft-constraints = { workspace = true }
cadcraft-fonts = { workspace = true }
cadcraft-render = { workspace = true }
craft-docpath = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
+6
View File
@@ -82,6 +82,12 @@ impl Report {
"message": self.message(),
})
}
/// Format this report for an AUDITCTL `.adt` log file.
pub fn format_adt(&self, title: &str, path: Option<&str>) -> String {
let name = path.unwrap_or(title);
format!("Audit of drawing: {name}\n\n{}\n", self.message())
}
}
/// Audit `d` without changing it.
+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(())
}
}
+6 -8
View File
@@ -260,18 +260,16 @@ fn radial(s: &mut Session, p: &Value, diameter: bool) -> Result<Value> {
dim(s, DimKind::Radius, c, Vec2::ZERO, Vec2::ZERO, pt, Vec2::ZERO, "")
};
let k = match at {
Some(at) => text_inside(k, c, c.dist(pt), at),
Some(at) => text_at(k, at),
None => k,
};
Ok(json!({ "handle": add_dim(s, k)?.hex() }))
}
/// A radius/diameter dimension line location picked inside the circle puts the text there, on
/// the dimension line (outside, the text keeps its default place beyond the arrowhead).
fn text_inside(mut k: EntityKind, c: Vec2, r: f64, at: Vec2) -> EntityKind {
if let EntityKind::Dimension(d) = &mut k
&& at.dist(c) < r
{
/// The dimension line location of a radius/diameter dimension puts the text there, inside or
/// outside the circle, as a user-positioned text (DXF flag 70 bit 128), like AutoCAD.
fn text_at(mut k: EntityKind, at: Vec2) -> EntityKind {
if let EntityKind::Dimension(d) = &mut k {
d.text_mid = v3(at);
d.user_text_pos = true;
}
@@ -1106,7 +1104,7 @@ impl RadialM {
} else {
dim(s, DimKind::Radius, c, Vec2::ZERO, Vec2::ZERO, pt, Vec2::ZERO, "")
});
text_inside(k, c, r, at)
text_at(k, at)
}
}
+1 -1
View File
@@ -626,7 +626,7 @@ impl Interactive for RectM {
SpaceRows => dist("Specify the distance between rows", self.rs),
UnitCell => match self.corner {
None => Prompt::new("Specify first corner of the unit cell", Accept::POINT).base(self.base),
Some(c) => Prompt::new("Specify other corner", Accept::POINT).base(c),
Some(c) => Prompt::new("Specify other corner", Accept::POINT).corner(c),
},
ColsSpace => dist("Specify the distance between columns", self.cs).kw(&["Total"]),
ColsTotal => dist("Specify the distance between the first and last column", self.cs * self.cols.saturating_sub(1) as f64),
+26 -1
View File
@@ -84,7 +84,11 @@ fn run_audit(s: &mut Session, p: &Value) -> Result<Value> {
if fix && !issues.is_empty() {
s.state_mut()?.doc = Arc::new(copy);
}
Ok(crate::audit::Report { issues, fixed: fix }.to_json())
let report = crate::audit::Report { issues, fixed: fix };
if s.settings.auditctl {
write_audit_log(s, &report);
}
Ok(report.to_json())
}
/// Open a drawing file as tolerantly as the readers allow, then fix what AUDIT finds. The
@@ -119,12 +123,33 @@ fn run_recover(s: &mut Session, p: &Value) -> Result<Value> {
st.saved = Arc::new((*st.doc).clone());
}
let uid = st.uid;
if s.settings.auditctl {
write_audit_log(s, &report);
}
let mut r = report.to_json();
r["index"] = json!(i);
r["uid"] = json!(uid);
Ok(r)
}
#[cfg(not(target_arch = "wasm32"))]
fn write_audit_log(s: &Session, report: &crate::audit::Report) {
let (path_str, title) = match s.state() {
Ok(st) => (st.path.clone(), st.title.clone()),
Err(_) => (None, "Drawing".to_string()),
};
// An unsaved drawing has no folder of its own to hold the log.
let Some(path) = &path_str else { return };
let adt_path = std::path::PathBuf::from(path).with_extension("adt");
let content = report.format_adt(&title, path_str.as_deref());
if let Err(e) = std::fs::write(&adt_path, content) {
log::warn!("AUDITCTL: could not write audit log {}: {e}", adt_path.display());
}
}
#[cfg(target_arch = "wasm32")]
fn write_audit_log(_: &Session, _: &crate::audit::Report) {}
#[cfg(not(target_arch = "wasm32"))]
fn read_path(cmd: &str, path: &str) -> Result<Vec<u8>> {
std::fs::read(path).map_err(|e| bad(cmd, format!("{path}: {e}")))
+128 -10
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,10 @@ use super::*;
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
mod attedit;
mod burst;
mod dyngrip;
mod extract;
pub(crate) mod refedit;
pub fn specs() -> Vec<CommandSpec> {
vec![
@@ -42,6 +47,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")
@@ -50,11 +73,14 @@ pub fn specs() -> Vec<CommandSpec> {
battman::battman_spec(),
attribs::attdisp_spec(),
attribs::attsync_spec(),
burst::burst_spec(),
extract::dataextraction_spec(),
extract::eattext_spec(),
]
.into_iter()
.chain(xref::specs())
.chain(refedit::specs())
.chain(bedit::specs())
.collect()
}
@@ -84,8 +110,41 @@ fn references_block(d: &cadcraft_doc::Drawing, ents: &[Entity], name: &str) -> b
false
}
/// What BLOCK does with the selected objects once the block is defined (the dialog's Convert to
/// block, Retain and Delete).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Keep {
/// Replace the objects by a reference to the new block.
Convert,
/// Leave the objects in the drawing.
Retain,
/// Erase the objects.
Delete,
}
impl Keep {
/// The `keep` parameter's documented values, in any case; `None` for anything else.
pub(crate) fn parse(value: &str) -> Option<Keep> {
match value.trim().to_ascii_lowercase().as_str() {
"convert" => Some(Keep::Convert),
"retain" => Some(Keep::Retain),
"delete" => Some(Keep::Delete),
_ => None,
}
}
}
/// The `keep` parameter of `cmd`, `default` when absent. A value that is not documented is an
/// error (issue #935: a misspelt value used to erase the objects without converting them).
fn keep_param(cmd: &str, p: &Value, default: Keep) -> Result<Keep> {
match str_param(p, "keep") {
None => Ok(default),
Some(v) => Keep::parse(v).ok_or_else(|| bad(cmd, format!("`keep` must be \"convert\", \"retain\" or \"delete\", not {v:?}"))),
}
}
/// Create a block from entities. Returns the insert handle when converting.
pub(crate) fn make_block(s: &mut Session, name: &str, base: Vec2, hs: &[Handle], keep: &str, description: &str) -> Result<Option<Handle>> {
pub(crate) fn make_block(s: &mut Session, name: &str, base: Vec2, hs: &[Handle], keep: Keep, description: &str) -> Result<Option<Handle>> {
if !valid_block_name(name) {
return Err(bad("block", "invalid block name"));
}
@@ -97,6 +156,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();
@@ -109,13 +171,13 @@ pub(crate) fn make_block(s: &mut Session, name: &str, base: Vec2, hs: &[Handle],
b.entities.push(e);
}
doc.blocks.insert(name.to_string(), Arc::new(b));
if keep != "retain" {
if keep != Keep::Retain {
for h in hs {
doc.remove_entity(*h);
}
}
s.set_selection(Vec::new());
if keep == "convert" {
if keep == Keep::Convert {
let h = insert(s, name, base, 1.0, 0.0, &serde_json::Map::new())?;
return Ok(Some(h));
}
@@ -180,6 +242,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(),
});
}
}
@@ -190,8 +254,8 @@ fn run_block(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "name").ok_or_else(|| bad("block", "`name` is required"))?.to_string();
let base = point_param(p, "base").unwrap_or(Vec2::ZERO);
let hs = targets(s, p)?;
let keep = str_param(p, "keep").unwrap_or("convert").to_string();
let r = make_block(s, &name, base, &hs, &keep, str_param(p, "description").unwrap_or(""))?;
let keep = keep_param("block", p, Keep::Convert)?;
let r = make_block(s, &name, base, &hs, keep, str_param(p, "description").unwrap_or(""))?;
// An annotative block: its references (the converted selection too) are annotative.
if bool_or(p, "annotative", false) {
let d = s.doc_mut()?;
@@ -252,6 +316,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 +387,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 ----------------
@@ -412,7 +530,7 @@ impl BlockM {
fn finish(&mut self, s: &mut Session, hs: &[Handle]) -> Result<Step> {
let (Some(name), Some(base)) = (self.name.clone(), self.base) else { return Ok(Step::Cancel) };
// A definition that can't be made (e.g. one that would reference itself) ends the command.
let keep = if self.dash { "delete" } else { "convert" };
let keep = if self.dash { Keep::Delete } else { Keep::Convert };
if let Err(e) = make_block(s, &name, base, hs, keep, "") {
s.echo(e.to_string());
return Ok(Step::Done);
+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)
}
}
+57
View File
@@ -0,0 +1,57 @@
//! BURST (Express Tools): explode block references as EXPLODE does, but their visible attributes
//! become text with the values they show instead of the block's attribute definitions.
use cadcraft_color::Color;
use cadcraft_doc::{Common, Drawing, Entity, EntityKind, Handle, Insert};
use serde_json::{Value, json};
use crate::cmd::machines::SelectRun;
use crate::cmd::modify::{explode_kind, explode_with};
use crate::cmd::{CommandSpec, targets};
use crate::{Result, Session};
pub(super) fn burst_spec() -> CommandSpec {
CommandSpec::new("burst", "Explode Attributes to Text", run_burst)
.menu(&["Tools", "Express Tools", "Explode Attributes to Text"])
.params("{handles?} (block references; other objects are left alone) → {handles}")
.interactive(|_| Ok(Box::new(SelectRun::new("burst", "BURST"))))
}
fn run_burst(s: &mut Session, p: &Value) -> Result<Value> {
let hs = targets(s, p)?;
let r = explode_with(s, &hs, burst_pieces)?;
Ok(json!({ "handles": r.iter().map(|h| h.hex()).collect::<Vec<_>>() }))
}
/// What a block reference bursts into: EXPLODE's pieces without the attribute definitions, then
/// a text (multiline text for a multiline attribute) for each visible attribute.
fn burst_pieces(d: &Drawing, e: &Entity) -> Option<Vec<Entity>> {
let EntityKind::Insert(ins) = &e.kind else { return None };
let mut out: Vec<Entity> = explode_kind(d, e)?.into_iter().filter(|p| !matches!(p.kind, EntityKind::AttDef(_))).collect();
for a in ins.attribs.iter().filter(|a| !a.invisible) {
let kind = match a.multiline_text() {
Some(m) => EntityKind::MText(m),
None => EntityKind::Text(a.text.clone()),
};
out.push(Entity { handle: Handle(0), common: attrib_common(d, e, ins, &a.tag, &a.props), kind });
}
Some(out)
}
/// The properties an attribute is drawn with: its own, else its definition's; layer 0 is the
/// reference's layer and ByBlock colour the reference's colour, as EXPLODE gives the block's
/// objects.
fn attrib_common(d: &Drawing, e: &Entity, ins: &Insert, tag: &str, props: &cadcraft_doc::AttribProps) -> Common {
let def =
d.block(&ins.block).and_then(|b| b.entities.iter().find(|be| matches!(&be.kind, EntityKind::AttDef(ad) if ad.tag.eq_ignore_ascii_case(tag))));
// An attribute without a definition follows the reference.
let base = def.map(|de| de.common.clone()).unwrap_or_else(|| Common { color: Color::ByBlock, ..Common::default() });
let mut c = props.over(&base);
if c.layer == "0" {
c.layer.clone_from(&e.common.layer);
}
if c.color == Color::ByBlock {
c.color = e.common.color;
}
c
}
+200
View File
@@ -0,0 +1,200 @@
//! 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::color::Color;
use cadcraft_doc::dynblock::{self, GripPart, GripShape};
use cadcraft_doc::{Common, Drawing, Entity, EntityKind, Handle, Lineweight, Transparency};
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 properties an object of a block draws with in reference `r`: on layer 0 it is on the
/// reference's layer, and its ByBlock colour, linetype, lineweight and transparency are the
/// reference's (as the renderer draws a block's contents).
fn under(c: &Common, r: &Common) -> Common {
let mut c = c.clone();
if c.layer == "0" {
c.layer.clone_from(&r.layer);
}
if c.color == Color::ByBlock {
c.color = r.color;
}
if c.linetype.eq_ignore_ascii_case("ByBlock") {
c.linetype.clone_from(&r.linetype);
}
if c.lineweight == Lineweight::ByBlock {
c.lineweight = r.lineweight;
}
if c.transparency == Transparency::ByBlock {
c.transparency = r.transparency;
}
c
}
/// The reference `h` as drawn in drawing `d`: its block's shown objects placed by it, in the
/// block's order (fills under the outline and text), and its attributes, each with the
/// properties it draws with.
fn drawn(d: &Drawing, h: Handle) -> Vec<Entity> {
let Some(e) = d.entity(h) else { return Vec::new() };
let EntityKind::Insert(ins) = &e.kind else { return Vec::new() };
let Some(b) = d.block(&ins.block) else { return Vec::new() };
let r = &e.common;
let m = ins.transform(b.base.xy());
let mut out: Vec<Entity> = b
.entities
.iter()
.filter(|e| e.common.visible && !matches!(&e.kind, EntityKind::AttDef(_) | EntityKind::Wipeout(_)))
.take(MAX_PREVIEW)
.map(|e| {
let mut kind = e.kind.clone();
kind.transform(&m);
Entity { handle: Handle(0), common: under(&e.common, r), kind }
})
.collect();
// An attribute draws with its definition's properties, except those set on it.
let by_block = Common { layer: "0".into(), color: Color::ByBlock, linetype: "ByBlock".into(), lineweight: Lineweight::ByBlock, ..r.clone() };
out.extend(ins.attribs.iter().filter(|a| !a.invisible).map(|a| {
let def = b.entities.iter().find_map(|e| match &e.kind {
EntityKind::AttDef(ad) if ad.tag.eq_ignore_ascii_case(&a.tag) => Some(&e.common),
_ => None,
});
let common = under(&a.props.over(def.unwrap_or(&by_block)), r);
Entity { handle: Handle(0), common, kind: 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)
}
/// The rubber band from the grip.
fn preview(&self, _s: &Session, c: Vec2) -> Vec<EntityKind> {
vec![crate::cmd::helpers::line(self.from, c)]
}
/// The reference re-evaluated at the cursor, drawn as it will look (its own colours, so
/// text stays readable on a filled block).
fn preview_objects(&self, s: &Session, c: Vec2) -> Vec<Entity> {
let Ok(d) = s.doc() else { return Vec::new() };
let mut d = d.clone();
match dynblock::drag_grip(&mut d, self.handle, self.node, self.part, c) {
Ok(()) => drawn(&d, self.handle),
Err(_) => Vec::new(),
}
}
}
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)
}
}
+10 -13
View File
@@ -104,8 +104,8 @@ fn reference_count(d: &Drawing, key: &str) -> usize {
fn describe(d: &Drawing, b: &Block, host: Option<&str>) -> Value {
let refs = reference_count(d, &b.name);
let status = if refs == 0 && b.xref_status == XrefStatus::Loaded { "Unreferenced" } else { b.xref_status.label() };
let dir = host.and_then(|h| std::path::Path::new(h).parent().map(std::path::Path::to_path_buf));
let found = b.xref_path.as_deref().and_then(|p| load::find(p, dir.as_deref())).map(|f| f.to_string_lossy().into_owned());
let found =
b.xref_path.as_deref().and_then(|p| load::find(p, host.map(std::path::Path::new), false).ok()).map(|f| f.to_string_lossy().into_owned());
json!({
"name": b.name,
"path": b.xref_path,
@@ -146,7 +146,7 @@ fn attach(s: &mut Session, name: &str, path: &str, overlay: bool) -> Result<Stri
b.xref_path = Some(path.to_string());
b.xref_overlay = overlay;
d.blocks.insert(name.to_string(), Arc::new(b));
let out = load::load(&mut d, name, host.as_deref());
let out = load::load(&mut d, name, host.as_deref(), true);
match out.first() {
Some(o) if o.status == XrefStatus::Loaded => {}
Some(o) => return Err(bad("xattach", format!("{path} can't be attached: {}", o.detail))),
@@ -227,11 +227,7 @@ fn run_dash_xref(s: &mut Session, p: &Value) -> Result<Value> {
}
"reload" | "r" => {
let names = named(s, "-xref", p)?;
let mut out = Vec::new();
for n in &names {
let d = s.doc_mut()?;
out.extend(load::load(d, n, host.as_deref()));
}
let out = load::load_each(s.doc_mut()?, &names, host.as_deref(), false);
echo_all(s, &out);
Ok(
json!({ "xrefs": out.iter().filter(|o| !o.name.is_empty()).map(|o| json!({"name": o.name, "status": o.status.label(), "detail": o.detail})).collect::<Vec<_>>() }),
@@ -271,14 +267,13 @@ fn run_dash_xref(s: &mut Session, p: &Value) -> Result<Value> {
"path" | "p" => {
let path = str_param(p, "path").map(str::trim).filter(|t| !t.is_empty()).ok_or_else(|| bad("-xref", "`path` is required"))?.to_string();
let names = named(s, "-xref", p)?;
let mut out = Vec::new();
let d = s.doc_mut()?;
for n in &names {
let d = s.doc_mut()?;
if let Some(b) = d.blocks.get_mut(n) {
Arc::make_mut(b).xref_path = Some(path.clone());
}
out.extend(load::load(d, n, host.as_deref()));
}
let out = load::load_each(d, &names, host.as_deref(), true);
echo_all(s, &out);
Ok(json!({ "xrefs": names, "path": path }))
}
@@ -367,8 +362,10 @@ fn run_xopen(s: &mut Session, p: &Value) -> Result<Value> {
let b = d.block(&name).ok_or_else(|| bad("xopen", format!("no xref \"{name}\"")))?;
let path = b.xref_path.clone().ok_or_else(|| bad("xopen", format!("\"{name}\" is not an xref")))?;
let host = host(s);
let dir = host.as_deref().and_then(|h| std::path::Path::new(h).parent().map(std::path::Path::to_path_buf));
let file = load::find(&path, dir.as_deref()).ok_or_else(|| bad("xopen", format!("{path}: file not found")))?;
let file = load::find(&path, host.as_deref().map(std::path::Path::new), false).map_err(|m| match m {
load::Missing::NotFound => bad("xopen", format!("{path}: file not found")),
load::Missing::Refused(r) => bad("xopen", format!("{path}: not opened, {r}")),
})?;
s.execute("open", &json!({ "path": file.to_string_lossy() }))
}
+142 -60
View File
@@ -3,8 +3,10 @@
//! `xref|name`.
//!
//! A path is looked for as given (relative paths against the host drawing's folder), then by its
//! file name in the host drawing's folder. A referenced drawing's own attached xrefs load with it
//! (overlays don't), at most [`MAX_BLOCK_DEPTH`] levels deep; a drawing that is already being
//! file name in the host drawing's folder. A path stored in a drawing is followed only to this
//! computer or the host drawing's network share ([`crate::doc_path`]); one that isn't followed and
//! has no file of its name beside the host leaves the xref unresolved. A referenced drawing's own
//! attached xrefs load with it (overlays don't), at most [`MAX_BLOCK_DEPTH`] levels deep; a drawing that is already being
//! loaded further up (a circular reference) is not loaded again. Every failure leaves the xref's
//! block empty with a status saying why; nothing here stops the host drawing from opening.
@@ -13,6 +15,8 @@ use std::sync::Arc;
use cadcraft_doc::{Attrib, Drawing, Entity, EntityKind, EntityStore, MAX_BLOCK_DEPTH, XrefStatus};
use crate::doc_path::{self, Refused};
/// Most objects the xrefs of one drawing load in one go (nested xrefs included).
pub(crate) const MAX_XREF_OBJECTS: usize = 4_000_000;
/// Most referenced files read in one go (nested xrefs included).
@@ -20,6 +24,9 @@ const MAX_XREF_FILES: usize = 1000;
/// Largest referenced file read.
#[cfg(not(target_arch = "wasm32"))]
const MAX_XREF_BYTES: u64 = 512 << 20;
/// Most bytes read from referenced files in one go (nested xrefs included).
#[cfg(not(target_arch = "wasm32"))]
const MAX_XREF_TOTAL_BYTES: u64 = 1 << 30;
/// The kinds of named symbols an object can refer to.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
@@ -119,16 +126,38 @@ struct Ctx {
chain: Vec<PathBuf>,
objects: usize,
files: usize,
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
bytes: u64,
/// Messages about nested xrefs.
notes: Vec<String>,
}
/// The folder of the host drawing at `host` (a file path).
fn folder(host: Option<&str>) -> Option<PathBuf> {
let p = Path::new(host?).parent()?;
fn folder(host: Option<&Path>) -> Option<PathBuf> {
let p = host?.parent()?;
Some(if p.as_os_str().is_empty() { PathBuf::from(".") } else { p.to_path_buf() })
}
/// Why a referenced drawing wasn't found.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Missing {
NotFound,
/// The saved path isn't followed, and there is no file of its name beside the host.
Refused(Refused),
}
impl Missing {
fn outcome(self, path: &str) -> (XrefStatus, String) {
match self {
Self::NotFound => {
let why = if cfg!(target_arch = "wasm32") { "files are not available on the web" } else { "file not found" };
(XrefStatus::NotFound, format!("{path}: {why}"))
}
Self::Refused(r) => (XrefStatus::Unresolved, format!("{path}: not opened, {r}")),
}
}
}
#[cfg(not(target_arch = "wasm32"))]
fn canonical(p: &Path) -> PathBuf {
std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf())
@@ -138,58 +167,94 @@ fn canonical(p: &Path) -> PathBuf {
p.to_path_buf()
}
/// Where to look for a referenced drawing saved as `path`: the path itself (a relative one against
/// the host's folder), then its file name in the host's folder. Both separators work everywhere.
fn candidates(path: &str, dir: Option<&Path>) -> Vec<PathBuf> {
let norm = if cfg!(windows) { path.to_string() } else { path.replace('\\', "/") };
let p = PathBuf::from(norm.trim());
/// Where to look for a referenced drawing at `path` in the drawing at `host`: the path itself (a
/// relative one against the host's folder), then its file name in the host's folder. Both
/// separators work everywhere. A path stored in a drawing goes through [`doc_path::resolve`]
/// (the refusal is returned); a path the user just `chosen` is used as given.
fn candidates(path: &str, host: Option<&Path>, chosen: bool) -> (Vec<PathBuf>, Option<Refused>) {
let path = path.trim();
let mut out = Vec::new();
match dir {
Some(d) if !p.is_absolute() => out.push(d.join(&p)),
_ => out.push(p.clone()),
let mut refused = None;
if chosen {
let norm = if cfg!(windows) { path.to_string() } else { path.replace('\\', "/") };
let p = PathBuf::from(norm);
match folder(host) {
Some(d) if !p.is_absolute() => out.push(d.join(&p)),
_ => out.push(p),
}
} else {
match doc_path::resolve(path, host) {
Ok(p) => out.push(p),
Err(Refused::Invalid) => {}
Err(r) => refused = Some(r),
}
}
if let (Some(d), Some(f)) = (dir, p.file_name()) {
out.push(d.join(f));
let name = path.rsplit(['/', '\\']).next().unwrap_or(path);
if host.is_some()
&& !name.is_empty()
&& let Ok(p) = doc_path::resolve(name, host)
&& !out.contains(&p)
{
out.push(p);
}
out
(out, refused)
}
/// The file a referenced drawing saved as `path` is loaded from, if there is one.
pub(crate) fn find(path: &str, dir: Option<&Path>) -> Option<PathBuf> {
/// The file a referenced drawing at `path` (saved in the drawing at `host`, or just `chosen` by
/// the user) is loaded from.
pub(crate) fn find(path: &str, host: Option<&Path>, chosen: bool) -> Result<PathBuf, Missing> {
if path.trim().is_empty() {
return None;
return Err(Missing::NotFound);
}
let (files, refused) = candidates(path, host, chosen);
#[cfg(not(target_arch = "wasm32"))]
{
candidates(path, dir).into_iter().find(|p| p.is_file())
}
let found = files.into_iter().find(|p| p.is_file());
#[cfg(target_arch = "wasm32")]
{
let _ = candidates(path, dir);
let found: Option<PathBuf> = {
let _ = files;
None
}
};
found.ok_or(refused.map_or(Missing::NotFound, Missing::Refused))
}
/// Read a drawing file through the host's io hooks.
fn read(file: &Path) -> Result<Drawing, String> {
/// Read a drawing file through the host's io hooks, counting its bytes against the load's total.
fn read(file: &Path, cx: &mut Ctx) -> Result<Drawing, String> {
let hooks = super::super::super::file::io().ok_or("file formats are not available in this build")?;
#[cfg(not(target_arch = "wasm32"))]
{
let len = std::fs::metadata(file).map_err(|e| e.to_string())?.len();
if len > MAX_XREF_BYTES {
return Err(format!("the file is larger than {} MB", MAX_XREF_BYTES >> 20));
use std::io::Read;
let left = MAX_XREF_TOTAL_BYTES.saturating_sub(cx.bytes);
let limit = MAX_XREF_BYTES.min(left);
let mut f = std::fs::File::open(file).map_err(|e| e.to_string())?;
let len = f.metadata().map_err(|e| e.to_string())?.len();
let too_big = || {
if limit < MAX_XREF_BYTES {
format!("the referenced files are larger than {} MB together", MAX_XREF_TOTAL_BYTES >> 20)
} else {
format!("the file is larger than {} MB", MAX_XREF_BYTES >> 20)
}
};
if len > limit {
return Err(too_big());
}
let bytes = std::fs::read(file).map_err(|e| e.to_string())?;
let mut bytes = Vec::new();
f.by_ref().take(limit.saturating_add(1)).read_to_end(&mut bytes).map_err(|e| e.to_string())?;
let n = u64::try_from(bytes.len()).unwrap_or(u64::MAX);
if n > limit {
return Err(too_big());
}
cx.bytes = cx.bytes.saturating_add(n);
(hooks.read)(&bytes, &file.to_string_lossy())
}
#[cfg(target_arch = "wasm32")]
{
let _ = (hooks, file);
let _ = (hooks, file, cx);
Err("files are not available on the web".into())
}
}
/// Load every xref of `d` that isn't unloaded; `host` is the drawing's own path.
/// Load every xref of `d` that isn't unloaded from the paths saved in it; `host` is the drawing's
/// own path.
pub(crate) fn load_all(d: &mut Drawing, host: Option<&str>) -> Vec<Outcome> {
let names: Vec<String> = d
.blocks
@@ -197,28 +262,44 @@ pub(crate) fn load_all(d: &mut Drawing, host: Option<&str>) -> Vec<Outcome> {
.filter(|b| b.xref_path.is_some() && b.xref_status != XrefStatus::Unloaded && !b.name.contains('|'))
.map(|b| b.name.clone())
.collect();
names.iter().flat_map(|n| load(d, n, host)).collect()
load_each(d, &names, host, false)
}
/// Load (or reload) xref `name` of `d` from its file; `host` is the drawing's own path. Returns
/// the outcome, then notes about its nested xrefs.
pub(crate) fn load(d: &mut Drawing, name: &str, host: Option<&str>) -> Vec<Outcome> {
let host_file = host.map(Path::new).filter(|p| p.file_name().is_some());
let mut cx = Ctx { chain: host_file.map(canonical).into_iter().collect(), objects: 0, files: 0, notes: Vec::new() };
let dir = folder(host);
let (status, detail) = load_in(d, name, dir.as_deref(), &mut cx, 0);
/// Load (or reload) xref `name` of `d` from its file; `host` is the drawing's own path, and
/// `chosen` says the xref's path was just given by the user (not read from a drawing). Returns the
/// outcome, then notes about its nested xrefs.
pub(crate) fn load(d: &mut Drawing, name: &str, host: Option<&str>, chosen: bool) -> Vec<Outcome> {
load_each(d, std::slice::from_ref(&name.to_string()), host, chosen)
}
/// [`load`] the xrefs `names` within one set of limits.
pub(crate) fn load_each(d: &mut Drawing, names: &[String], host: Option<&str>, chosen: bool) -> Vec<Outcome> {
let mut cx = Ctx::new(host);
names.iter().flat_map(|n| load_with(d, n, host, chosen, &mut cx)).collect()
}
impl Ctx {
fn new(host: Option<&str>) -> Self {
let host_file = host.map(Path::new).filter(|p| p.file_name().is_some());
Ctx { chain: host_file.map(canonical).into_iter().collect(), objects: 0, files: 0, bytes: 0, notes: Vec::new() }
}
}
fn load_with(d: &mut Drawing, name: &str, host: Option<&str>, chosen: bool, cx: &mut Ctx) -> Vec<Outcome> {
let host = host.map(Path::new).filter(|p| p.file_name().is_some());
let (status, detail) = load_in(d, name, host, chosen, cx, 0);
let key = d.block(name).map(|b| b.name.clone()).unwrap_or_else(|| name.to_string());
let mut out = vec![Outcome { name: key, status, detail }];
out.extend(cx.notes.into_iter().map(|n| Outcome { name: String::new(), status: XrefStatus::Unresolved, detail: n }));
out.extend(cx.notes.drain(..).map(|n| Outcome { name: String::new(), status: XrefStatus::Unresolved, detail: n }));
out
}
fn load_in(d: &mut Drawing, name: &str, dir: Option<&Path>, cx: &mut Ctx, depth: usize) -> (XrefStatus, String) {
fn load_in(d: &mut Drawing, name: &str, host: Option<&Path>, chosen: bool, cx: &mut Ctx, depth: usize) -> (XrefStatus, String) {
let Some(b) = d.block(name) else { return (XrefStatus::NotFound, "no such xref".into()) };
let key = b.name.clone();
let Some(path) = b.xref_path.clone() else { return (XrefStatus::Unresolved, "not an xref".into()) };
clear(d, &key);
let (status, detail) = match fetch(&path, dir, cx, depth) {
let (status, detail) = match fetch(&path, host, chosen, cx, depth) {
Ok((x, file)) => match import(d, &key, &x, cx) {
Ok(()) => (XrefStatus::Loaded, file.to_string_lossy().into_owned()),
Err(e) => (XrefStatus::Unresolved, e),
@@ -234,15 +315,12 @@ fn load_in(d: &mut Drawing, name: &str, dir: Option<&Path>, cx: &mut Ctx, depth:
}
/// Read the drawing referenced as `path` and load its own attached xrefs into it.
fn fetch(path: &str, dir: Option<&Path>, cx: &mut Ctx, depth: usize) -> Result<(Drawing, PathBuf), (XrefStatus, String)> {
fn fetch(path: &str, host: Option<&Path>, chosen: bool, cx: &mut Ctx, depth: usize) -> Result<(Drawing, PathBuf), (XrefStatus, String)> {
let unresolved = |m: &str| (XrefStatus::Unresolved, m.to_string());
if depth >= MAX_BLOCK_DEPTH {
return Err(unresolved("xrefs are nested too deeply"));
}
let Some(file) = find(path, dir) else {
let why = if cfg!(target_arch = "wasm32") { "files are not available on the web" } else { "file not found" };
return Err((XrefStatus::NotFound, format!("{path}: {why}")));
};
let file = find(path, host, chosen).map_err(|m| m.outcome(path))?;
let canon = canonical(&file);
if cx.chain.contains(&canon) {
return Err(unresolved("circular reference"));
@@ -251,13 +329,12 @@ fn fetch(path: &str, dir: Option<&Path>, cx: &mut Ctx, depth: usize) -> Result<(
return Err(unresolved("too many referenced files"));
}
cx.files += 1;
let mut x = read(&file).map_err(|e| (XrefStatus::Unresolved, format!("{}: {e}", file.to_string_lossy())))?;
let mut x = read(&file, cx).map_err(|e| (XrefStatus::Unresolved, format!("{}: {e}", file.to_string_lossy())))?;
cx.chain.push(canon);
let nested: Vec<String> =
x.blocks.values().filter(|b| b.xref_path.is_some() && !b.xref_overlay && !b.name.contains('|')).map(|b| b.name.clone()).collect();
let xdir = file.parent().map(Path::to_path_buf);
for n in nested {
let (status, detail) = load_in(&mut x, &n, xdir.as_deref(), cx, depth + 1);
let (status, detail) = load_in(&mut x, &n, Some(&file), false, cx, depth + 1);
if status != XrefStatus::Loaded {
cx.notes.push(format!("nested xref \"{n}\" not loaded: {detail}"));
}
@@ -430,18 +507,23 @@ mod tests {
#[test]
fn paths_resolve_against_the_host_folder_then_by_name() {
let dir = Path::new("/proj/plans");
let c = candidates("parts/a.dxf", Some(dir));
assert_eq!(c, vec![dir.join("parts/a.dxf"), dir.join("a.dxf")]);
let c = candidates("/abs/b.dwg", Some(dir));
assert_eq!(c, vec![PathBuf::from("/abs/b.dwg"), dir.join("b.dwg")]);
let dir = std::env::temp_dir().join("proj").join("plans");
let host = dir.join("host.dxf");
let c = |path: &str, chosen| candidates(path, Some(&host), chosen);
assert_eq!(c("parts/a.dxf", false), (vec![dir.join("parts").join("a.dxf"), dir.join("a.dxf")], None));
let abs = std::env::temp_dir().join("abs").join("b.dwg");
assert_eq!(c(&abs.to_string_lossy(), false), (vec![abs.clone(), dir.join("b.dwg")], None));
if !cfg!(windows) {
// A Windows path saved elsewhere still finds the file next to the host.
let c = candidates("C:\\jobs\\site.dwg", Some(dir));
assert_eq!(c.last(), Some(&dir.join("site.dwg")));
assert_eq!(c("C:\\jobs\\site.dwg", false), (vec![dir.join("site.dwg")], None));
}
assert_eq!(candidates("a.dxf", None), vec![PathBuf::from("a.dxf")]);
assert!(find("", Some(dir)).is_none());
// Paths to other computers are only looked for beside the host.
assert_eq!(c(r"\\192.0.2.1\share\x.dxf", false), (vec![dir.join("x.dxf")], Some(Refused::Network)));
assert_eq!(c("file://192.0.2.1/s/x.dxf", false), (vec![dir.join("x.dxf")], Some(Refused::Network)));
// Without a host drawing a relative path names no file.
assert_eq!(candidates("a.dxf", None, false), (vec![], None));
assert_eq!(candidates("a.dxf", None, true), (vec![PathBuf::from("a.dxf")], None));
assert_eq!(find("", Some(&host), false), Err(Missing::NotFound));
}
#[test]
+14 -2
View File
@@ -93,7 +93,7 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("parameters", "Parameters Manager", run_parameters)
.menu(&["Tools", "Parametric", "Parameters Manager"])
.alias(&["par", "parametersclose"])
.params("{} lists; {name, expr | value, description?} sets (new name = user parameter) and re-solves; {delete: name}"),
.params("{} lists; {name, expr | value, description?} sets (new name = user parameter) and re-solves; {delete: name}; {name, rename: newName} renames it in every expression"),
CommandSpec::new("constraintsettings", "Constraint Settings", run_constraintsettings)
.menu(&["Tools", "Parametric", "Constraint Settings"])
.alias(&["csettings"])
@@ -873,6 +873,10 @@ fn run_parameters(s: &mut Session, p: &Value) -> Result<Value> {
return Err(EngineError::Other(format!("`{name}` is used by `{}`", row.name)));
}
*s.doc_mut()? = d;
} else if let (Some(old), Some(to)) = (str_param(p, "name"), str_param(p, "rename")) {
let mut d = s.doc()?.clone();
cs::rename_parameter(&mut d, old.trim(), to.trim()).map_err(err)?;
*s.doc_mut()? = d;
} else if let Some(name) = str_param(p, "name") {
let expr = match (str_param(p, "expr"), p.get("value").and_then(Value::as_f64)) {
(Some(e), _) => e.to_string(),
@@ -952,6 +956,13 @@ fn run_constraintsettings(s: &mut Session, p: &Value) -> Result<Value> {
set.angle_tolerance = f64_or(p, "angleTolerance", set.angle_tolerance).clamp(0.0, 45.0);
set.bars_visible = bool_or(p, "barsVisible", set.bars_visible);
set.dims_visible = bool_or(p, "dimsVisible", set.dims_visible);
// The checkboxes act like Show All / Hide All: exceptions are relative to the old value and would flip.
if set.bars_visible != cur.bars_visible {
set.bar_exceptions.clear();
}
if set.dims_visible != cur.dims_visible {
set.dim_exceptions.clear();
}
if let Some(t) = p.get("barTransparency").and_then(Value::as_f64).filter(|v| v.is_finite()) {
set.bar_transparency = t.clamp(0.0, 90.0) as u8;
}
@@ -1136,8 +1147,9 @@ pub fn after_command(s: &mut Session, before: Option<&Arc<Drawing>>, ok: bool) {
}
let mut d = (*st.doc).clone();
let purged = cs::purge_invalid(&mut d);
let restored = cs::restore_fixed(&mut d, before);
let satisfied = cs::all_satisfied(&d);
if purged.is_empty() && satisfied {
if purged.is_empty() && !restored && satisfied {
return;
}
let mut msg = Vec::new();
+116
View File
@@ -56,6 +56,42 @@ fn json_geometric_and_dimensional_constraints() {
assert_eq!(inspect["glyphs"][0]["kind"], "Horizontal");
}
#[test]
fn renaming_a_parameter_rewrites_the_expressions_that_use_it() {
let mut s = Session::new();
let a = line(&mut s, [0.0, 0.0], [10.0, 0.0]);
let b = line(&mut s, [0.0, 5.0], [10.0, 5.0]);
s.execute("parameters", &json!({ "name": "user1", "expr": "4" })).unwrap();
s.execute("dcaligned", &json!({ "h1": a, "expr": "user1*2" })).unwrap();
s.execute("dcaligned", &json!({ "h1": b, "expr": "d1+1" })).unwrap();
let names = |s: &Session| -> Vec<(String, String)> {
cadcraft_constraints::parameter_table(s.doc().unwrap()).into_iter().map(|r| (r.name, r.expr)).collect()
};
s.execute("parameters", &json!({ "name": "user1", "rename": "width" })).unwrap();
assert_eq!(names(&s), vec![("d1".into(), "width*2".into()), ("d2".into(), "d1+1".into()), ("width".into(), "4".into())]);
assert!((ends(&s, &a).0.dist(ends(&s, &a).1) - 8.0).abs() < 1e-6);
// A dimensional constraint's name too, and only the whole name: d1 is not part of d10.
s.execute("parameters", &json!({ "name": "d10", "expr": "3" })).unwrap();
s.execute("parameters", &json!({ "name": "d2", "expr": "d1+d10" })).unwrap();
s.execute("parameters", &json!({ "name": "d1", "rename": "base" })).unwrap();
assert_eq!(
names(&s),
vec![("base".into(), "width*2".into()), ("d2".into(), "base+d10".into()), ("width".into(), "4".into()), ("d10".into(), "3".into())]
);
// Taken and invalid names are refused and change nothing; undo takes a rename back.
let before = s.doc().unwrap().clone();
for to in ["width", "d10", "1x", "pi", ""] {
assert!(s.execute("parameters", &json!({ "name": "base", "rename": to })).is_err(), "{to}");
}
assert!(s.execute("parameters", &json!({ "name": "nothing", "rename": "x" })).is_err());
assert_eq!(*s.doc().unwrap(), before);
s.undo().unwrap();
assert_eq!(names(&s)[0], ("d1".into(), "width*2".into()));
}
#[test]
fn edits_are_resolved_and_erase_purges() {
let mut s = Session::new();
@@ -231,3 +267,83 @@ fn delconstraint_rejects_referenced_dimension() {
s.execute("delconstraint", &json!({ "ids": ids })).unwrap();
assert!(s.doc().unwrap().constraints.is_empty());
}
/// The angle between the two rays from the vertex to the far end of each line: the angle
/// DCANGULAR draws.
fn drawn_angle(s: &Session, a: &str, b: &str) -> f64 {
let ((a0, a1), (b0, b1)) = (ends(s, a), ends(s, b));
let (o, _, _) = cadcraft_geom::line_line_infinite(a0, a1, b0, b1).unwrap();
let far = |p: Vec2, q: Vec2| if p.dist(o) > q.dist(o) { p } else { q };
let (u, v) = (far(a0, a1) - o, far(b0, b1) - o);
u.cross(v).atan2(u.dot(v)).abs().to_degrees()
}
#[test]
fn dcangular_constrains_the_drawn_angle_whichever_way_the_lines_run() {
// #1134: the second line drawn towards the shared vertex got the supplement of the value.
for b_pts in [[[2.0, 10.0], [0.0, 0.0]], [[0.0, 0.0], [2.0, 10.0]]] {
let mut s = Session::new();
s.viewport_px = (1000.0, 1000.0);
let a = line(&mut s, [0.0, 0.0], [10.0, 0.0]);
let b = line(&mut s, b_pts[0], b_pts[1]);
s.cmdline("dcangular").unwrap();
s.input(Input::Point(Vec2::new(3.0, 0.0))).unwrap();
s.input(Input::Point(Vec2::new(0.6, 3.0))).unwrap();
assert!(s.prompt_text().contains("78.69"), "{b_pts:?}: {}", s.prompt_text());
s.cmdline("100").unwrap();
assert!(s.running.is_none());
let got = drawn_angle(&s, &a, &b);
assert!((got - 100.0).abs() < 1e-6, "{b_pts:?}: drawn angle {got}");
}
}
/// Same rule as the overlay in `ui-egui/src/parametric.rs` that draws dynamic constraints.
fn dim_shown(s: &Session, h: &str) -> bool {
let d = s.doc().unwrap();
let set = &d.parametric.settings;
let c = d.constraints.iter().find(|c| c.refs.iter().any(|r| r.handle.hex() == h)).unwrap();
set.dims_visible != c.refs.iter().any(|r| set.dim_exceptions.contains(&r.handle))
}
#[test]
fn settings_dims_checkbox_clears_exceptions() {
let mut s = Session::new();
let a = line(&mut s, [0.0, 0.0], [10.0, 0.0]);
s.execute("dclinear", &json!({ "h1": a, "expr": "10" })).unwrap();
s.execute("dcdisplay.hideall", &json!({})).unwrap();
let b = line(&mut s, [20.0, 0.0], [25.0, 0.0]);
s.execute("dclinear", &json!({ "h1": b, "expr": "5" })).unwrap();
let r = s.execute("dcdisplay", &json!({ "mode": "show", "handles": [b] })).unwrap();
assert_eq!(r["exceptions"], json!([b]));
assert!(!dim_shown(&s, &a) && dim_shown(&s, &b));
// Re-stating the current value keeps the exceptions.
s.execute("constraintsettings", &json!({ "dimsVisible": false })).unwrap();
assert_eq!(s.doc().unwrap().parametric.settings.dim_exceptions.len(), 1);
// Ticking "Show dynamic constraints" shows all of them, like Show All.
s.execute("constraintsettings", &json!({ "dimsVisible": true })).unwrap();
assert!(s.doc().unwrap().parametric.settings.dim_exceptions.is_empty());
assert!(dim_shown(&s, &a) && dim_shown(&s, &b));
}
#[test]
fn settings_bars_checkbox_clears_exceptions() {
let mut s = Session::new();
let a = line(&mut s, [0.0, 0.0], [10.0, 0.0]);
s.execute("gchorizontal", &json!({ "h1": a })).unwrap();
s.execute("constraintbar.hideall", &json!({})).unwrap();
let b = line(&mut s, [0.0, 5.0], [10.0, 5.0]);
s.execute("gchorizontal", &json!({ "h1": b })).unwrap();
let r = s.execute("constraintbar", &json!({ "mode": "show", "handles": [b] })).unwrap();
assert_eq!(r["exceptions"], json!([b]));
// Same rule as the bar overlay in `ui-egui/src/parametric.rs`.
let bar = |s: &Session, h: &str| {
let set = &s.doc().unwrap().parametric.settings;
set.bars_visible != set.bar_exceptions.contains(&Handle::parse_hex(h).unwrap())
};
assert!(!bar(&s, &a) && bar(&s, &b));
s.execute("constraintsettings", &json!({ "barsVisible": false })).unwrap();
assert_eq!(s.doc().unwrap().parametric.settings.bar_exceptions.len(), 1);
s.execute("constraintsettings", &json!({ "barsVisible": true })).unwrap();
assert!(s.doc().unwrap().parametric.settings.bar_exceptions.is_empty());
assert!(bar(&s, &a) && bar(&s, &b));
}
+8 -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 ----------------
@@ -129,6 +129,12 @@ pub fn last_curve_end(s: &Session) -> Option<(Vec2, Vec2)> {
}
EntityKind::Arc(a) => {
let g = Arc::new(a.center.xy(), a.radius, a.start, a.end);
// Arcs are stored CCW: one drawn clockwise ended on its start point, so continue
// from the last picked point (LASTPOINT), leaving it in the drawing direction.
let tol = 1e-9 * g.radius.max(1.0);
if g.start_point().near(s.last_point, tol) && !g.end_point().near(s.last_point, tol) {
return Some((g.start_point(), -Vec2::from_angle(g.start).perp()));
}
return Some((g.end_point(), Vec2::from_angle(g.end).perp()));
}
EntityKind::LwPolyline(p) if !p.closed => {
+55 -8
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)),
_ => {}
}
@@ -741,9 +755,12 @@ impl Interactive for CircleM {
let last_r = s.doc().map(|d| d.header.f64("CIRCLERAD", 0.0)).unwrap_or(0.0);
match (self.mode, n) {
(0, 0) => Prompt::new("Specify center point for circle", Accept::POINT).kw(&["3P", "2P", "Ttr (tan tan radius)"]),
(0, _) if self.diameter => Prompt::new("Specify diameter of circle", Accept::POINT_OR_NUMBER).base_opt(self.pts.first().copied()),
(0, _) if self.diameter => {
Prompt::new("Specify diameter of circle", Accept::POINT_OR_NUMBER).base_opt(self.pts.first().copied()).distance()
}
(0, _) => {
let p = Prompt::new("Specify radius of circle", Accept::POINT_OR_NUMBER).kw(&["Diameter"]).base_opt(self.pts.first().copied());
let p =
Prompt::new("Specify radius of circle", Accept::POINT_OR_NUMBER).kw(&["Diameter"]).base_opt(self.pts.first().copied()).distance();
if last_r > 0.0 { p.default(format!("{last_r:.4}")) } else { p }
}
(1, k) => Prompt::new(
@@ -780,6 +797,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 => {
@@ -873,7 +893,7 @@ impl Interactive for PolygonM {
(Some(_), false, Some(_), None) => Prompt::new("Enter an option", Accept::TEXT)
.kw(&["Inscribed in circle", "Circumscribed about circle"])
.default(if s.last_used.polygon_circumscribed { "C" } else { "I" }),
(Some(_), false, Some(c), Some(_)) => Prompt::new("Specify radius of circle", Accept::POINT_OR_NUMBER).base(c),
(Some(_), false, Some(c), Some(_)) => Prompt::new("Specify radius of circle", Accept::POINT_OR_NUMBER).base(c).distance(),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
@@ -895,6 +915,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 +940,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 +1441,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 +1517,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 +1605,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) => {
+37 -11
View File
@@ -12,6 +12,7 @@ use super::machines::number;
use super::*;
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
pub(crate) mod breakline;
pub(crate) mod mline;
pub fn specs() -> Vec<CommandSpec> {
@@ -133,6 +134,7 @@ pub fn specs() -> Vec<CommandSpec> {
.interactive(|_| Ok(Box::new(CenterM { first: None, mark: false }))),
];
v.extend(mline::specs());
v.extend(breakline::specs());
v
}
@@ -217,7 +219,7 @@ impl ArcV {
ArcV::Sea => &[(S, Ask::Point), (E, Ask::Point), (A, Ask::Angle(0))],
ArcV::Sed => &[(S, Ask::Point), (E, Ask::Point), ("Specify tangent direction for the start point of arc", Ask::Angle(0))],
ArcV::Ser => &[(S, Ask::Point), (E, Ask::Point), ("Specify radius of arc", Ask::Dist(1))],
ArcV::Cse => &[(C, Ask::Point), (S, Ask::Point), (E, Ask::Point)],
ArcV::Cse => &[(C, Ask::Point), (S, Ask::Point), ("Specify end point of arc (hold Ctrl to switch direction)", Ask::Point)],
ArcV::Csa => &[(C, Ask::Point), (S, Ask::Point), (A, Ask::Sweep(0, 1))],
ArcV::Csl => &[(C, Ask::Point), (S, Ask::Point), (L, Ask::Dist(1))],
ArcV::CircleCd => &[("Specify center point for circle", Ask::Point), ("Specify diameter of circle", Ask::Dist(0))],
@@ -231,7 +233,7 @@ impl ArcV {
],
}
}
fn build(self, v: &[Val]) -> Option<EntityKind> {
fn build(self, v: &[Val], ctrl: bool) -> Option<EntityKind> {
let p = |i: usize| v.get(i).and_then(|x| x.p());
let n = |i: usize| v.get(i).and_then(|x| x.n());
let a = match self {
@@ -243,7 +245,14 @@ impl ArcV {
ArcV::Sea => curves::arc_sea(p(0)?, p(1)?, n(2)?),
ArcV::Sed => curves::arc_sed(p(0)?, p(1)?, Vec2::from_angle(n(2)?)),
ArcV::Ser => curves::arc_ser(p(0)?, p(1)?, n(2)?),
ArcV::Cse => Some(Arc::from_start_center_end(p(1)?, p(0)?, p(2)?)),
// Center, Start, End. Ctrl keeps the two endpoints on the circle and walks the
// other way around: swapping the start/end angles is the mirror arc through the
// same two endpoints. (Continue below flips differently, by negating the start
// tangent; both are "reverse", but only here is the chord fixed by the picks.)
ArcV::Cse => {
let a = Arc::from_start_center_end(p(1)?, p(0)?, p(2)?);
Some(if ctrl { Arc::new(a.center, a.radius, a.end, a.start) } else { a })
}
ArcV::Csa => curves::arc_sca(p(1)?, p(0)?, n(2)?),
ArcV::Csl => curves::arc_scl(p(1)?, p(0)?, n(2)?),
ArcV::CircleCd => {
@@ -338,6 +347,11 @@ impl Interactive for SeqM {
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
if let Some((st, dir)) = self.cont {
// Ctrl flips the arc: negating the start tangent makes `arc_sed` pick the mirror
// arc (same two points and center, opposite sweep). `ctrl_down` comes from the
// UI's per-frame write on `Session`; the `Input` enum itself stays free of
// modifiers so it remains UI-agnostic.
let dir = if s.ctrl_down { -dir } else { dir };
return match i {
Input::Point(e) => {
let a = curves::arc_sed(st, e, dir).ok_or_else(|| other("Invalid end point."))?;
@@ -380,7 +394,7 @@ impl Interactive for SeqM {
if self.vals.len() < self.v.steps().len() {
return Ok(Step::Continue);
}
match self.v.build(&self.vals) {
match self.v.build(&self.vals, s.ctrl_down) {
Some(k) => {
if let EntityKind::Circle(c) = &k {
let r = c.radius;
@@ -397,8 +411,9 @@ impl Interactive for SeqM {
}
}
}
fn preview(&self, _s: &Session, c: Vec2) -> Vec<EntityKind> {
fn preview(&self, s: &Session, c: Vec2) -> Vec<EntityKind> {
if let Some((st, d)) = self.cont {
let d = if s.ctrl_down { -d } else { d };
return curves::arc_sed(st, c, d).map(|a| vec![arc(&a)]).unwrap_or_default();
}
let Some((_, ask)) = self.ask() else { return Vec::new() };
@@ -407,7 +422,7 @@ impl Interactive for SeqM {
vals.push(v);
let mut out: Vec<EntityKind> = self.base().map(|b| vec![line(b, c)]).unwrap_or_default();
if vals.len() == self.v.steps().len() {
out.extend(self.v.build(&vals));
out.extend(self.v.build(&vals, s.ctrl_down));
}
out
}
@@ -460,7 +475,10 @@ fn run_arc_continue(s: &mut Session, p: &Value) -> Result<Value> {
_ => curves::last_curve_end(s).ok_or_else(|| bad("arc.continue", "no line or arc to continue from"))?,
};
let a = curves::arc_sed(st, e, dir).ok_or_else(|| bad("arc.continue", "end point is on the tangent line"))?;
added(s.add_entity(arc(&a))?)
let h = s.add_entity(arc(&a))?;
// The next continuation starts where this arc was drawn to, not at its stored CCW end.
s.last_point = e;
added(h)
}
#[derive(Default)]
@@ -485,6 +503,8 @@ impl Interactive for ArcContinueM {
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
let Some((st, dir)) = self.from else { return Ok(Step::Cancel) };
// Ctrl flips the direction, exactly as in `SeqM::input`.
let dir = if s.ctrl_down { -dir } else { dir };
match i {
Input::Point(e) => {
let a = curves::arc_sed(st, e, dir).ok_or_else(|| other("Invalid end point."))?;
@@ -495,8 +515,14 @@ impl Interactive for ArcContinueM {
_ => Err(other("Point required.")),
}
}
fn preview(&self, _s: &Session, c: Vec2) -> Vec<EntityKind> {
self.from.and_then(|(st, d)| curves::arc_sed(st, c, d)).map(|a| vec![arc(&a)]).unwrap_or_default()
fn preview(&self, s: &Session, c: Vec2) -> Vec<EntityKind> {
self.from
.and_then(|(st, dir)| {
let dir = if s.ctrl_down { -dir } else { dir };
curves::arc_sed(st, c, dir)
})
.map(|a| vec![arc(&a)])
.unwrap_or_default()
}
}
@@ -575,7 +601,7 @@ impl Interactive for TanCircleM {
1 => Prompt::new("Specify point on object for second tangent of circle", Accept::POINT),
_ => {
let r = s.doc().map(|d| d.header.f64("CIRCLERAD", 0.0)).unwrap_or(0.0);
let p = Prompt::new("Specify radius of circle", Accept::POINT_OR_NUMBER).base_opt(self.picks.last().map(|x| x.1));
let p = Prompt::new("Specify radius of circle", Accept::POINT_OR_NUMBER).base_opt(self.picks.last().map(|x| x.1)).distance();
if r > 0.0 { p.default(format!("{r:.4}")) } else { p }
}
}
@@ -1692,7 +1718,7 @@ impl Interactive for RectM2 {
}
match self.first {
None => Prompt::new("Specify first corner point", Accept::POINT).kw(&["Chamfer", "Elevation", "Fillet", "Thickness", "Width"]),
Some(p) => Prompt::new("Specify other corner point", Accept::POINT).kw(&["Area", "Dimensions", "Rotation"]).base(p),
Some(p) => Prompt::new("Specify other corner point", Accept::POINT).kw(&["Area", "Dimensions", "Rotation"]).corner(p),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
+301
View File
@@ -0,0 +1,301 @@
//! BREAKLINE (Express Tools): one polyline from the first point to the second with a break
//! symbol at a location along it, the line optionally extended past both points.
//!
//! The symbol is a run of vertices between two attachment points. The built-in one is our own
//! zigzag; the Block option takes a block of the drawing that marks its attachment points with
//! two POINT objects on the Defpoints layer and draws the symbol with lines and polylines.
use cadcraft_doc::{Drawing, EntityKind};
use cadcraft_geom::{PolyVertex, Vec2};
use serde_json::Value;
use super::added;
use super::curves::MAX_GEN;
use super::machines::number;
use super::*;
use crate::cmd::modify2::as_lwpoly;
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("breakline", "Break-line Symbol", run_breakline)
.menu(&["Tools", "Express Tools", "Break-line Symbol"])
.params(
"{p1, p2, at?: [x,y] (symbol location; default the midpoint), size? (default 1, times DIMSCALE), \
extension? (past both points; default 0), block? (a block with two points on Defpoints; default the built-in symbol)} → {handle}",
)
.interactive(|s| Ok(Box::new(BreaklineM::new(s)))),
]
}
/// The values BREAKLINE offers as defaults: the symbol block (empty = built-in), its size and
/// how far the line runs past the two points.
#[derive(Clone, Debug, PartialEq)]
pub struct BreaklineOptions {
pub block: String,
pub size: f64,
pub extension: f64,
}
impl Default for BreaklineOptions {
fn default() -> Self {
BreaklineOptions { block: String::new(), size: 1.0, extension: 0.0 }
}
}
fn other(m: impl Into<String>) -> EngineError {
EngineError::Other(m.into())
}
/// A break symbol in its own coordinates: the vertices from one attachment point to the other.
struct Symbol {
path: Vec<PolyVertex>,
}
impl Symbol {
/// Our own zigzag: up and across to the other side of the line, one unit between the
/// attachment points and one unit tall.
fn built_in() -> Self {
let pts = [(-0.5, 0.0), (-0.25, 0.5), (0.25, -0.5), (0.5, 0.0)];
Symbol { path: pts.into_iter().map(|(x, y)| PolyVertex::new(Vec2::new(x, y))).collect() }
}
fn of_block(d: &Drawing, name: &str) -> Result<Self> {
let b = d.block(name).ok_or_else(|| other(format!("Block \"{name}\" not found.")))?;
let mut anchors = Vec::new();
let mut pieces: Vec<Vec<PolyVertex>> = Vec::new();
for e in b.entities.iter() {
match &e.kind {
EntityKind::Point(p) if e.common.layer.eq_ignore_ascii_case("Defpoints") => anchors.push(p.p.xy()),
k => {
if let Some(pl) = as_lwpoly(k).filter(|pl| !pl.closed && pl.vertices.len() >= 2)
&& pieces.len() < MAX_GEN
{
pieces.push(pl.vertices);
}
}
}
}
let [a, z] = anchors[..] else {
return Err(other(format!("Block \"{name}\" needs exactly two points on the Defpoints layer.")));
};
let tol = a.dist(z) * 1e-6;
if !(tol > 0.0 && tol.is_finite()) {
return Err(other(format!("The two Defpoints points of block \"{name}\" coincide.")));
}
// Chain the pieces end to end from one attachment point until the other is reached.
let mut path = vec![PolyVertex::new(a)];
let mut at = a;
while !at.near(z, tol) {
let next = pieces.iter().position(|p| p.first().is_some_and(|v| v.p.near(at, tol)) || p.last().is_some_and(|v| v.p.near(at, tol)));
let Some(i) = next else {
return Err(other(format!("The lines of block \"{name}\" must run from one Defpoints point to the other.")));
};
let mut piece = pieces.swap_remove(i);
if !piece.first().is_some_and(|v| v.p.near(at, tol)) {
piece = reversed(&piece);
}
if let Some(last) = path.last_mut() {
last.bulge = piece.first().map_or(0.0, |v| v.bulge);
}
path.extend(piece.iter().skip(1).copied());
at = path.last().map_or(z, |v| v.p);
}
Ok(Symbol { path })
}
fn span(&self) -> f64 {
match (self.path.first(), self.path.last()) {
(Some(a), Some(z)) => a.p.dist(z.p),
_ => 0.0,
}
}
/// The symbol scaled by `k`, its attachment points on the line through `from` along `u`.
fn placed(&self, from: Vec2, u: Vec2, k: f64) -> Vec<PolyVertex> {
let (Some(a), Some(z)) = (self.path.first(), self.path.last()) else { return Vec::new() };
let ax = (z.p - a.p).normalized();
self.path
.iter()
.map(|v| {
let q = v.p - a.p;
let (x, y) = (q.dot(ax) * k, q.dot(ax.perp()) * k);
PolyVertex::with_bulge(from + u * x + u.perp() * y, v.bulge)
})
.collect()
}
}
/// The same vertices run backwards, each arc keeping its side.
fn reversed(vs: &[PolyVertex]) -> Vec<PolyVertex> {
let n = vs.len();
(0..n)
.rev()
.filter_map(|i| {
let v = vs.get(i)?;
let bulge = if i == 0 { 0.0 } else { -vs.get(i - 1).map_or(0.0, |p| p.bulge) };
Some(PolyVertex::with_bulge(v.p, bulge))
})
.collect()
}
fn dimscale(s: &Session) -> f64 {
s.doc().ok().map(|d| d.header.f64("DIMSCALE", 1.0)).filter(|k| k.is_finite() && *k > 0.0).unwrap_or(1.0)
}
fn symbol(s: &Session, block: &str) -> Result<Symbol> {
if block.is_empty() { Ok(Symbol::built_in()) } else { Symbol::of_block(s.doc()?, block) }
}
/// The breakline's vertices: the extension, the line up to the symbol, the symbol and the rest of
/// the line. The symbol is centred on `at` projected onto the line (the midpoint without it) and
/// kept between the two points.
fn breakline_vertices(sym: &Symbol, p1: Vec2, p2: Vec2, at: Option<Vec2>, k: f64, ext: f64) -> Result<Vec<PolyVertex>> {
let len = p1.dist(p2);
let u = (p2 - p1).normalized();
if !(len.is_finite() && len > 0.0) || u == Vec2::ZERO {
return Err(other("The two points of the breakline must differ."));
}
let span = sym.span() * k;
if !span.is_finite() || span <= 0.0 {
return Err(other("The break symbol has no size."));
}
if span > len {
return Err(other(format!("The breakline must be at least as long as the symbol ({span:.4}).")));
}
let t = at.map_or(len / 2.0, |c| (c - p1).dot(u)).clamp(span / 2.0, len - span / 2.0);
let mut pts = vec![PolyVertex::new(p1 - u * ext)];
pts.extend(sym.placed(p1 + u * (t - span / 2.0), u, k));
pts.push(PolyVertex::new(p2 + u * ext));
let mut out: Vec<PolyVertex> = Vec::with_capacity(pts.len());
for v in pts {
if !v.p.is_finite() {
return Err(other("The breakline is too large."));
}
match out.last_mut() {
Some(l) if l.p.near(v.p, len * 1e-12) => l.bulge = v.bulge,
_ => out.push(v),
}
}
if let Some(l) = out.last_mut() {
l.bulge = 0.0;
}
Ok(out)
}
fn run_breakline(s: &mut Session, p: &Value) -> Result<Value> {
let p1 = point_req("breakline", p, "p1")?;
let p2 = point_req("breakline", p, "p2")?;
let size = size_param("breakline", p, "size", false)?.unwrap_or(1.0);
let ext = size_param("breakline", p, "extension", true)?.unwrap_or(0.0);
let sym = symbol(s, str_param(p, "block").unwrap_or(""))?;
let vs = breakline_vertices(&sym, p1, p2, point_param(p, "at"), size * dimscale(s), ext)?;
added(s.add_entity(lwpoly(vs, false))?)
}
#[derive(Clone, Copy, PartialEq)]
enum Ask {
Points,
Block,
Size,
Extension,
}
struct BreaklineM {
opts: BreaklineOptions,
ask: Ask,
p1: Option<Vec2>,
p2: Option<Vec2>,
}
impl BreaklineM {
fn new(s: &Session) -> Self {
BreaklineM { opts: s.last_used.breakline.clone(), ask: Ask::Points, p1: None, p2: None }
}
fn block_label(&self) -> String {
if self.opts.block.is_empty() { "(built-in)".into() } else { self.opts.block.clone() }
}
fn finish(&self, s: &mut Session, at: Option<Vec2>) -> Result<Step> {
let (Some(p1), Some(p2)) = (self.p1, self.p2) else { return Ok(Step::Cancel) };
let vs = breakline_vertices(&symbol(s, &self.opts.block)?, p1, p2, at, self.opts.size * dimscale(s), self.opts.extension)?;
s.add_entity(lwpoly(vs, false))?;
Ok(Step::Done)
}
}
impl Interactive for BreaklineM {
fn name(&self) -> &'static str {
"BREAKLINE"
}
fn begin(&mut self, s: &mut Session) -> Result<Step> {
s.echo(format!("Block= {}, Size= {:.4}, Extension= {:.4}", self.block_label(), self.opts.size, self.opts.extension));
Ok(Step::Continue)
}
fn prompt(&self, _s: &Session) -> Prompt {
match (self.ask, self.p1, self.p2) {
(Ask::Block, ..) => {
Prompt::new("Enter name of breakline symbol block (. for the built-in symbol)", Accept::TEXT).default(self.block_label())
}
(Ask::Size, ..) => Prompt::new("Breakline symbol size", Accept::NUMBER).default(format!("{:.4}", self.opts.size)),
(Ask::Extension, ..) => Prompt::new("Breakline extension distance", Accept::NUMBER).default(format!("{:.4}", self.opts.extension)),
(Ask::Points, None, _) => Prompt::new("Specify first point for breakline", Accept::POINT).kw(&["Block", "Size", "Extension"]),
(Ask::Points, Some(a), None) => Prompt::new("Specify second point for breakline", Accept::POINT).base(a),
(Ask::Points, Some(_), Some(_)) => Prompt::new("Specify location for break symbol", Accept::POINT).default("Midpoint"),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
match (self.ask, i) {
(Ask::Block, Input::Text(t) | Input::Keyword(t)) => {
let t = t.trim();
let name = if t == "." { String::new() } else { t.to_string() };
symbol(s, &name)?;
self.opts.block = name;
self.ask = Ask::Points;
}
(Ask::Size, Input::Text(t)) => {
self.opts.size = number(&t).filter(|v| v.is_finite() && *v > 0.0).ok_or_else(|| other("Requires a positive value."))?;
self.ask = Ask::Points;
}
(Ask::Extension, Input::Text(t)) => {
self.opts.extension = number(&t).filter(|v| v.is_finite() && *v >= 0.0).ok_or_else(|| other("Requires zero or a positive value."))?;
self.ask = Ask::Points;
}
(Ask::Block | Ask::Size | Ask::Extension, Input::Enter) => self.ask = Ask::Points,
(Ask::Points, Input::Keyword(k)) if self.p1.is_none() => {
self.ask = match k.as_str() {
"Block" => Ask::Block,
"Size" => Ask::Size,
"Extension" => Ask::Extension,
_ => Ask::Points,
};
}
(Ask::Points, Input::Point(p)) => match (self.p1, self.p2) {
(None, _) => self.p1 = Some(p),
(Some(a), None) => {
let span = symbol(s, &self.opts.block)?.span() * self.opts.size * dimscale(s);
let len = a.dist(p);
if len.is_nan() || len < span {
return Err(other(format!("The breakline must be at least as long as the symbol ({span:.4}).")));
}
self.p2 = Some(p);
}
(Some(_), Some(_)) => return self.finish(s, Some(p)),
},
(Ask::Points, Input::Enter) if self.p2.is_some() => return self.finish(s, None),
(Ask::Points, Input::Enter) => return Ok(Step::Cancel),
_ => {}
}
s.last_used.breakline = self.opts.clone();
Ok(Step::Continue)
}
fn preview(&self, s: &Session, c: Vec2) -> Vec<EntityKind> {
match (self.ask, self.p1, self.p2) {
(Ask::Points, Some(a), None) => vec![line(a, c)],
(Ask::Points, Some(a), Some(b)) => symbol(s, &self.opts.block)
.and_then(|sym| breakline_vertices(&sym, a, b, Some(c), self.opts.size * dimscale(s), self.opts.extension))
.map(|vs| vec![lwpoly(vs, false)])
.unwrap_or_default(),
_ => Vec::new(),
}
}
}
+24 -5
View File
@@ -576,8 +576,7 @@ fn mledit_pair(s: &mut Session, tool: Tool, ha: Handle, pa: Vec2, hb: Handle, pb
let aa = a.clone();
cut_across(&mut b, u, &outer_of(&bo), &aa, k);
}
Tool::MergedTee => return Err(other("Merged tee is not available yet.")),
Tool::ClosedTee | Tool::OpenTee => {
Tool::ClosedTee | Tool::OpenTee | Tool::MergedTee => {
let path = path_seg(&a, k).ok_or_else(|| other("*Invalid selection*"))?;
let bm = b.miters();
let (top, bottom) = outer(&bo).ok_or_else(|| other("*Invalid selection*"))?;
@@ -593,16 +592,36 @@ fn mledit_pair(s: &mut Session, tool: Tool, ha: Handle, pa: Vec2, hb: Handle, pb
_ => (sb, bottom),
};
let near = if near_e == top { lt } else { lb };
a = trim_at(&a, k, at, keep_before).ok_or_else(|| other("Nothing would be left of the first multiline."))?;
if tool == Tool::OpenTee {
// Merged tee: the first one's inner elements run on to the second one's nearest inner element.
let inner = if tool == Tool::MergedTee && ao.len() > 2 {
(0..bo.len())
.filter(|e| *e != top && *e != bottom)
.filter_map(|e| meet(&path, &line(e)?))
.min_by(|x, y| (x - at).abs().total_cmp(&(y - at).abs()))
} else {
None
};
a = trim_at(&a, k, inner.unwrap_or(at), keep_before).ok_or_else(|| other("Nothing would be left of the first multiline."))?;
if tool != Tool::ClosedTee {
// Open the near outer element of the second multiline where the first meets it.
let ak = if keep_before { a.vertices.len().saturating_sub(2) } else { 0 };
let (am, aoff) = (a.miters(), a.offsets());
if let Some((t2, b2)) = outer(&aoff) {
let al = |e: usize| aoff.get(e).and_then(|d| element_line(&a, &am, ak, *d));
if let (Some(x), Some(y)) = (al(t2).and_then(|l| meet(&near, &l)), al(b2).and_then(|l| meet(&near, &l))) {
let (lt2, lb2) = (al(t2), al(b2));
if let (Some(x), Some(y)) = (lt2.and_then(|l| meet(&near, &l)), lb2.and_then(|l| meet(&near, &l))) {
add_cut(&mut b, u, near_e, x, y);
}
// ...and the first one's outer elements stop at it.
if inner.is_some() {
for (e, l) in [(t2, lt2), (b2, lb2)] {
if let Some(l) = l
&& let Some(x) = meet(&l, &near)
{
add_cut(&mut a, ak, e, x, if keep_before { l.len() } else { 0.0 });
}
}
}
}
}
}
+135
View File
@@ -121,6 +121,28 @@ fn arc_continue_from_last_line() {
assert!(near(as_arc(&last(&s)).center, 0.0, 1.0));
}
#[test]
fn arc_continue_from_last_picked_end_of_three_point_arc() {
// (picked points, end of the continuation, its center): both arcs leave their last
// picked point heading -Y, so the continuation is a quarter turn on that side.
let cases = [
// Clockwise: stored CCW, the last picked point (2,0) is the arc's start.
("arc 0,0 1,1 2,0", "3,-1", (3.0, 0.0)),
// Counter-clockwise: the last picked point (0,0) is the arc's end.
("arc 2,0 1,1 0,0", "-1,-1", (-1.0, 0.0)),
];
for (draw, end, center) in cases {
let mut s = Session::new();
s.cmdline(draw).unwrap();
s.cmdline("arc").unwrap();
s.cmdline("").unwrap();
s.cmdline(end).unwrap();
let a = as_arc(&last(&s));
assert!(near(a.center, center.0, center.1), "{draw}: {a:?}");
assert!((a.sweep() - std::f64::consts::FRAC_PI_2).abs() < 1e-6, "{draw}: reversed tangent {a:?}");
}
}
// ---------------- CIRCLE ----------------
#[test]
@@ -350,6 +372,118 @@ fn revcloud_command_line() {
assert_eq!(kinds(&s).len(), 4);
}
// ---------------- BREAKLINE ----------------
fn breakline_points(s: &Session) -> Vec<(f64, f64)> {
match last(s) {
EntityKind::LwPolyline(p) => {
assert!(!p.closed);
p.vertices.iter().map(|v| ((v.p.x * 1e9).round() / 1e9, (v.p.y * 1e9).round() / 1e9)).collect()
}
k => panic!("not a polyline: {k:?}"),
}
}
#[test]
fn breakline_json_symbol_size_extension_and_location() {
let mut s = Session::new();
// One polyline: the line to the symbol, the zigzag centred on the midpoint, the rest of the line.
s.execute("breakline", &json!({"p1": [0, 0], "p2": [10, 0]})).unwrap();
assert_eq!(s.doc().unwrap().model.len(), 1);
assert_eq!(breakline_points(&s), vec![(0.0, 0.0), (4.5, 0.0), (4.75, 0.5), (5.25, -0.5), (5.5, 0.0), (10.0, 0.0)]);
// Size scales the symbol, Extension runs the line past both points, `at` is projected onto the line.
s.execute("breakline", &json!({"p1": [0, 0], "p2": [10, 0], "size": 2, "extension": 1, "at": [2, 3]})).unwrap();
assert_eq!(breakline_points(&s), vec![(-1.0, 0.0), (1.0, 0.0), (1.5, 1.0), (2.5, -1.0), (3.0, 0.0), (11.0, 0.0)]);
// A location past the end keeps the symbol on the line; its end then is the line's end.
s.execute("breakline", &json!({"p1": [0, 0], "p2": [10, 0], "size": 2, "at": [100, 0]})).unwrap();
assert_eq!(breakline_points(&s), vec![(0.0, 0.0), (8.0, 0.0), (8.5, 1.0), (9.5, -1.0), (10.0, 0.0)]);
// The symbol turns with the line.
s.execute("breakline", &json!({"p1": [0, 0], "p2": [0, 10]})).unwrap();
assert_eq!(breakline_points(&s), vec![(0.0, 0.0), (0.0, 4.5), (-0.5, 4.75), (0.5, 5.25), (0.0, 5.5), (0.0, 10.0)]);
// DIMSCALE scales the size.
s.doc_mut().unwrap().header.set_f64("DIMSCALE", 4.0);
s.execute("breakline", &json!({"p1": [0, 0], "p2": [10, 0]})).unwrap();
assert_eq!(breakline_points(&s), vec![(0.0, 0.0), (3.0, 0.0), (4.0, 2.0), (6.0, -2.0), (7.0, 0.0), (10.0, 0.0)]);
let n = s.doc().unwrap().model.len();
for p in [
json!({"p1": [0, 0], "p2": [3, 0]}),
json!({"p1": [0, 0], "p2": [0, 0]}),
json!({"p1": [0, 0], "p2": [10, 0], "size": 0}),
json!({"p1": [0, 0], "p2": [10, 0], "extension": -1}),
json!({"p1": [0, 0], "p2": [10, 0], "block": "NOPE"}),
json!({"p1": [0, 0], "p2": [1e308, 0], "extension": 1e308}),
] {
assert!(s.execute("breakline", &p).is_err(), "{p}");
}
assert_eq!(s.doc().unwrap().model.len(), n);
}
#[test]
fn breakline_block_symbol_runs_between_its_defpoints() {
let mut s = Session::new();
s.execute("layer.current", &json!({"name": "Defpoints"})).unwrap();
s.execute("point", &json!({"at": [0, 0]})).unwrap();
s.execute("point", &json!({"at": [2, 0]})).unwrap();
s.execute("layer.current", &json!({"name": "0"})).unwrap();
// Drawn in pieces, one of them backwards: they are chained from one point to the other.
s.execute("line", &json!({"points": [[2, 0], [1, 1]]})).unwrap();
s.execute("line", &json!({"points": [[0, 0], [1, 1]]})).unwrap();
let hs: Vec<String> = s.doc().unwrap().model.iter().map(|e| e.handle.hex()).collect();
s.execute("block", &json!({"name": "PEAK", "base": [0, 0], "handles": hs, "keep": "delete"})).unwrap();
s.execute("breakline", &json!({"p1": [0, 0], "p2": [10, 0], "block": "PEAK"})).unwrap();
assert_eq!(breakline_points(&s), vec![(0.0, 0.0), (4.0, 0.0), (5.0, 1.0), (6.0, 0.0), (10.0, 0.0)]);
// Without two Defpoints points the block is refused.
s.execute("line", &json!({"points": [[0, 5], [1, 5]]})).unwrap();
let l = s.doc().unwrap().model.last().unwrap().handle.hex();
s.execute("block", &json!({"name": "BARE", "base": [0, 0], "handles": [l], "keep": "delete"})).unwrap();
assert!(s.execute("breakline", &json!({"p1": [0, 0], "p2": [10, 0], "block": "BARE"})).is_err());
s.cmdline("breakline b PEAK 0,0 10,0").unwrap();
s.cmdline("").unwrap();
assert!(s.running.is_none(), "{}", s.prompt_text());
assert_eq!(breakline_points(&s).len(), 5);
s.cmdline("breakline b nope").unwrap();
assert!(s.prompt_text().contains("block"), "an unknown block asks again: {}", s.prompt_text());
s.cmdline(".").unwrap();
s.cmdline("0,0 10,0").unwrap();
s.cmdline("").unwrap();
assert_eq!(breakline_points(&s).len(), 6, "`.` goes back to the built-in symbol");
}
#[test]
fn breakline_command_line_prompts_options_and_undo() {
let mut s = Session::new();
s.start("breakline").unwrap();
assert!(s.prompt_text().contains("Specify first point for breakline"), "{}", s.prompt_text());
assert!(s.prompt_text().contains("Block") && s.prompt_text().contains("Size") && s.prompt_text().contains("Extension"), "{}", s.prompt_text());
s.cmdline("0,0").unwrap();
assert!(s.prompt_text().contains("Specify second point for breakline"), "{}", s.prompt_text());
// Too short for the symbol: asked again.
s.cmdline("0.5,0").unwrap();
assert!(s.prompt_text().contains("second point"), "{}", s.prompt_text());
s.cmdline("10,0").unwrap();
assert!(s.prompt_text().contains("Specify location for break symbol") && s.prompt_text().contains("Midpoint"), "{}", s.prompt_text());
assert!(matches!(s.preview(Vec2::new(3.0, 1.0)).first().map(|e| &e.kind), Some(EntityKind::LwPolyline(p)) if p.vertices.len() == 6));
s.cmdline("").unwrap();
assert!(s.running.is_none(), "{}", s.prompt_text());
assert_eq!(breakline_points(&s), vec![(0.0, 0.0), (4.5, 0.0), (4.75, 0.5), (5.25, -0.5), (5.5, 0.0), (10.0, 0.0)]);
assert_eq!(s.state().unwrap().undo.len(), 1, "one undo step");
// Size and Extension at the first prompt, a picked location; remembered for the next time.
s.cmdline("breakline s 2 e 1 0,0 10,0 2,3").unwrap();
assert!(s.running.is_none(), "{}", s.prompt_text());
assert_eq!(breakline_points(&s), vec![(-1.0, 0.0), (1.0, 0.0), (1.5, 1.0), (2.5, -1.0), (3.0, 0.0), (11.0, 0.0)]);
assert_eq!(s.state().unwrap().undo.len(), 2);
s.cmdline("breakline s").unwrap();
assert!(s.prompt_text().contains("2.0000"), "{}", s.prompt_text());
s.cmdline("0").unwrap();
assert!(s.prompt_text().contains("size"), "zero is refused: {}", s.prompt_text());
s.cancel();
s.undo().unwrap();
assert_eq!(s.doc().unwrap().model.len(), 1, "undo removes the whole breakline");
}
// ---------------- WIPEOUT ----------------
#[test]
@@ -622,6 +756,7 @@ fn new_draw_commands_survive_garbage_input() {
"revcloud.polygonal",
"revcloud.freehand",
"wipeout",
"breakline",
"3dpoly",
"mline",
"helix",
+163 -13
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
@@ -311,11 +316,9 @@ fn run_preview(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(l) = p.get("landscape").and_then(Value::as_bool) {
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 sheet = Sheet::from_page(&page, paper::page_unit_mm(d, &page));
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();
+124 -17
View File
@@ -14,6 +14,8 @@ use serde_json::{Value, json};
use super::*;
use crate::{Accept, Input, Prompt, Result, Session, Step};
/// The rules the Command Aliases dialog checks before it sends an alias to ALIASEDIT.
pub use pgp::{MAX_ALIASES, alias_name, command_id};
pub(crate) use pgp::{specs as alias_specs, user_alias};
pub use template::{qnew_template, set_qnew_template};
@@ -37,6 +39,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 +137,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 +160,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 +250,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 +335,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 +345,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 +380,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 +428,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 +487,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 +499,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 +561,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);
+3 -2
View File
@@ -43,13 +43,13 @@ pub(crate) fn user_alias(name: &str) -> Option<String> {
}
/// The command id `target` names (`*LINE`, `_.line`, `-LAYER`…), if there is such a command.
fn command_id(target: &str) -> Option<String> {
pub fn command_id(target: &str) -> Option<String> {
let t = target.trim().trim_start_matches('*').trim().trim_start_matches(['_', '.']).to_ascii_lowercase();
find_command(&t).map(|c| c.id.to_string())
}
/// Whether `a` can be typed as an alias: one word of printable characters, not a command name.
fn alias_name(a: &str) -> std::result::Result<String, String> {
pub fn alias_name(a: &str) -> std::result::Result<String, String> {
let a = a.trim();
if a.is_empty() || a.chars().count() > MAX_ALIAS_CHARS || a.chars().any(|c| c.is_whitespace() || c.is_control() || c == '\'' || c == '{') {
return Err(format!("`{a}` can't be an alias"));
@@ -193,6 +193,7 @@ fn run_reinit(s: &mut Session, p: &Value) -> Result<Value> {
pub(crate) fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("aliasedit", "Command Aliases", run_aliasedit)
.menu(&["Tools", "Customize", "Edit Command Aliases (PGP)..."])
.params("{file?: path to a .pgp alias file | text?: its contents, set?: {alias: command}, remove?: [alias], clear?: bool} → {aliases, file, loaded, warnings}")
.enabled(always)
.noundo(),
+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], addPoints?: [[x,y]], addBoundary?: [hex], removeLoops?: [zero-based index] (geometry edits require one hatch), 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();
+13 -1
View File
@@ -137,6 +137,18 @@ fn reshaped(prev: &Hatch, h: &Hatch) -> bool {
h.loops.iter().any(|l| !prev.loops.iter().any(|q| q.vertices == l.vertices))
}
/// HATCHEDIT may add or remove whole loops without moving the ones it kept. New associative
/// loops must still match their source objects; a hatch moved on its own must not keep its link.
fn valid_loop_edit(prev: &Hatch, h: &Hatch, after: &Drawing) -> bool {
if prev.loops.len().saturating_mul(h.loops.len()) > 4_000_000 {
return false;
}
h.loops.iter().all(|l| {
prev.loops.iter().any(|old| old.vertices == l.vertices)
|| (!l.sources.is_empty() && matches!(refill(after, after, l), Refill::Loop(vs) if same_shape(&vs, &l.vertices)))
})
}
/// What happens to one associative hatch after a command; `None` when nothing changes.
fn follow(before: &Drawing, after: &Drawing, handle: Handle, h: &Hatch, checked: &mut usize) -> Option<(Hatch, Option<String>)> {
let prev = match before.entity(handle).map(|e| &e.kind) {
@@ -153,7 +165,7 @@ fn follow(before: &Drawing, after: &Drawing, handle: Handle, h: &Hatch, checked:
}
match prev {
// Moved or reshaped on its own: no longer tied to the boundary.
Some(p) if reshaped(p, h) => {
Some(p) if reshaped(p, h) && !valid_loop_edit(p, h, after) => {
detach(&mut nh);
return Some((nh, None));
}
+249 -6
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, dedup_loops, gradient_name, island_style, loops_at, loops_from_handles};
use crate::cmd::helpers::lwpoly;
use crate::cmd::{bad, bool_or, point_param, str_param, targets};
use crate::cmd::{bad, bool_or, point_param, points_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,8 +167,203 @@ 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")),
}
}
/// Boundary changes are prepared before any hatch is edited, so a bad pick cannot leave a
/// partially changed drawing. Loop numbers refer to the hatch before this request.
fn boundary_changes(s: &Session, p: &Value, hs: &[Handle]) -> Result<(Vec<HatchLoop>, Vec<usize>)> {
let given = |key: &str| p.get(key).is_some_and(|v| !v.is_null());
if !["addPoints", "addBoundary", "removeLoops"].into_iter().any(given) {
return Ok((Vec::new(), Vec::new()));
}
if hs.len() != 1 {
return Err(bad("hatchedit", "edit the boundaries of one hatch at a time"));
}
let mut added = Vec::new();
if given("addPoints") {
if s.doc()?.space_of(hs[0]) != Some(s.space()) {
return Err(bad("hatchedit", "open the hatch's space before adding points"));
}
let points = points_param(p, "addPoints").ok_or_else(|| bad("hatchedit", "`addPoints` is an array of finite [x, y] points"))?;
if points.len() > MAX_LOOPS {
return Err(bad("hatchedit", format!("at most {MAX_LOOPS} points")));
}
for q in points {
if !q.is_finite() {
return Err(bad("hatchedit", "`addPoints` must contain finite points"));
}
added.extend(loops_at(s, q)?);
if added.len() > MAX_LOOPS {
return Err(bad("hatchedit", format!("at most {MAX_LOOPS} added loops")));
}
}
}
if given("addBoundary") {
let raw =
p.get("addBoundary").and_then(Value::as_array).ok_or_else(|| bad("hatchedit", "`addBoundary` is an array of closed object handles"))?;
if raw.len() > MAX_LOOPS {
return Err(bad("hatchedit", format!("at most {MAX_LOOPS} boundary objects")));
}
let d = s.doc()?;
let space = d.space_of(hs[0]);
let mut handles = Vec::with_capacity(raw.len());
for value in raw {
let h = value.as_str().and_then(Handle::parse_hex).ok_or_else(|| bad("hatchedit", "invalid `addBoundary` handle"))?;
let e = d.entity(h).ok_or_else(|| bad("hatchedit", format!("boundary object {} not found", h.hex())))?;
if d.space_of(h) != space || closed_loop(d, e).is_none() {
return Err(bad("hatchedit", format!("boundary object {} must be closed and in the hatch's space", h.hex())));
}
handles.push(h);
}
if !handles.is_empty() {
added.extend(loops_from_handles(s, &handles)?);
}
}
let mut removed = Vec::new();
if given("removeLoops") {
let raw =
p.get("removeLoops").and_then(Value::as_array).ok_or_else(|| bad("hatchedit", "`removeLoops` is an array of zero-based loop numbers"))?;
if raw.len() > MAX_LOOPS {
return Err(bad("hatchedit", format!("at most {MAX_LOOPS} loop numbers")));
}
let count = hs
.first()
.and_then(|h| s.doc().ok()?.entity(*h))
.and_then(|e| match &e.kind {
EntityKind::Hatch(h) => Some(h.loops.len()),
_ => None,
})
.unwrap_or(0);
for value in raw {
let index = value
.as_u64()
.and_then(|n| usize::try_from(n).ok())
.filter(|n| *n < count)
.ok_or_else(|| bad("hatchedit", "`removeLoops` contains an invalid loop number"))?;
if !removed.contains(&index) {
removed.push(index);
}
}
}
Ok((added, removed))
}
fn edit_loops(h: &mut Hatch, added: &[HatchLoop], removed: &[usize]) -> Result<()> {
if added.is_empty() && removed.is_empty() {
return Ok(());
}
h.loops = h.loops.drain(..).enumerate().filter_map(|(i, l)| (!removed.contains(&i)).then_some(l)).collect();
h.loops.extend_from_slice(added);
dedup_loops(&mut h.loops);
if h.loops.is_empty() {
return Err(bad("hatchedit", "a hatch needs at least one boundary loop"));
}
if h.loops.len() > MAX_LOOPS {
return Err(bad("hatchedit", format!("a hatch may have at most {MAX_LOOPS} loops")));
}
let outlines: Vec<Vec<Vec2>> = h.loops.iter().map(outline).collect();
for (i, l) in h.loops.iter_mut().enumerate() {
let p = outlines.get(i).and_then(|v| v.first()).copied();
let depth = p.map_or(0, |q| outlines.iter().enumerate().filter(|(j, other)| *j != i && point_in_polygon(other, q)).count());
l.outer = depth % 2 == 0;
}
Ok(())
}
pub(super) fn run_hatchedit(s: &mut Session, p: &Value) -> Result<Value> {
check_numbers(p)?;
let hs = hatches(s, p)?;
let (added, removed) = boundary_changes(s, p, &hs)?;
let style = match str_param(p, "style") {
Some(t) => Some(island_style(t).ok_or_else(|| bad("hatchedit", "`style` is \"normal\", \"outer\" or \"ignore\""))?),
None => None,
@@ -185,6 +382,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())
@@ -209,7 +410,18 @@ pub(super) fn run_hatchedit(s: &mut Session, p: &Value) -> Result<Value> {
let mut budget = MAX_LOOPS;
for handle in &hs {
let Some(EntityKind::Hatch(mut h)) = s.doc()?.entity(*handle).map(|e| e.kind.clone()) else { continue };
edit_loops(&mut h, &added, &removed)?;
if h.associative && h.loops.iter().any(|l| l.sources.is_empty()) && p.get("associative").and_then(Value::as_bool) != Some(false) {
let missing = link(s.doc()?, *handle, &mut h, None, &mut budget);
if missing != 0 {
detach(&mut h);
notes.push(format!("Hatch {}: {missing} new boundary loop(s) could not be linked; it is now non-associative.", handle.hex()));
}
}
properties(&mut h, p);
if let Some(g) = &grad {
g.apply(&mut h);
}
if let Some(st) = style {
h.style = st;
}
@@ -258,6 +470,9 @@ pub(super) fn run_hatchedit(s: &mut Session, p: &Value) -> Result<Value> {
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Ask {
Option,
AddPoint,
AddObject,
Remove,
Style,
Pattern,
Scale,
@@ -268,7 +483,18 @@ enum Ask {
Link,
}
const OPTIONS: [&str; 7] = ["Disassociate", "Style", "Properties", "recreate Boundary", "ASsociate", "separate Hatches", "Origin"];
const OPTIONS: [&str; 10] = [
"Disassociate",
"Style",
"Properties",
"recreate Boundary",
"ASsociate",
"separate Hatches",
"Origin",
"add Point",
"add Object",
"remove Boundary",
];
/// -HATCHEDIT: select hatches, then one option.
pub(crate) struct HatchEditM {
@@ -320,6 +546,9 @@ impl Interactive for HatchEditM {
let h = self.first(s);
match self.ask {
Ask::Option => Prompt::new("Enter a hatch option", Accept::TEXT).kw(&OPTIONS),
Ask::AddPoint => Prompt::new("Pick an internal point for the new boundary", Accept::POINT),
Ask::AddObject => Prompt::new("Select closed boundary objects to add", Accept::SELECT),
Ask::Remove => Prompt::new("Enter a boundary loop number to remove (starting at 1)", Accept::TEXT),
Ask::Style => {
let cur = match h.map_or(0, |h| h.style) {
1 => "Outer",
@@ -373,10 +602,24 @@ impl Interactive for HatchEditM {
Some("recreate Boundary") => self.ask = Ask::Recreate,
Some("ASsociate") => self.ask = Ask::Link,
Some("Origin") => self.ask = Ask::Origin,
Some("add Point") => self.ask = Ask::AddPoint,
Some("add Object") => self.ask = Ask::AddObject,
Some("remove Boundary") => self.ask = Ask::Remove,
_ => s.echo("Choose one of the options."),
}
Ok(Step::Continue)
}
(Ask::AddPoint, Input::Point(p)) => self.apply(s, json!({ "addPoints": [[p.x, p.y]] })),
(Ask::AddObject, Input::Pick(hs)) => self.apply(s, json!({ "addBoundary": hs.iter().map(|h| h.hex()).collect::<Vec<_>>() })),
(Ask::AddPoint | Ask::AddObject, Input::Enter) => Ok(Step::Done),
(Ask::AddPoint | Ask::AddObject, _) => Ok(Step::Continue),
(Ask::Remove, _) => {
let Some(index) = text.as_deref().and_then(|t| t.parse::<usize>().ok()).and_then(|n| n.checked_sub(1)) else {
s.echo("Enter a positive boundary loop number.");
return Ok(Step::Continue);
};
self.apply(s, json!({ "removeLoops": [index] }))
}
(Ask::Style, Input::Enter) => Ok(Step::Done),
(Ask::Style, _) => match text.as_deref().and_then(island_style) {
Some(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)]
}
}
+215 -30
View File
@@ -1,7 +1,7 @@
//! Inquiry: DIST, ID, LIST, AREA, drawing inspection for agents.
use cadcraft_doc::{EntityKind, Handle};
use cadcraft_geom::{Polyline, Vec2};
use cadcraft_doc::{Entity, EntityKind, Handle};
use cadcraft_geom::{Polyline, Vec2, Vec3};
use serde_json::{Value, json};
use super::*;
@@ -9,6 +9,7 @@ use crate::units::{format_angle, format_distance};
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
mod regions;
mod shapes;
pub fn specs() -> Vec<CommandSpec> {
let mut v = vec![
@@ -41,7 +42,11 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("status", "Status", run_status).menu(&["Tools", "Inquiry", "Status"]).noundo(),
CommandSpec::new("drawing.inspect", "Inspect Drawing", run_inspect).params("{entities?: bool, limit?: n}").noundo(),
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}")
.params("{type?, layer?, selected?: bool (default false; true = current selection in active space), window?: [[x,y],[x,y]], crossing?: bool (default true; false = entirely inside), limit?, offset?} → {count (all matches), returned, offset, entities}")
.noundo(),
CommandSpec::new("shapes", "Analyze Shapes", shapes::run).params(shapes::PARAMS).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")
@@ -68,28 +73,119 @@ fn fmt_a(s: &Session, v: f64) -> String {
format_angle(v, h.0, h.1)
}
fn dist_text(s: &Session, a: Vec2, b: Vec2) -> String {
/// The angle of `d` up from the XY plane.
fn angle_from_xy(d: Vec3) -> f64 {
d.z.atan2(d.xy().len())
}
fn dist_text(s: &Session, a: Vec3, b: Vec3) -> String {
let d = b - a;
format!(
"Distance = {}, Angle in XY Plane = {}, Angle from XY Plane = 0\nDelta X = {}, Delta Y = {}, Delta Z = {}",
fmt_d(s, a.dist(b)),
fmt_a(s, d.angle()),
"Distance = {}, Angle in XY Plane = {}, Angle from XY Plane = {}\nDelta X = {}, Delta Y = {}, Delta Z = {}",
fmt_d(s, (b - a).len()),
fmt_a(s, d.xy().angle()),
fmt_a(s, angle_from_xy(d)),
fmt_d(s, d.x),
fmt_d(s, d.y),
fmt_d(s, 0.0)
fmt_d(s, d.z)
)
}
/// A point parameter with its Z: the third number when given, else the Z of what is drawn
/// there ([`z_at`]).
fn point3_req(s: &Session, cmd: &str, p: &Value, key: &str) -> Result<Vec3> {
let q = point_req(cmd, p, key)?;
let z = p.get(key).and_then(|v| v.get(2)).and_then(Value::as_f64).filter(|z| z.is_finite());
Ok(Vec3::new(q.x, q.y, z.unwrap_or_else(|| z_at(s, q))))
}
fn run_dist(s: &mut Session, p: &Value) -> Result<Value> {
let a = point_req("dist", p, "p1")?;
let b = point_req("dist", p, "p2")?;
Ok(json!({ "distance": a.dist(b), "angle": (b - a).angle().to_degrees(), "dx": b.x - a.x, "dy": b.y - a.y, "message": dist_text(s, a, b) }))
let a = point3_req(s, "dist", p, "p1")?;
let b = point3_req(s, "dist", p, "p2")?;
let d = b - a;
Ok(json!({
"distance": d.len(),
"angle": d.xy().angle().to_degrees(),
"angleFromXY": angle_from_xy(d).to_degrees(),
"dx": d.x,
"dy": d.y,
"dz": d.z,
"message": dist_text(s, a, b),
}))
}
fn id_text(s: &Session, a: Vec3) -> String {
format!("X = {} Y = {} Z = {}", fmt_d(s, a.x), fmt_d(s, a.y), fmt_d(s, a.z))
}
fn run_id(s: &mut Session, p: &Value) -> Result<Value> {
let a = point_req("id", p, "at")?;
s.last_point = a;
Ok(json!({ "x": a.x, "y": a.y, "message": format!("X = {} Y = {} Z = {}", fmt_d(s, a.x), fmt_d(s, a.y), fmt_d(s, 0.0)) }))
let a = point3_req(s, "id", p, "at")?;
s.last_point = a.xy();
Ok(json!({ "x": a.x, "y": a.y, "z": a.z, "message": id_text(s, a) }))
}
/// The Z of what is drawn at `p`: the object a pick finds there, at that point, or the current
/// elevation (ELEVATION) when there is none. Points come from the canvas in 2D; the object
/// under a snapped point gives its Z back, as AutoCAD's ID and DIST report a snapped point's.
pub(crate) fn z_at(s: &Session, p: Vec2) -> f64 {
let Ok(d) = s.doc() else { return 0.0 };
let elevation = d.header.f64("ELEVATION", 0.0);
let ap = s.pixel_size() * s.settings.pickbox.max(1.0) * 1.5;
let space = s.space();
crate::select::pick(d, &space, p, ap)
.and_then(|h| d.entity(h))
.and_then(|e| entity_z(e, p))
.filter(|z| z.is_finite())
.unwrap_or(if elevation.is_finite() { elevation } else { 0.0 })
}
/// The Z of `e` at the point of it nearest `p` (seen from above), when it has one there.
fn entity_z(e: &Entity, p: Vec2) -> Option<f64> {
let on_segment = |a: Vec3, b: Vec3| {
let d = b.xy() - a.xy();
let l2 = d.dot(d);
let t = if l2 > 0.0 { ((p - a.xy()).dot(d) / l2).clamp(0.0, 1.0) } else { 0.0 };
(a.z + (b.z - a.z) * t, (a.xy() + d * t).dist(p))
};
let along = |pts: &[Vec3], closed: bool| {
let n = pts.len();
let segs = if closed { n } else { n.saturating_sub(1) };
(0..segs)
.filter_map(|i| Some(on_segment(*pts.get(i)?, *pts.get((i + 1) % n)?)))
.min_by(|a, b| a.1.total_cmp(&b.1))
.map(|(z, _)| z)
.or_else(|| pts.first().map(|q| q.z))
};
let n = e.common.extrusion;
let plan = n.x.abs() < 1e-9 && n.y.abs() < 1e-9 && n.z != 0.0;
match &e.kind {
EntityKind::Point(q) => Some(q.p.z),
EntityKind::Line(l) => Some(on_segment(l.a, l.b).0),
EntityKind::Polyline3d(pl) => along(&pl.points, pl.closed),
// Elevation is in the polyline's OCS: down Z for a mirrored one.
EntityKind::LwPolyline(pl) if plan => Some(if n.z < 0.0 { -pl.elevation } else { pl.elevation }),
// Read in world coordinates already.
EntityKind::Circle(c) => Some(c.center.z),
EntityKind::Arc(a) => Some(a.center.z),
EntityKind::Text(t) | EntityKind::AttDef(cadcraft_doc::Attrib { text: t, .. }) => Some(t.insert.z),
EntityKind::MText(t) => Some(t.insert.z),
EntityKind::Insert(i) => Some(i.insert.z),
EntityKind::Face3d(f) => face_z(&f.corners, p).or_else(|| along(&f.corners, true)),
_ => None,
}
}
/// The Z at `p` on a 3D face: on the plane of the triangle of its corners that holds `p`.
fn face_z(c: &[Vec3; 4], p: Vec2) -> Option<f64> {
let tri = |a: Vec3, b: Vec3, d: Vec3| -> Option<f64> {
let n = (b - a).cross(d - a);
if n.z.abs() < 1e-12 * (1.0 + n.len()) {
return None;
}
let inside = cadcraft_geom::point_in_polygon(&[a.xy(), b.xy(), d.xy()], p);
inside.then(|| a.z - (n.x * (p.x - a.x) + n.y * (p.y - a.y)) / n.z)
};
tri(c[0], c[1], c[2]).or_else(|| if c[3] == c[2] { None } else { tri(c[0], c[2], c[3]) })
}
fn list_text(s: &Session, h: Handle) -> String {
@@ -109,23 +205,25 @@ fn list_text(s: &Session, h: Handle) -> String {
"\n from point, X={} Y={} Z={}\n to point, X={} Y={} Z={}",
fmt_d(s, l.a.x),
fmt_d(s, l.a.y),
fmt_d(s, 0.0),
fmt_d(s, l.a.z),
fmt_d(s, l.b.x),
fmt_d(s, l.b.y),
fmt_d(s, 0.0)
fmt_d(s, l.b.z)
));
t.push_str(&format!(
"\n Length = {}, Angle in XY Plane = {}",
fmt_d(s, l.a.xy().dist(l.b.xy())),
fmt_d(s, (l.b - l.a).len()),
fmt_a(s, l.a.xy().angle_to(l.b.xy()))
));
let d = l.b - l.a;
t.push_str(&format!("\n Delta X = {}, Delta Y = {}, Delta Z = {}", fmt_d(s, d.x), fmt_d(s, d.y), fmt_d(s, d.z)));
}
EntityKind::Circle(c) => {
t.push_str(&format!(
"\n center point, X={} Y={} Z={}\n radius {}\n circumference {}\n area {}",
fmt_d(s, c.center.x),
fmt_d(s, c.center.y),
fmt_d(s, 0.0),
fmt_d(s, c.center.z),
fmt_d(s, c.radius),
fmt_d(s, c.radius * cadcraft_geom::TAU),
fmt_d(s, c.radius * c.radius * cadcraft_geom::PI)
@@ -136,7 +234,7 @@ fn list_text(s: &Session, h: Handle) -> String {
"\n center point, X={} Y={} Z={}\n radius {}\n start angle {}\n end angle {}",
fmt_d(s, a.center.x),
fmt_d(s, a.center.y),
fmt_d(s, 0.0),
fmt_d(s, a.center.z),
fmt_d(s, a.radius),
fmt_a(s, a.start),
fmt_a(s, a.end)
@@ -150,16 +248,20 @@ fn list_text(s: &Session, h: Handle) -> String {
} else {
t.push_str(&format!("\n length {}", fmt_d(s, pl.len())));
}
// The elevation, in world Z (down Z for a mirrored polyline).
let z = entity_z(e, Vec2::ZERO).unwrap_or(p.elevation);
t.push_str(&format!("\n elevation {}", fmt_d(s, z)));
for v in &p.vertices {
t.push_str(&format!("\n at point X={} Y={} Z={}", fmt_d(s, v.p.x), fmt_d(s, v.p.y), fmt_d(s, 0.0)));
t.push_str(&format!("\n at point X={} Y={} Z={}", fmt_d(s, v.p.x), fmt_d(s, v.p.y), fmt_d(s, z)));
}
}
EntityKind::Text(tx) => {
t.push_str(&format!(
"\n Style = \"{}\"\n start point, X={} Y={}\n height {}\n text {}\n rotation angle {}",
"\n Style = \"{}\"\n start point, X={} Y={} Z={}\n height {}\n text {}\n rotation angle {}",
tx.style,
fmt_d(s, tx.insert.x),
fmt_d(s, tx.insert.y),
fmt_d(s, tx.insert.z),
fmt_d(s, tx.height),
tx.value,
fmt_a(s, tx.rotation)
@@ -180,7 +282,7 @@ fn run_list(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "message": texts.join("\n\n") }))
}
fn area_of(pts: &[Vec2]) -> (f64, f64) {
pub(super) fn area_of(pts: &[Vec2]) -> (f64, f64) {
let a = cadcraft_geom::shoelace(pts).abs();
let n = pts.len();
let per: f64 = (0..n).filter_map(|i| Some(pts.get(i)?.dist(*pts.get((i + 1) % n)?))).sum();
@@ -202,14 +304,16 @@ fn area_text(s: &Session, a: f64, per: f64) -> String {
}
/// Area and perimeter of a closed object (AREA's Object option).
fn object_area(s: &Session, h: Handle) -> Result<(f64, f64)> {
pub(super) fn object_area(s: &Session, h: Handle) -> Result<(f64, f64)> {
let d = s.doc()?;
let e = d.entity(h).ok_or_else(|| bad("area", "no such object"))?;
Ok(match &e.kind {
EntityKind::Circle(c) => (c.radius * c.radius * cadcraft_geom::PI, c.radius * cadcraft_geom::TAU),
EntityKind::LwPolyline(pl) => {
// An open polyline is measured as if closed for the area, but its perimeter is its own length (AutoCAD's AREA help).
let g = Polyline { vertices: pl.vertices.clone(), closed: true };
(g.area().abs(), g.len())
let drawn = Polyline { vertices: pl.vertices.clone(), closed: pl.closed };
(g.area().abs(), drawn.len())
}
EntityKind::Ellipse(el) => {
let arc = cadcraft_geom::Ellipse { center: el.center.xy(), major: el.major.xy(), ratio: el.ratio, start: el.start, end: el.end };
@@ -218,7 +322,9 @@ fn object_area(s: &Session, h: Handle) -> Result<(f64, f64)> {
}
let a = el.major.xy().len();
let b = a * el.ratio;
(cadcraft_geom::PI * a * b, cadcraft_geom::PI * (3.0 * (a + b) - ((3.0 * a + b) * (a + 3.0 * b)).sqrt()))
// The perimeter integrated around the ellipse, not a closed-form estimate (those
// fall short as the ellipse flattens: 0.3% on a 100:1 one).
(cadcraft_geom::PI * a * b, arc.arc_length_from(0.0, cadcraft_geom::TAU))
}
EntityKind::Hatch(hh) => {
// Odd parity, as drawn: a loop nested inside an odd number of other loops is an unfilled island.
@@ -283,7 +389,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 +404,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))
@@ -329,14 +438,17 @@ fn run_entities(s: &mut Session, p: &Value) -> Result<Value> {
})
.transpose()?;
let crossing = given("crossing").map_or(Ok(true), |v| v.as_bool().ok_or_else(|| bad(CMD, "`crossing` must be true or false")))?;
let selected = given("selected").map_or(Ok(false), |v| v.as_bool().ok_or_else(|| bad(CMD, "`selected` must be true or false")))?;
let limit = count("limit", 500)?;
let offset = count("offset", 0)?;
let st = s.state()?;
let d = &st.doc;
let Some(store) = d.space(&st.space) else { return Ok(json!({ "count": 0, "returned": 0, "entities": [] })) };
let tol = window.as_ref().map(crate::select::window_tol).unwrap_or(0.0);
let selection: std::collections::HashSet<Handle> = if selected { st.selection.iter().copied().collect() } else { Default::default() };
let matches: Vec<&std::sync::Arc<cadcraft_doc::Entity>> = store
.iter()
.filter(|e| !selected || selection.contains(&e.handle))
.filter(|e| ty.as_ref().is_none_or(|t| e.kind.type_name().to_ascii_lowercase() == *t || e.kind.dxf_name().to_ascii_lowercase() == *t))
.filter(|e| layer.as_ref().is_none_or(|l| e.common.layer.to_ascii_lowercase() == *l))
.filter(|e| window.as_ref().is_none_or(|w| crate::select::in_window(d, e, None, w, crossing, tol)))
@@ -346,10 +458,82 @@ 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)]
struct DistM {
pub(super) struct DistM {
first: Option<Vec2>,
first_source: Option<crate::snap::ViewportPoint>,
/// Multiple points: the points so far, the first included.
@@ -475,7 +659,8 @@ impl Interactive for DistM {
(Some(first), Some(second)) if first.viewport == second.viewport => (first.point, second.point),
_ => (a, b),
};
let t = dist_text(s, a, b);
let (za, zb) = if self.first_source.is_some() || s.picked_viewport.is_some() { (0.0, 0.0) } else { (z_at(s, a), z_at(s, b)) };
let t = dist_text(s, Vec3::new(a.x, a.y, za), Vec3::new(b.x, b.y, zb));
for l in t.lines() {
s.echo(l.to_string());
}
@@ -522,7 +707,7 @@ impl Interactive for IdM {
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
if let Input::Point(p) = i {
let m = format!("X = {} Y = {} Z = {}", fmt_d(s, p.x), fmt_d(s, p.y), fmt_d(s, 0.0));
let m = id_text(s, Vec3::new(p.x, p.y, z_at(s, p)));
s.echo(m);
}
Ok(Step::Done)
@@ -584,7 +769,7 @@ enum AreaMode {
}
#[derive(Default)]
struct AreaM {
pub(super) struct AreaM {
pts: Vec<Vec2>,
mode: AreaMode,
total: f64,
+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) {
+196
View File
@@ -0,0 +1,196 @@
//! SHAPES: geometry facts for every closed shape in the current space — perimeter, area, centroid,
//! winding, the first self-intersection and coincident vertices — for agents that read a drawing.
//! Read-only.
use std::collections::{BTreeMap, BTreeSet};
use cadcraft_doc::{Entity, EntityKind};
use cadcraft_geom::{Bounds2, Polyline, Vec2, intersect_segments};
use serde_json::{Value, json};
use super::super::*;
use super::{area_of, object_area};
use crate::{Result, Session};
const CMD: &str = "shapes";
pub const PARAMS: &str = "{type?, layer?, window?: [[x,y],[x,y]], crossing?: bool (default true; false = entirely inside), limit? (default 200), offset?, tolerance?: positive number (default 1e-9 × the extents diagonal; near-duplicate vertices and a first vertex equal to the last)} → {schema: 1, count (all closed shapes), returned, offset, truncated, shapes: [{handle, type, layer, perimeter, area (≥ 0), winding: ccw|cw, centroid: [x,y] | null (zero area), selfIntersection: [x,y] | null (the first crossing of two non-adjacent edges; null past 4000 edges), duplicateVertices: [{index, distance} (vertices index and index+1, wrapping)]}], skipped: {<type>: {count, reason}} (the other matches)}: closed polylines, circles, full ellipses and closed splines, arcs and bulges exact";
/// The facts SHAPES reports for one closed shape, or why an entity is not one.
enum Facts {
Shape { perimeter: f64, area: f64, signed: f64, centroid: Option<Vec2>, cross: Option<Vec2>, dups: Vec<(usize, f64)> },
Skip(&'static str),
}
pub fn run(s: &mut Session, p: &Value) -> Result<Value> {
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_ascii_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 ty = text("type")?;
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(Bounds2::new(*a, *b)),
_ => Err(bad(CMD, "`window` must be two corner points [[x0, y0], [x1, y1]]")),
}
})
.transpose()?;
let crossing = given("crossing").map_or(Ok(true), |v| v.as_bool().ok_or_else(|| bad(CMD, "`crossing` must be true or false")))?;
let limit = count("limit", 200)?;
let offset = count("offset", 0)?;
let tolerance = given("tolerance")
.map(|v| v.as_f64().filter(|t| t.is_finite() && *t > 0.0).ok_or_else(|| bad(CMD, "`tolerance` must be a positive number")))
.transpose()?;
let s: &Session = s;
let st = s.state()?;
let d = &st.doc;
let empty = || json!({ "schema": 1, "count": 0, "returned": 0, "offset": offset, "truncated": false, "shapes": [], "skipped": {} });
let Some(store) = d.space(&st.space) else { return Ok(empty()) };
let tol = tolerance.unwrap_or_else(|| {
let ext = d.extents(&st.space);
let diag = if ext.is_empty() { 0.0 } else { ext.min.dist(ext.max) };
(diag * 1e-9).max(1e-12)
});
let wtol = window.as_ref().map(crate::select::window_tol).unwrap_or(0.0);
let mut skipped: BTreeMap<&str, (usize, BTreeSet<&str>)> = BTreeMap::new();
let mut shapes: Vec<(&Entity, Facts)> = Vec::new();
for e in store
.iter()
.filter(|e| ty.as_ref().is_none_or(|t| e.kind.type_name().to_ascii_lowercase() == *t || e.kind.dxf_name().to_ascii_lowercase() == *t))
.filter(|e| layer.as_ref().is_none_or(|l| e.common.layer.to_ascii_lowercase() == *l))
.filter(|e| window.as_ref().is_none_or(|w| crate::select::in_window(d, e, None, w, crossing, wtol)))
{
match facts(s, e, tol)? {
Facts::Skip(why) => {
let entry = skipped.entry(e.kind.type_name()).or_default();
entry.0 += 1;
entry.1.insert(why);
}
f => shapes.push((e, f)),
}
}
let page: Vec<Value> = shapes.iter().skip(offset).take(limit).map(|(e, f)| shape_json(e, f)).collect();
let skipped: BTreeMap<&str, Value> =
skipped.into_iter().map(|(k, (n, why))| (k, json!({ "count": n, "reason": why.into_iter().collect::<Vec<_>>().join("; ") }))).collect();
Ok(json!({
"schema": 1,
"count": shapes.len(),
"returned": page.len(),
"offset": offset,
"truncated": page.len() < shapes.len().saturating_sub(offset),
"shapes": page,
"skipped": skipped,
}))
}
fn shape_json(e: &Entity, f: &Facts) -> Value {
let Facts::Shape { perimeter, area, signed, centroid, cross, dups } = f else { return Value::Null };
json!({
"handle": e.handle.hex(),
"type": e.kind.type_name(),
"layer": e.common.layer,
"perimeter": perimeter,
"area": area,
"winding": if *signed < 0.0 { "cw" } else { "ccw" },
"centroid": centroid.map(|c| [c.x, c.y]),
"selfIntersection": cross.map(|c| [c.x, c.y]),
"duplicateVertices": dups.iter().map(|(i, dist)| json!({ "index": i, "distance": dist })).collect::<Vec<_>>(),
})
}
fn facts(s: &Session, e: &Entity, tol: f64) -> Result<Facts> {
Ok(match &e.kind {
EntityKind::Circle(c) => {
let (area, perimeter) = object_area(s, e.handle)?;
Facts::Shape { perimeter, area, signed: area, centroid: Some(c.center.xy()), cross: None, dups: Vec::new() }
}
EntityKind::Ellipse(el) => {
let geom = cadcraft_geom::Ellipse { center: el.center.xy(), major: el.major.xy(), ratio: el.ratio, start: el.start, end: el.end };
if !geom.is_full() {
return Ok(Facts::Skip("open"));
}
let (area, perimeter) = object_area(s, e.handle)?;
Facts::Shape { perimeter, area, signed: area, centroid: Some(geom.center), cross: None, dups: Vec::new() }
}
EntityKind::LwPolyline(pl) => {
let mut vertices = pl.vertices.clone();
let ends_meet = match (vertices.first(), vertices.last()) {
(Some(a), Some(b)) => vertices.len() > 1 && a.p.dist(b.p) <= tol,
_ => false,
};
if !pl.closed {
if !ends_meet {
return Ok(Facts::Skip("open"));
}
// The last vertex repeats the first: the shape closes there.
vertices.pop();
}
if vertices.len() < 2 {
return Ok(Facts::Skip("fewer than two vertices"));
}
let g = Polyline { vertices, closed: true };
let signed = g.area();
let n = g.vertices.len();
let dups = (0..n)
.filter_map(|i| {
let dist = g.vertices.get(i)?.p.dist(g.vertices.get((i + 1) % n)?.p);
(dist <= tol).then_some((i, dist))
})
.collect();
Facts::Shape { perimeter: g.len(), area: signed.abs(), signed, centroid: g.centroid(), cross: first_crossing(&g), dups }
}
EntityKind::Spline(sp) => {
let mut pts = sp.tessellate(1e-3);
let ends_meet = match (pts.first(), pts.last()) {
(Some(a), Some(b)) => pts.len() > 2 && a.dist(*b) <= tol.max(1e-9),
_ => false,
};
if !sp.closed && !ends_meet {
return Ok(Facts::Skip("open"));
}
if ends_meet {
pts.pop();
}
if pts.len() < 3 {
return Ok(Facts::Skip("degenerate"));
}
let (area, perimeter) = area_of(&pts);
let g = Polyline::from_points(&pts, true);
Facts::Shape { perimeter, area, signed: g.area(), centroid: g.centroid(), cross: first_crossing(&g), dups: Vec::new() }
}
EntityKind::Arc(_) => Facts::Skip("open"),
_ => Facts::Skip("not a closed shape"),
})
}
/// Shapes with more edges than this report no selfIntersection.
const MAX_CROSSING_SEGMENTS: usize = 4_000;
/// The first crossing between two non-adjacent edges, in edge order.
fn first_crossing(g: &Polyline) -> Option<Vec2> {
let segs = g.segments();
let n = segs.len();
// The pairwise test is quadratic: past this many edges the answer is null, not a hang.
if n > MAX_CROSSING_SEGMENTS {
return None;
}
for (i, a) in segs.iter().enumerate() {
for (j, b) in segs.iter().enumerate().skip(i + 2) {
if i == 0 && j + 1 == n {
continue;
}
if let Some(x) = intersect_segments(a, b).first() {
return Some(*x);
}
}
}
None
}
+3 -1
View File
@@ -40,7 +40,9 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("layoff", "Layer Off", |s, p| set_obj_layers(s, p, |l| l.on = false))
.menu(&["Format", "Layer Tools", "Layer Off"])
.params("{handles?}")
.interactive(|_| Ok(Box::new(SelectRun::single("layoff", "LAYOFF", "Select an object on the layer to be turned off")))),
// Layer Off stays active until Enter or Esc, as in AutoCAD (#1418): one object per
// pick and the prompt comes straight back.
.interactive(|_| Ok(Box::new(SelectRun::repeat("layoff", "LAYOFF", "Select an object on the layer to be turned off")))),
CommandSpec::new("laylck", "Lock Layer", |s, p| set_obj_layers(s, p, |l| l.locked = true))
.menu(&["Format", "Layer Tools", "Lock Layer"])
.params("{handles?}")
+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),
}
}
}
+39 -9
View File
@@ -121,31 +121,50 @@ impl SelectPhase {
}
/// "Select objects:", then run a JSON command on them as `{handles}`. With a pickfirst selection it
/// runs at once, exactly like the JSON form does on the current selection.
/// runs at once, exactly like the JSON form does on the current selection. A repeating run acts on
/// every pick and asks for the next object, the way AutoCAD's LAYOFF does.
pub struct SelectRun {
id: &'static str,
name: &'static str,
/// Single-object prompt (e.g. LAYFRZ's "Select an object on the layer to be frozen").
single: Option<&'static str>,
/// Stay active after each pick, until Enter or Esc (LAYOFF).
repeat: bool,
sel: SelectPhase,
}
impl SelectRun {
pub fn new(id: &'static str, name: &'static str) -> Self {
SelectRun { id, name, single: None, sel: SelectPhase::default() }
SelectRun { id, name, single: None, repeat: false, sel: SelectPhase::default() }
}
/// Pick one object, with a command-specific prompt.
pub fn single(id: &'static str, name: &'static str, msg: &'static str) -> Self {
SelectRun { single: Some(msg), ..SelectRun::new(id, name) }
}
fn run(&self, s: &mut Session, hs: &[Handle]) -> Result<Step> {
/// Get an object, act on it and ask again, until Enter or Esc: AutoCAD's Layer Off (LAYOFF)
/// turns off the layer of every object picked, one after another, in one command.
pub fn repeat(id: &'static str, name: &'static str, msg: &'static str) -> Self {
SelectRun { single: Some(msg), repeat: true, ..SelectRun::new(id, name) }
}
fn run(&self, s: &mut Session, hs: &[Handle]) -> Result<()> {
let spec = super::find_command(self.id).ok_or_else(|| EngineError::UnknownCommand(self.id.into()))?;
// Call the command body directly: the running command records the single undo step.
let r = (spec.run)(s, &json!({ "handles": hs.iter().map(|h| h.hex()).collect::<Vec<_>>() }))?;
if let Some(m) = r.get("message").and_then(Value::as_str) {
s.echo(m.to_string());
}
Ok(Step::Done)
Ok(())
}
/// One pick of a repeating run: act on it at once and keep the prompt up.
fn pick(&self, s: &mut Session, hs: &[Handle]) -> Result<Step> {
if hs.is_empty() {
s.echo("0 found");
return Ok(Step::Continue);
}
self.run(s, hs)?;
// Objects picked at the prompt don't stay selected afterwards.
s.set_selection(Vec::new());
Ok(Step::Continue)
}
}
@@ -157,26 +176,37 @@ impl Interactive for SelectRun {
self.sel = SelectPhase::begin(s);
if self.sel.done {
let hs = self.sel.picked.clone();
return self.run(s, &hs);
self.run(s, &hs)?;
return Ok(Step::Done);
}
self.sel.single = self.single.is_some();
// A repeating run acts on every pick, so no single pick may finish it.
self.sel.single = self.single.is_some() && !self.repeat;
Ok(Step::Continue)
}
fn prompt(&self, _s: &Session) -> Prompt {
match self.single {
Some(msg) if !self.sel.removing => Prompt::new(msg, Accept::SELECT),
// A repeating run keeps asking for the next object, whatever selection keywords were typed.
Some(msg) if self.repeat || !self.sel.removing => Prompt::new(msg, Accept::SELECT),
_ => self.sel.prompt(),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
// A repeating run acts on each pick and prompts again; Enter (or Esc) ends it.
if self.repeat {
return match i {
Input::Pick(hs) => self.pick(s, &hs),
Input::Enter => Ok(Step::Done),
other => self.sel.feed(s, &other).map(|_| Step::Continue),
};
}
match self.sel.feed(s, &i)? {
SelOutcome::More => Ok(Step::Continue),
SelOutcome::Empty => Ok(Step::Done),
SelOutcome::Done(hs) => {
// Objects picked at the prompt don't stay selected afterwards.
let step = self.run(s, &hs)?;
self.run(s, &hs)?;
s.set_selection(Vec::new());
Ok(step)
Ok(Step::Done)
}
}
}
+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());
}
+9 -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;
@@ -29,6 +31,7 @@ mod overkill;
mod pline_opts;
pub mod plotters;
mod props;
pub use draw2::breakline::BreaklineOptions;
pub use props::MatchProps;
mod qselect;
mod selecttools;
@@ -37,6 +40,8 @@ mod spline_opts;
mod table;
mod tolerance;
mod trimextend;
mod txt2mtxt;
pub use txt2mtxt::Txt2MtxtOptions;
mod utility;
mod view;
@@ -176,6 +181,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());
@@ -197,7 +203,9 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(utility::specs());
v.extend(table::specs());
v.extend(tolerance::specs());
v.extend(txt2mtxt::specs());
v.extend(constraints::specs());
v.extend(lisp::specs());
v
})
}
+293 -84
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"])
@@ -91,7 +92,7 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("join", "Join", run_join)
.menu(&["Modify", "Join"])
.alias(&["j"])
.params("{handles?}")
.params("{handles?, close?: bool (a single arc becomes its circle, an elliptical arc its full ellipse)}")
.interactive(|_| Ok(Box::new(SelectThen::new(Op::Join)))),
]
}
@@ -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,14 @@ 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();
// `_C`, `_L`: the English name, as scripts write it.
let word = word.strip_prefix('_').unwrap_or(&word).to_string();
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 +439,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 +448,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 +484,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 +519,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);
@@ -499,10 +586,14 @@ pub(crate) fn offset_kind(k: &EntityKind, dist: f64, side: Vec2) -> Option<Entit
EntityKind::Ellipse(e) => {
// Approximate with a polyline offset of the tessellation.
let ge = cadcraft_geom::Ellipse { center: e.center.xy(), major: e.major.xy(), ratio: e.ratio, start: e.start, end: e.end };
// Inside = on the centre side of the full ellipse, even for an open arc.
let inside = {
let mut pts = Vec::new();
ge.tessellate(e.major.xy().len() * 1e-3, &mut pts);
cadcraft_geom::point_in_polygon(&pts, side)
let a = e.major.xy().len();
let b = a * e.ratio.abs();
let u = e.major.xy().normalized();
let d = side - e.center.xy();
let (x, y) = (d.dot(u) * b, d.dot(u.perp()) * a);
x * x + y * y < (a * b) * (a * b)
};
// offset_points shifts along the left normal, which points into a counter-clockwise ellipse.
let mut pts = Vec::new();
@@ -597,20 +688,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 +729,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 +1020,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() {
@@ -1287,6 +1403,16 @@ pub(crate) fn explode_kind(d: &cadcraft_doc::Drawing, e: &Entity) -> Option<Vec<
}
fn explode(s: &mut Session, hs: &[Handle]) -> Result<Vec<Handle>> {
explode_with(s, hs, explode_kind)
}
/// Replace each of `hs` (off locked layers) by the pieces `pieces` gives for it; objects it gives
/// none for are left alone. EXPLODE, and BURST with its own pieces for block references.
pub(crate) fn explode_with(
s: &mut Session,
hs: &[Handle],
pieces: impl Fn(&cadcraft_doc::Drawing, &Entity) -> Option<Vec<Entity>>,
) -> Result<Vec<Handle>> {
let space = s.space();
let mut out = Vec::new();
for h in hs {
@@ -1294,10 +1420,16 @@ fn explode(s: &mut Session, hs: &[Handle]) -> Result<Vec<Handle>> {
continue;
}
let Some(e) = s.doc()?.entity(*h).map(|e| (**e).clone()) else { continue };
let Some(parts) = explode_kind(s.doc()?, &e) else { continue };
let Some(parts) = pieces(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) {
@@ -1315,8 +1447,21 @@ fn run_explode(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "handles": r.iter().map(|h| h.hex()).collect::<Vec<_>>() }))
}
/// Gives a rebuilt arc piece its source centre Z, or a rebuilt polyline piece its source elevation.
fn with_z(mut k: EntityKind, z: f64) -> EntityKind {
match &mut k {
EntityKind::Arc(a) => a.center.z = z,
EntityKind::LwPolyline(p) => p.elevation = z,
_ => {}
}
k
}
/// 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) => {
@@ -1325,12 +1470,13 @@ pub(crate) fn break_entity(s: &mut Session, h: Handle, p1: Vec2, p2: Vec2) -> Re
if t1 > t2 {
std::mem::swap(&mut t1, &mut t2);
}
let at3 = |t: f64| ln.at(t).to3(l.a.z + (l.b.z - l.a.z) * t);
let mut v = Vec::new();
if t1 > 1e-12 {
v.push(line(ln.a, ln.at(t1)));
v.push(EntityKind::Line(cadcraft_doc::Line { a: l.a, b: at3(t1) }));
}
if t2 < 1.0 - 1e-12 {
v.push(line(ln.at(t2), ln.b));
v.push(EntityKind::Line(cadcraft_doc::Line { a: at3(t2), b: l.b }));
}
v
}
@@ -1341,7 +1487,7 @@ pub(crate) fn break_entity(s: &mut Session, h: Handle, p1: Vec2, p2: Vec2) -> Re
let a1 = c.center.xy().angle_to(p1);
let a2 = c.center.xy().angle_to(p2);
// AutoCAD removes the CCW portion from the first to the second point.
vec![arc(&Arc::new(c.center.xy(), c.radius, a2, a1))]
vec![with_z(arc(&Arc::new(c.center.xy(), c.radius, a2, a1)), c.center.z)]
}
EntityKind::Arc(a) => {
let ga = Arc::new(a.center.xy(), a.radius, a.start, a.end);
@@ -1352,15 +1498,15 @@ pub(crate) fn break_entity(s: &mut Session, h: Handle, p1: Vec2, p2: Vec2) -> Re
}
let mut v = Vec::new();
if t1 > 1e-12 {
v.push(arc(&Arc::new(ga.center, ga.radius, ga.start, ga.start + t1)));
v.push(with_z(arc(&Arc::new(ga.center, ga.radius, ga.start, ga.start + t1)), a.center.z));
}
if t2 < ga.sweep() - 1e-12 {
v.push(arc(&Arc::new(ga.center, ga.radius, ga.start + t2, ga.end)));
v.push(with_z(arc(&Arc::new(ga.center, ga.radius, ga.start + t2, ga.end)), a.center.z));
}
v
}
EntityKind::LwPolyline(p) if !p.closed => {
let pl = Polyline { vertices: p.vertices.clone(), closed: false };
EntityKind::LwPolyline(p) => {
let pl = Polyline { vertices: p.vertices.clone(), closed: p.closed };
let segs = pl.segments();
let total: f64 = segs.iter().map(Segment::len).sum();
let lp = |q: Vec2| {
@@ -1377,17 +1523,28 @@ pub(crate) fn break_entity(s: &mut Session, h: Handle, p1: Vec2, p2: Vec2) -> Re
best.1
};
let (mut t1, mut t2) = (lp(p1), lp(p2));
if t1 > t2 {
std::mem::swap(&mut t1, &mut t2);
if p.closed {
if (t1 - t2).abs() < 1e-9 {
return Err(EngineError::Other("Cannot break a closed polyline at a single point.".into()));
}
// Like a circle: the stretch from the first point to the second, along the
// vertices, goes; what is left runs from the second point round to the first.
let doubled: Vec<Segment> = segs.iter().chain(segs.iter()).copied().collect();
let end = if t1 > t2 { t1 } else { t1 + total };
vec![with_z(lwpoly(sub_polyline(&doubled, t2, end), false), p.elevation)]
} else {
if t1 > t2 {
std::mem::swap(&mut t1, &mut t2);
}
let mut v = Vec::new();
if t1 > 1e-12 {
v.push(with_z(lwpoly(sub_polyline(&segs, 0.0, t1), false), p.elevation));
}
if t2 < total - 1e-12 {
v.push(with_z(lwpoly(sub_polyline(&segs, t2, total), false), p.elevation));
}
v
}
let mut v = Vec::new();
if t1 > 1e-12 {
v.push(lwpoly(sub_polyline(&segs, 0.0, t1), false));
}
if t2 < total - 1e-12 {
v.push(lwpoly(sub_polyline(&segs, t2, total), false));
}
v
}
_ => return Err(EngineError::Other("Cannot break that object.".into())),
};
@@ -1426,6 +1583,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.
@@ -1502,6 +1662,10 @@ pub(crate) fn join(s: &mut Session, hs: &[Handle]) -> Result<Option<Handle>> {
fn run_join(s: &mut Session, p: &Value) -> Result<Value> {
let hs = targets(s, p)?;
if p.get("close").and_then(Value::as_bool) == Some(true) {
let h = super::modify2::join_close(s, &hs)?;
return Ok(json!({ "handle": h.hex() }));
}
let r = join(s, &hs)?;
Ok(json!({ "handle": r.map(|h| h.hex()) }))
}
@@ -1538,6 +1702,8 @@ struct SelectThen {
ref_len: Option<f64>,
copy_mode: bool,
ask_erase: bool,
/// JOIN of a single arc or elliptical arc: the prompt asks for arcs to join to it, or cLose.
join_source: Option<Handle>,
}
impl SelectThen {
@@ -1554,6 +1720,7 @@ impl SelectThen {
ref_len: None,
copy_mode: false,
ask_erase: false,
join_source: None,
}
}
fn apply(&mut self, s: &mut Session) -> Result<Step> {
@@ -1569,23 +1736,57 @@ impl SelectThen {
Ok(Step::Done)
}
Op::Join => {
match join(s, &self.objs) {
Ok(h) => {
// Objects outside the joined chain are kept, so count only the ones JOIN consumed
// (and the source object an ellipse or spline join keeps).
let d = s.doc()?;
let n = self.objs.iter().filter(|o| d.entity(**o).is_none() || h == Some(**o)).count();
let what = match h.and_then(|h| d.entity(h)).map(|e| &e.kind) {
Some(EntityKind::Spline(_)) => "spline",
Some(EntityKind::Ellipse(_)) => "ellipse",
_ => "polyline",
};
s.echo(format!("{n} objects joined into 1 {what}"))
}
Err(e) => s.echo(e.to_string()),
if let [h] = self.objs[..]
&& s.doc()?.entity(h).is_some_and(|e| super::modify2::closed_kind(&e.kind).is_some())
{
self.join_source = Some(h);
return Ok(Step::Continue);
}
self.join_now(s)
}
_ => Ok(Step::Continue),
}
}
/// JOIN the selected objects and report what they became.
fn join_now(&mut self, s: &mut Session) -> Result<Step> {
match join(s, &self.objs) {
Ok(h) => {
// Objects outside the joined chain are kept, so count only the ones JOIN consumed
// (and the source object an ellipse or spline join keeps).
let d = s.doc()?;
let n = self.objs.iter().filter(|o| d.entity(**o).is_none() || h == Some(**o)).count();
let what = match h.and_then(|h| d.entity(h)).map(|e| &e.kind) {
Some(EntityKind::Spline(_)) => "spline",
Some(EntityKind::Ellipse(_)) => "ellipse",
_ => "polyline",
};
s.echo(format!("{n} objects joined into 1 {what}"))
}
Err(e) => s.echo(e.to_string()),
}
Ok(Step::Done)
}
/// JOIN of a single arc or elliptical arc: more arcs picked join to it on Enter, cLose makes
/// it its circle or full ellipse.
fn join_source_input(&mut self, s: &mut Session, src: Handle, i: Input) -> Result<Step> {
match i {
Input::Keyword(k) if k == "cLose" => {
let ellipse = matches!(s.doc()?.entity(src).map(|e| &e.kind), Some(EntityKind::Ellipse(_)));
super::modify2::join_close(s, &[src])?;
s.echo(if ellipse { "The elliptical arc is now an ellipse." } else { "The arc is now a circle." });
Ok(Step::Done)
}
Input::Point(p) => {
if let Some(h) = super::curves::pick_at(s, p)
&& !self.objs.contains(&h)
{
self.objs.push(h);
}
Ok(Step::Continue)
}
Input::Enter => self.join_now(s),
_ => Ok(Step::Continue),
}
}
@@ -1770,6 +1971,11 @@ impl Interactive for SelectThen {
if self.ask_erase {
return Prompt::new("Erase source objects?", Accept::TEXT).kw(&["Yes", "No"]).default("No");
}
if let Some(src) = self.join_source {
let ellipse = s.doc().is_ok_and(|d| matches!(d.entity(src).map(|e| &e.kind), Some(EntityKind::Ellipse(_))));
let msg = if ellipse { "Select elliptical arcs to join to source" } else { "Select arcs to join to source" };
return Prompt::new(msg, Accept::POINT).kw(&["cLose"]);
}
let k = self.pts.len();
let base = self.pts.first().copied();
if self.op == Op::Scale && self.reference {
@@ -1817,6 +2023,9 @@ impl Interactive for SelectThen {
}
}
}
if let Some(src) = self.join_source {
return self.join_source_input(s, src, i);
}
if self.ask_erase {
let yes = matches!(&i, Input::Keyword(k) if k == "Yes") || matches!(&i, Input::Text(t) if t.trim().to_ascii_lowercase().starts_with('y'));
let (a, b) = (self.pts[0], self.pts[1]);
+34 -9
View File
@@ -1,4 +1,4 @@
//! Modify menu depth (M2): PEDIT, LENGTHEN, ALIGN, BLEND, REVERSE, SPLINEDIT, NCOPY, CHSPACE,
//! Modify menu depth (M2): PEDIT, LENGTHEN, ALIGN, BLEND, REVERSE, SPLINEDIT, NCOPY, MOCORO, CHSPACE,
//! FLATTEN, plus the fillet/trim/extend extensions used by modify.rs (arcs, polylines,
//! ellipses and splines).
@@ -16,7 +16,8 @@ use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, St
mod curve_edits;
mod fillet_pline;
pub(crate) use curve_edits::join_curves;
mod mocoro;
pub(crate) use curve_edits::{closed_kind, join_close, join_curves};
pub(crate) use fillet_pline::{ChamferDistances, fillet_pline};
pub fn specs() -> Vec<CommandSpec> {
@@ -61,6 +62,10 @@ pub fn specs() -> Vec<CommandSpec> {
.menu(&["Modify", "Copy Nested Objects"])
.params("{handle (block reference), pick?: [x,y] (nearest nested object; default all), delta? | from,to}")
.interactive(|_| Ok(Box::new(NcopyM::default()))),
CommandSpec::new("mocoro", "Move/Copy/Rotate", mocoro::run_mocoro)
.menu(&["Tools", "Express Tools", "Move/Copy/Rotate"])
.params("{handles?, base: [x,y], ops: [{move: [x,y]} | {copy: [x,y]} | {rotate: degrees} | {scale: factor} | {base: [x,y]}]} → {handles (copies)}")
.interactive(|_| Ok(Box::new(mocoro::MocoroM::default()))),
CommandSpec::new("chspace", "Change Space", run_chspace)
.menu(&["Modify", "Change Space"])
.params("{handles?, to?: \"model\" | layout name, viewport?: handle}")
@@ -454,10 +459,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)
@@ -754,7 +766,13 @@ pub(crate) fn lengthen_kind(k: &EntityKind, pick: Option<Vec2>, mode: LenMode) -
return Err(too_short());
}
let ne = fixed + dir * nl;
Ok(if at_b { line(a, ne) } else { line(ne, b) })
let mut out = l.clone();
if at_b {
out.b = ne.to3(l.b.z);
} else {
out.a = ne.to3(l.a.z);
}
Ok(EntityKind::Line(out))
}
EntityKind::Arc(ar) => {
let g = Arc::new(ar.center.xy(), ar.radius, ar.start, ar.end);
@@ -775,7 +793,8 @@ pub(crate) fn lengthen_kind(k: &EntityKind, pick: Option<Vec2>, mode: LenMode) -
if !(ns > 1e-12 && ns < TAU - 1e-9) {
return Err(other("The new arc would be zero length or a full circle."));
}
Ok(if at_end { arc(&Arc::new(g.center, g.radius, g.start, g.start + ns)) } else { arc(&Arc::new(g.center, g.radius, g.end - ns, g.end)) })
let na = if at_end { Arc::new(g.center, g.radius, g.start, g.start + ns) } else { Arc::new(g.center, g.radius, g.end - ns, g.end) };
Ok(EntityKind::Arc(cadcraft_doc::Arc { radius: na.radius, start: na.start, end: na.end, ..ar.clone() }))
}
EntityKind::LwPolyline(p) if !p.closed && p.vertices.len() >= 2 => {
let total = Polyline { vertices: p.vertices.clone(), closed: false }.len();
@@ -1308,6 +1327,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 +1337,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 +1470,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(_))) {
@@ -313,6 +316,32 @@ pub(crate) fn join_curves(s: &mut Session, hs: &[Handle]) -> Result<Option<Handl
Ok(Some(keep))
}
/// What JOIN's Close option makes of a single arc (its circle) or elliptical arc (its full
/// ellipse); `None` for anything else.
pub(crate) fn closed_kind(k: &EntityKind) -> Option<EntityKind> {
match k {
EntityKind::Arc(a) => Some(EntityKind::Circle(cadcraft_doc::Circle { center: a.center, radius: a.radius })),
EntityKind::Ellipse(e) if !ellipse_of(e).is_full() => Some(ellipse_kind(&Ellipse { start: 0.0, end: TAU, ..ellipse_of(e) })),
_ => None,
}
}
/// JOIN Close: the single arc or elliptical arc in `hs` becomes its circle or full ellipse,
/// keeping its handle.
pub(crate) fn join_close(s: &mut Session, hs: &[Handle]) -> Result<Handle> {
let mut seen = std::collections::HashSet::new();
let hs: Vec<Handle> = hs.iter().copied().filter(|h| seen.insert(*h)).collect();
let refuse = || other("Close works on a single arc or elliptical arc.");
let [h] = hs[..] else { return Err(refuse()) };
let k = s.doc()?.entity(h).and_then(|e| closed_kind(&e.kind)).ok_or_else(refuse)?;
if curves::is_locked(s, h) {
return Err(other("An object is on a locked layer."));
}
set_kind(s, h, k)?;
s.set_selection(Vec::new());
Ok(h)
}
// ---------------- FILLET with elliptical arcs and splines ----------------
/// An entity as a tangency target for FILLET (lines infinite, arcs as their circle, elliptical
+293
View File
@@ -0,0 +1,293 @@
//! MOCORO (Express Tools Move/Copy/Rotate): select objects and a base point once, then Move,
//! Copy, Rotate, Scale, Base and Undo at one option prompt until eXit.
//!
//! Copy leaves the selected objects where they are and adds copies: the options after it act on
//! the originals, from the same base point. Move carries the base point along, so a later Rotate
//! or Scale turns about the same spot on the objects. The whole command is one undo step; Undo
//! inside it steps back one option at a time (Base included), up to the last 1000.
use std::sync::Arc;
use cadcraft_doc::{Drawing, EntityKind, Handle};
use cadcraft_geom::{Mat3, Vec2};
use serde_json::{Value, json};
use super::other;
use crate::cmd::curves::KW;
use crate::cmd::helpers::line;
use crate::cmd::machines::{SelOutcome, SelectPhase, number};
use crate::cmd::modify::transform_entities;
use crate::cmd::{bad, point_req, point_value, targets};
use crate::prompt::Drag;
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
/// Most options one JSON call runs.
const MAX_OPS: usize = 10_000;
/// Most options Undo can step back through; the oldest are dropped past it.
const MAX_HISTORY: usize = 1000;
#[derive(Clone, Copy, Debug, PartialEq)]
enum Op {
Move(Vec2),
Copy(Vec2),
/// Radians, counterclockwise.
Rotate(f64),
Scale(f64),
Base(Vec2),
}
fn positive_factor(f: f64) -> Option<f64> {
(f.is_finite() && f > 1e-12).then_some(f)
}
/// One `ops` entry: an object with exactly one of move, copy, rotate (degrees), scale or base.
fn parse_op(i: usize, v: &Value) -> Result<Op> {
let err = |m: &str| bad("mocoro", format!("`ops[{i}]`: {m}"));
let Some(o) = v.as_object().filter(|o| o.len() == 1) else {
return Err(err("one of {move|copy|base: [x,y]} | {rotate: degrees} | {scale: factor}"));
};
let Some((k, v)) = o.iter().next() else { return Err(err("empty")) };
let point = || point_value(v).ok_or_else(|| err("a point [x, y] is required"));
match k.as_str() {
"move" => Ok(Op::Move(point()?)),
"copy" => Ok(Op::Copy(point()?)),
"base" => Ok(Op::Base(point()?)),
"rotate" => v.as_f64().filter(|a| a.is_finite()).map(|a| Op::Rotate(a.to_radians())).ok_or_else(|| err("`rotate` is an angle in degrees")),
"scale" => v.as_f64().and_then(positive_factor).map(Op::Scale).ok_or_else(|| err("`scale` is a factor above zero")),
_ => Err(err("unknown option (move, copy, rotate, scale, base)")),
}
}
/// How many of `objs` are still in the drawing, and how many of those are on a locked layer
/// (they don't move, rotate or scale; copies are made).
fn counts(s: &Session, objs: &[Handle]) -> Result<(usize, usize)> {
let d = s.doc()?;
let live: Vec<_> = objs.iter().filter_map(|h| d.entity(*h)).collect();
let locked = live.iter().filter(|e| d.layer(&e.common.layer).is_some_and(|l| l.locked)).count();
Ok((live.len(), locked))
}
fn locked_message(n: usize) -> String {
if n == 1 { "1 object was on a locked layer.".into() } else { format!("{n} objects were on a locked layer.") }
}
/// Apply one option to the objects; returns the copies made. The drawing is not touched when
/// nothing would change (objects gone or all locked), so no empty undo step is recorded.
fn apply(s: &mut Session, objs: &[Handle], base: &mut Vec2, op: Op) -> Result<Vec<Handle>> {
let b = *base;
let (live, locked) = counts(s, objs)?;
let m = match op {
Op::Base(p) => {
*base = p;
return Ok(Vec::new());
}
Op::Copy(p) if live > 0 => return transform_entities(s, objs, &Mat3::translate(p - b), true),
Op::Copy(_) => return Ok(Vec::new()),
Op::Move(p) => Mat3::translate(p - b),
Op::Rotate(a) => Mat3::rotate_about(b, a),
Op::Scale(f) => Mat3::scale_about(b, f),
};
if locked > 0 {
s.echo(locked_message(locked));
}
if locked < live {
transform_entities(s, objs, &m, false)?;
if let Op::Move(p) = op {
*base = p;
}
}
Ok(Vec::new())
}
pub(super) fn run_mocoro(s: &mut Session, p: &Value) -> Result<Value> {
let objs = targets(s, p)?;
let mut base = point_req("mocoro", p, "base")?;
let list = p.get("ops").and_then(Value::as_array).ok_or_else(|| bad("mocoro", "`ops` (array) is required"))?;
if list.len() > MAX_OPS {
return Err(bad("mocoro", format!("at most {MAX_OPS} `ops`")));
}
// Every option is checked before the drawing changes.
let ops = list.iter().enumerate().map(|(i, v)| parse_op(i, v)).collect::<Result<Vec<_>>>()?;
let mut copies = Vec::new();
for op in ops {
copies.extend(apply(s, &objs, &mut base, op)?);
}
Ok(json!({ "handles": copies.iter().map(|h| h.hex()).collect::<Vec<_>>() }))
}
/// The prompt MOCORO is at.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
enum Mode {
#[default]
Select,
BasePoint,
Options,
Move,
Copy,
Rotate,
Scale,
Base,
}
#[derive(Default)]
pub(super) struct MocoroM {
sel: SelectPhase,
objs: Vec<Handle>,
base: Vec2,
mode: Mode,
/// The drawing and base point before each option that changed either, for Undo.
history: Vec<(Arc<Drawing>, Vec2)>,
}
impl MocoroM {
fn run(&mut self, s: &mut Session, op: Op) -> Result<Step> {
let before = s.state()?.doc.clone();
let base = self.base;
apply(s, &self.objs, &mut self.base, op)?;
if !Arc::ptr_eq(&before, &s.state()?.doc) || self.base != base {
if self.history.len() >= MAX_HISTORY {
self.history.remove(0);
}
self.history.push((before, base));
}
self.mode = Mode::Options;
Ok(Step::Continue)
}
fn undo(&mut self, s: &mut Session) -> Result<Step> {
match self.history.pop() {
Some((doc, base)) => {
// Settings changed meanwhile (a transparent command) stay.
let header = s.doc()?.header.clone();
s.state_mut()?.doc = doc;
if s.doc()?.header != header {
s.doc_mut()?.header = header;
}
self.base = base;
}
None => s.echo("Nothing to undo."),
}
Ok(Step::Continue)
}
/// The transform under the cursor in an option that drags the objects.
fn matrix(&self, c: Vec2) -> Option<Mat3> {
let b = self.base;
match self.mode {
Mode::Move | Mode::Copy => Some(Mat3::translate(c - b)),
Mode::Rotate => Some(Mat3::rotate_about(b, b.angle_to(c))),
Mode::Scale => positive_factor(b.dist(c)).map(|f| Mat3::scale_about(b, f)),
_ => None,
}
}
}
impl Interactive for MocoroM {
fn name(&self) -> &'static str {
"MOCORO"
}
fn begin(&mut self, s: &mut Session) -> Result<Step> {
self.sel = SelectPhase::begin(s);
if self.sel.done {
self.objs = self.sel.picked.clone();
self.mode = Mode::BasePoint;
}
Ok(Step::Continue)
}
fn prompt(&self, _s: &Session) -> Prompt {
let b = self.base;
match self.mode {
Mode::Select => self.sel.prompt(),
Mode::BasePoint => Prompt::new("Specify base point", Accept::POINT),
Mode::Options => Prompt::new("Enter an option", KW).kw(&["Move", "Copy", "Rotate", "Scale", "Base", "Undo", "eXit"]).default("eXit"),
Mode::Move | Mode::Copy => Prompt::new("Specify second point", Accept::POINT).base(b),
Mode::Rotate => Prompt::new("Specify rotation angle", Accept::POINT_OR_NUMBER).base(b),
Mode::Scale => Prompt::new("Specify scale factor", Accept::POINT_OR_NUMBER).base(b),
Mode::Base => Prompt::new("Specify base point", Accept::POINT).base(b),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
match (self.mode, i) {
(Mode::Select, i) => match self.sel.feed(s, &i)? {
SelOutcome::More => Ok(Step::Continue),
SelOutcome::Empty => Ok(Step::Done),
SelOutcome::Done(hs) => {
self.objs = hs;
self.mode = Mode::BasePoint;
Ok(Step::Continue)
}
},
(Mode::BasePoint, Input::Point(p)) => {
self.base = p;
self.mode = Mode::Options;
Ok(Step::Continue)
}
(Mode::BasePoint, Input::Enter) => {
s.set_selection(Vec::new());
Ok(Step::Done)
}
(Mode::Options, Input::Keyword(k)) => {
self.mode = match k.as_str() {
"Move" => Mode::Move,
"Copy" => Mode::Copy,
"Rotate" => Mode::Rotate,
"Scale" => Mode::Scale,
"Base" => Mode::Base,
"Undo" => return self.undo(s),
_ => {
s.set_selection(Vec::new());
return Ok(Step::Done);
}
};
Ok(Step::Continue)
}
(Mode::Options, Input::Enter) => {
s.set_selection(Vec::new());
Ok(Step::Done)
}
(Mode::Options, _) => Err(other("Invalid option keyword.")),
// Enter in an option goes back to the option prompt without changing anything.
(Mode::Move | Mode::Copy | Mode::Rotate | Mode::Scale | Mode::Base, Input::Enter) => {
self.mode = Mode::Options;
Ok(Step::Continue)
}
(Mode::Move, Input::Point(p)) => self.run(s, Op::Move(p)),
(Mode::Copy, Input::Point(p)) => self.run(s, Op::Copy(p)),
(Mode::Base, Input::Point(p)) => self.run(s, Op::Base(p)),
(Mode::Rotate, Input::Point(p)) => self.run(s, Op::Rotate(self.base.angle_to(p))),
(Mode::Rotate, Input::Text(t)) => {
let a = s.angle_settings().rotation(&t).filter(|a| a.is_finite()).ok_or_else(|| other("Requires an angle or point."))?;
self.run(s, Op::Rotate(a))
}
(Mode::Scale, Input::Point(p)) => {
let f = positive_factor(self.base.dist(p)).ok_or_else(|| other("Requires a positive number."))?;
self.run(s, Op::Scale(f))
}
(Mode::Scale, Input::Text(t)) => {
let f = number(&t).and_then(positive_factor).ok_or_else(|| other("Requires a positive number."))?;
self.run(s, Op::Scale(f))
}
_ => Ok(Step::Continue),
}
}
fn preview(&self, s: &Session, c: Vec2) -> Vec<EntityKind> {
let (Some(m), Ok(d)) = (self.matrix(c), s.doc()) else { return Vec::new() };
let mut out: Vec<EntityKind> = self
.objs
.iter()
.take(500)
.filter_map(|h| d.entity(*h))
.map(|e| {
let mut k = e.kind.clone();
k.transform(&m);
k
})
.collect();
out.push(line(self.base, c));
out
}
fn drag(&self, _s: &Session, c: Vec2) -> Option<Drag> {
let matrix = self.matrix(c)?;
Some(Drag { handles: self.objs.clone(), matrix, extra: vec![line(self.base, c)], clipboard: false })
}
}
+86
View File
@@ -214,6 +214,39 @@ fn lengthen_json() {
assert!(near(poly(&kind(&s, &p)).vertices[0].p, -5.0, 10.0));
}
#[test]
fn lengthen_keeps_elevation() {
let mut s = Session::new();
let l = h(&s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap());
let a = h(&s.execute("arc", &json!({"center": [0, 0], "radius": 1, "start": 0, "end": 90})).unwrap());
let doc = s.doc_mut().unwrap();
doc.modify_entity(Handle::parse_hex(&l).unwrap(), |e| {
if let EntityKind::Line(x) = &mut e.kind {
x.a.z = 5.0;
x.b.z = 5.0;
}
})
.unwrap();
doc.modify_entity(Handle::parse_hex(&a).unwrap(), |e| {
if let EntityKind::Arc(x) = &mut e.kind {
x.center.z = 3.0;
}
})
.unwrap();
s.execute("lengthen", &json!({"handle": l, "delta": 2})).unwrap();
let EntityKind::Line(x) = kind(&s, &l) else { panic!("not a line") };
assert_eq!((x.a.x, x.a.y, x.a.z), (0.0, 0.0, 5.0));
assert_eq!((x.b.x, x.b.y, x.b.z), (12.0, 0.0, 5.0));
s.execute("lengthen", &json!({"handle": l, "delta": 1, "pick": [0, 0]})).unwrap();
let EntityKind::Line(x) = kind(&s, &l) else { panic!("not a line") };
assert_eq!((x.a.x, x.a.y, x.a.z), (-1.0, 0.0, 5.0));
assert_eq!((x.b.x, x.b.y, x.b.z), (12.0, 0.0, 5.0));
s.execute("lengthen", &json!({"handle": a, "deltaAngle": 10})).unwrap();
let EntityKind::Arc(x) = kind(&s, &a) else { panic!("not an arc") };
assert_eq!(x.center.z, 3.0);
assert!((arc_of(&kind(&s, &a)).sweep() - 100f64.to_radians()).abs() < 1e-9);
}
#[test]
fn lengthen_command_line() {
let mut s = Session::new();
@@ -683,3 +716,56 @@ 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);
}
#[test]
fn offset_elliptical_arc_follows_picked_side() {
let max_x = |side: Value| {
let mut s = Session::new();
let e = h(&s.execute("ellipse", &json!({"center": [0, 0], "major": [10, 0], "ratio": 0.5, "start": 0, "end": 90})).unwrap());
s.execute("offset", &json!({"handle": e, "distance": 1, "side": side})).unwrap();
let p = poly(&last(&s));
p.vertices.iter().map(|v| v.p.x).fold(f64::MIN, f64::max)
};
let centre = max_x(json!([0, 0]));
assert!((centre - 9.0).abs() < 0.05, "centre side went outward: max x = {centre}");
let outer = max_x(json!([20, 20]));
assert!((outer - 11.0).abs() < 0.05, "outer side went inward: max x = {outer}");
}
+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),
+14 -1
View File
@@ -33,12 +33,24 @@ pub const PROFILE_VARS: &[&str] = &[
"OSNAPHATCH",
"LWDISPLAY",
"QPMODE",
"SELECTIONEFFECT",
"SELECTIONPREVIEW",
"PREVIEWEFFECT",
"PREVIEWFILTER",
"GRIPS",
"GRIPOBJLIMIT",
"SELECTIONAREA",
"WINDOWAREACOLOR",
"CROSSINGAREACOLOR",
"SELECTIONAREAOPACITY",
"PICKSTYLE",
"PICKBOX",
"APERTURE",
"GRIPSIZE",
"CURSORSIZE",
"PICKFIRST",
"PICKADD",
"PICKDRAG",
"MAXARRAY",
];
@@ -50,7 +62,7 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("options", "Options...", run_options)
.menu(&["Tools", "Options..."])
.alias(&["op", "preferences"])
.params("{reset?: bool, OSMODE?, PICKBOX?, APERTURE?, GRIPSIZE?, CURSORSIZE?, PICKFIRST?, PICKADD?, …: any profile variable}")
.params("{reset?: bool, OSMODE?, PICKBOX?, APERTURE?, GRIPSIZE?, CURSORSIZE?, PICKFIRST?, PICKADD?, PICKDRAG?: 0 (two clicks) | 2 (clicks or drag), …: any profile variable}")
.enabled(always)
.noundo(),
]
@@ -130,6 +142,7 @@ pub fn reset(s: &mut Session) {
st.cursorsize = d.cursorsize;
st.pickfirst = d.pickfirst;
st.pickadd = d.pickadd;
st.pickdrag = d.pickdrag;
st.maxarray = d.maxarray;
}
+3 -3
View File
@@ -90,19 +90,19 @@ impl PlineAsk {
PlineAsk::Angle | PlineAsk::CenterAngle(_) | PlineAsk::RadiusAngle(_) => Prompt::new("Specify included angle", Accept::NUMBER),
PlineAsk::AngleEnd(_) => end(&["CEnter", "Radius"]),
PlineAsk::AngleCenter(_) | PlineAsk::Center => Prompt::new("Specify center point of arc", Accept::POINT).base(st),
PlineAsk::AngleRadius(_) | PlineAsk::Radius => Prompt::new("Specify radius of arc", Accept::POINT_OR_NUMBER).base(st),
PlineAsk::AngleRadius(_) | PlineAsk::Radius => Prompt::new("Specify radius of arc", Accept::POINT_OR_NUMBER).base(st).distance(),
PlineAsk::Chord(a, _) => {
let d = shown_direction(s, chord_default(tangent, a));
Prompt::new("Specify direction of chord for arc", Accept::POINT_OR_NUMBER).base(st).default(d)
}
PlineAsk::CenterEnd(_) => end(&["Angle", "Length"]),
PlineAsk::CenterLength(_) => Prompt::new("Specify length of chord", Accept::POINT_OR_NUMBER).base(st),
PlineAsk::CenterLength(_) => Prompt::new("Specify length of chord", Accept::POINT_OR_NUMBER).base(st).distance(),
PlineAsk::Direction => Prompt::new("Specify the tangent direction for the start point of arc", Accept::POINT_OR_NUMBER).base(st),
PlineAsk::DirectionEnd(_) => Prompt::new("Specify endpoint of the arc", Accept::POINT).base(st),
PlineAsk::RadiusEnd(_) => end(&["Angle"]),
PlineAsk::Second => Prompt::new("Specify second point on arc", Accept::POINT).base(st),
PlineAsk::SecondEnd(_) => Prompt::new("Specify end point of arc", Accept::POINT).base(st),
PlineAsk::Length => Prompt::new("Specify length of line", Accept::POINT_OR_NUMBER).base(st),
PlineAsk::Length => Prompt::new("Specify length of line", Accept::POINT_OR_NUMBER).base(st).distance(),
}
}
+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> {
+68 -3
View File
@@ -11,12 +11,12 @@ use cadcraft_geom::Vec3;
use serde_json::{Value, json};
use super::*;
use crate::{Result, Session};
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("properties", "Properties", run_get).menu(&["Modify", "Properties"]).alias(&["pr", "props", "ch"]).params("{handles?} → properties of the selection").noundo(),
CommandSpec::new("properties.set", "Set Properties", run_set).params("{handles?, layer?, color?, linetype?, lineweight?, ltscale?, transparency?: \"ByLayer\" | \"ByBlock\" | 0..90, visible?, <geometry fields: radius, center, start, end, text, height, rotation…>}"),
CommandSpec::new("properties.set", "Set Properties", run_set).params("{handles?, layer?, color?, linetype?, lineweight?, ltscale?, transparency?: \"ByLayer\" | \"ByBlock\" | 0..90, visible?, <geometry fields: radius, center, start, end, text, height, rotation…>; dimensions: textOverride, dimStyle, textHeight, arrowSize, dimScale, precision (per-dimension overrides)}"),
CommandSpec::new("matchprop", "Match Properties", matchprop::run_matchprop)
.menu(&["Modify", "Match Properties"])
.alias(&["ma", "painter"])
@@ -30,7 +30,7 @@ pub fn specs() -> Vec<CommandSpec> {
.noundo(),
CommandSpec::new("-linetype", "Linetype", run_linetype).params("{current?, load?, file? | text?, reload?} (as LINETYPE)").interactive(|_| Ok(Box::new(linfile::DashLinetype::new()))),
CommandSpec::new("lweight", "Lineweight...", run_lweight).menu(&["Format", "Lineweight..."]).alias(&["lw", "lineweight"]).params("{lineweight: mm | ByLayer}"),
CommandSpec::new("ltscale", "Linetype Scale", run_ltscale).alias(&["lts"]).params("{scale}"),
CommandSpec::new("ltscale", "Linetype Scale", run_ltscale).alias(&["lts"]).params("{scale}").interactive(|_| Ok(Box::new(LtscaleM))),
CommandSpec::new("units", "Units...", run_units).menu(&["Format", "Units..."]).alias(&["un"]).params("{lunits?: 1..5, luprec?: 0..8, aunits?: 0..4, auprec?: 0..8, insunits?}"),
CommandSpec::new("limits", "Drawing Limits", run_limits).menu(&["Format", "Drawing Limits"]).params("{min: [x,y], max: [x,y]}"),
CommandSpec::new("style", "Text Style...", run_style)
@@ -203,6 +203,7 @@ fn run_set(s: &mut Session, p: &Value) -> Result<Value> {
let dim_style = str_param(p, "dimStyle")
.map(|n| d.dim_style(n).map(|st| st.name.clone()).ok_or_else(|| bad("properties.set", format!("dimension style `{n}` not found"))))
.transpose()?;
let dim_over = dim_overrides(p)?;
let anno_scale = d.current_anno_scale();
for h in &hs {
d.modify_entity(*h, |e| {
@@ -341,6 +342,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 +360,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);
@@ -367,6 +375,10 @@ fn run_set(s: &mut Session, p: &Value) -> Result<Value> {
dm.style.clone_from(st);
dm.block = None;
}
if !dim_over.is_empty() {
dm.overrides.extend(dim_over.clone());
dm.block = None;
}
}
EntityKind::Hatch(h) => {
if let Some(v) = str_param(p, "pattern") {
@@ -464,6 +476,39 @@ fn run_ltscale(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "message": "Regenerating model." }))
}
/// Typed LTSCALE: "Enter new linetype scale factor <1.0000>:".
struct LtscaleM;
impl Interactive for LtscaleM {
fn name(&self) -> &'static str {
"LTSCALE"
}
fn prompt(&self, s: &Session) -> Prompt {
let cur = s.doc().map(|d| d.header.f64("LTSCALE", 1.0)).unwrap_or(1.0);
Prompt::new("Enter new linetype scale factor", Accept::NUMBER).default(format!("{cur:.4}"))
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
let text = match i {
Input::Enter | Input::Cancel => return Ok(Step::Done),
Input::Text(t) => t,
_ => return Ok(Step::Continue),
};
let scale = text.trim().parse::<f64>().ok().filter(|v| v.is_finite() && *v > 0.0);
match scale.map(|v| run_ltscale(s, &json!({ "scale": v }))) {
Some(Ok(r)) => {
if let Some(m) = r.get("message").and_then(Value::as_str) {
s.echo(m.to_string());
}
Ok(Step::Done)
}
_ => {
s.echo("Requires a positive number.");
Ok(Step::Continue)
}
}
}
}
fn run_units(s: &mut Session, p: &Value) -> Result<Value> {
let d = s.doc_mut()?;
for (k, lo, hi) in [("lunits", 1, 5), ("luprec", 0, 8), ("aunits", 0, 4), ("auprec", 0, 8), ("insunits", 0, 24)] {
@@ -824,6 +869,26 @@ fn run_dimstyle_delete(s: &mut Session, p: &Value) -> Result<Value> {
Ok(dimstyles_json(d))
}
/// A dimension's text height, arrow size, overall scale (DIMSCALE) and precision as Properties
/// sets them: per-dimension overrides, as in AutoCAD's Properties palette.
fn dim_overrides(p: &Value) -> Result<serde_json::Map<String, Value>> {
let mut o = serde_json::Map::new();
for (key, field, zero_ok) in [("textHeight", "textHeight", false), ("arrowSize", "arrowSize", true), ("dimScale", "scale", false)] {
if let Some(v) = p.get(key).filter(|v| !v.is_null()) {
let x = v
.as_f64()
.filter(|x| x.is_finite() && (*x > 0.0 || (zero_ok && *x == 0.0)))
.ok_or_else(|| bad("properties.set", format!("`{key}` must be a positive number")))?;
o.insert(field.into(), json!(x));
}
}
if let Some(v) = p.get("precision").filter(|v| !v.is_null()) {
let n = v.as_u64().filter(|n| *n <= 8).ok_or_else(|| bad("properties.set", "`precision` must be 0 to 8 decimal places"))?;
o.insert("decimals".into(), json!(n));
}
Ok(o)
}
/// Per-dimension style overrides (DIMOVERRIDE): merge fields, or `clear` them.
pub(super) fn dim_override(s: &mut Session, p: &Value) -> Result<Value> {
let hs = targets(s, p)?;
+36 -9
View File
@@ -224,16 +224,27 @@ fn separator(path: &str, text: &str) -> char {
if first.contains(';') && !first.contains(',') { ';' } else { ',' }
}
/// The file to read for a data link: a path the user just `chosen` as given, a path stored in the
/// drawing at `host` only through [`crate::doc_path::resolve`].
fn link_file(id: &str, path: &str, host: Option<&str>, chosen: bool) -> Result<std::path::PathBuf> {
if chosen {
return Ok(std::path::PathBuf::from(path));
}
crate::doc_path::resolve(path, host.map(std::path::Path::new)).map_err(|r| bad(id, format!("{path}: not opened, {r}")))
}
/// Read a linked file through the host (natively the file system; not on the web).
fn read_link(id: &str, path: &str) -> Result<String> {
fn read_link(id: &str, file: &std::path::Path) -> Result<String> {
let path = file.display();
#[cfg(not(target_arch = "wasm32"))]
{
let meta = std::fs::metadata(path).map_err(|e| bad(id, format!("{path}: {e}")))?;
let meta = std::fs::metadata(file).map_err(|e| bad(id, format!("{path}: {e}")))?;
if meta.len() > MAX_LINK_BYTES {
return Err(bad(id, format!("{path}: larger than {} MB", MAX_LINK_BYTES >> 20)));
}
let bytes = std::fs::read(path).map_err(|e| bad(id, format!("{path}: {e}")))?;
Ok(String::from_utf8_lossy(&bytes).into_owned())
let bytes = std::fs::read(file).map_err(|e| bad(id, format!("{path}: {e}")))?;
let text = String::from_utf8_lossy(&bytes);
Ok(text.strip_prefix('\u{feff}').unwrap_or(&text).to_owned())
}
#[cfg(target_arch = "wasm32")]
{
@@ -278,14 +289,16 @@ fn fill(t: &mut Table, start: usize, data: &[Vec<String>]) {
}
}
/// Re-read the linked file of table `h`.
fn update(s: &mut Session, h: Handle) -> Result<(usize, usize)> {
/// Re-read the linked file of table `h`; `chosen` says its path was just given by the user.
fn update(s: &mut Session, h: Handle, chosen: bool) -> Result<(usize, usize)> {
let id = "datalinkupdate";
let link = match s.doc()?.entity(h).map(|e| &e.kind) {
Some(EntityKind::Table(t)) => t.data_link.clone().ok_or_else(|| bad(id, "the table has no data link"))?,
_ => return Err(bad(id, "object is not a table")),
};
let text = read_link(id, &link.path)?;
let host = s.state()?.path.clone();
let file = link_file(id, &link.path, host.as_deref(), chosen)?;
let text = read_link(id, &file)?;
let data = parse_csv(&text, separator(&link.path, &text));
let mut size = (0, 0);
edit(s, &json!({ "handle": h.hex() }), id, |t| {
@@ -313,7 +326,7 @@ fn run_datalink(s: &mut Session, p: &Value) -> Result<Value> {
Ok(())
})?;
let h = r.get("handle").and_then(Value::as_str).and_then(Handle::parse_hex).ok_or_else(|| bad(id, "no table"))?;
let (rows, cols) = update(s, h)?;
let (rows, cols) = update(s, h, true)?;
Ok(json!({ "handle": h.hex(), "rows": rows, "cols": cols }))
}
@@ -340,7 +353,7 @@ fn run_update(s: &mut Session, p: &Value) -> Result<Value> {
let mut updated = 0;
let mut errors = Vec::new();
for h in hs {
match update(s, h) {
match update(s, h, false) {
Ok(_) => updated += 1,
Err(e) => errors.push(e.to_string()),
}
@@ -369,4 +382,18 @@ mod tests {
assert_eq!(separator("a.tsv", "x,y"), '\t');
assert_eq!(separator("a.csv", "x;y"), ';');
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn link_strips_utf8_bom() {
let path = std::env::temp_dir().join(format!("cadcraft_bom_link_{}.csv", std::process::id()));
let mut bytes = vec![0xEF, 0xBB, 0xBF];
bytes.extend_from_slice(b"\"Item, name\",Qty\r\nBolt,4\r\n");
std::fs::write(&path, bytes).expect("write temp csv");
let text = read_link("datalink", &path);
let _ = std::fs::remove_file(&path);
let text = text.expect("read link");
let rows = parse_csv(&text, separator("bom.csv", &text));
assert_eq!(rows, vec![vec!["Item, name", "Qty"], vec!["Bolt", "4"]]);
}
}
+50 -10
View File
@@ -646,6 +646,20 @@ pub enum Shift {
DeleteCol(usize),
}
/// The `lo..=hi` extent of a range after inserting a row/column at `at`.
fn shift_span(lo: usize, hi: usize, at: usize) -> (usize, usize) {
(if lo >= at { lo + 1 } else { lo }, if hi >= at { hi + 1 } else { hi })
}
/// The `lo..=hi` extent of a range after deleting row/column `at` (the start stays, the next
/// row moves up into it; the end moves up); `None` when the range was only that row/column.
fn shift_span_delete(lo: usize, hi: usize, at: usize) -> Option<(usize, usize)> {
if lo == at && hi == at {
return None;
}
Some((if lo > at { lo - 1 } else { lo }, if hi >= at { hi.checked_sub(1)? } else { hi }))
}
/// `formula` with its references moved for a row/column insertion or deletion; references to a
/// deleted row/column become `#REF` (range corners move inward instead).
pub fn shift_refs(formula: &str, shift: Shift) -> String {
@@ -655,24 +669,42 @@ pub fn shift_refs(formula: &str, shift: Shift) -> String {
let body = src.get(body_start..).unwrap_or("");
let mut out = String::from(src.get(..body_start).unwrap_or(""));
let mut last = 0;
let mut skip_to = 0;
for (k, (tok, span)) in toks.iter().enumerate() {
if k < skip_to {
continue;
}
let Tok::Cell(r, c) = *tok else { continue };
let range_start = toks.get(k + 1).is_some_and(|t| t.0 == Tok::Op(':'));
let range_end = k > 0 && toks.get(k - 1).is_some_and(|t| t.0 == Tok::Op(':'));
// `A5:A2` is the same range as `A2:A5`: move the normalised corners as one unit.
if let (Some((Tok::Op(':'), _)), Some((Tok::Cell(r2, c2), end))) = (toks.get(k + 1), toks.get(k + 2)) {
skip_to = k + 3;
let (rlo, rhi, clo, chi) = (r.min(*r2), r.max(*r2), c.min(*c2), c.max(*c2));
let moved = match shift {
Shift::InsertRow(at) => Some((shift_span(rlo, rhi, at), (clo, chi))),
Shift::DeleteRow(at) => shift_span_delete(rlo, rhi, at).map(|rr| (rr, (clo, chi))),
Shift::InsertCol(at) => Some(((rlo, rhi), shift_span(clo, chi, at))),
Shift::DeleteCol(at) => shift_span_delete(clo, chi, at).map(|cc| ((rlo, rhi), cc)),
};
out.push_str(body.get(last..span.start).unwrap_or(""));
match moved {
Some(((r0, r1), (c0, c1))) if (r0, r1, c0, c1) == (rlo, rhi, clo, chi) => {
out.push_str(body.get(span.start..end.end).unwrap_or(""));
}
Some(((r0, r1), (c0, c1))) => {
out.push_str(&format!("{}:{}", cell_name(r0, c0), cell_name(r1, c1)));
}
None => out.push_str("#REF"),
}
last = end.end;
continue;
}
let moved = |v: usize, at: usize, insert: bool| -> Option<usize> {
if insert {
Some(if v >= at { v + 1 } else { v })
} else if v > at {
Some(v - 1)
} else if v == at {
// A range's start stays (the next row moves up into it); its end moves up.
if range_start {
Some(v)
} else if range_end {
v.checked_sub(1)
} else {
None
}
None
} else {
Some(v)
}
@@ -728,5 +760,13 @@ mod tests {
assert_eq!(shift_refs("=Sum(A2:A5)", Shift::DeleteRow(4)), "=Sum(A2:A4)");
assert_eq!(shift_refs("=B2*2", Shift::DeleteCol(1)), "=#REF*2");
assert_eq!(shift_refs("=C1", Shift::InsertCol(0)), "=D1");
assert_eq!(shift_refs("=Sum(A3:A3)", Shift::DeleteRow(2)), "=Sum(#REF)");
assert_eq!(shift_refs("=Sum(A3:C3)", Shift::DeleteCol(1)), "=Sum(A3:B3)");
assert_eq!(shift_refs("=Sum(B3:B3)", Shift::DeleteCol(1)), "=Sum(#REF)");
assert_eq!(shift_refs("=Sum(A5:A2)", Shift::DeleteRow(1)), "=Sum(A2:A4)");
assert_eq!(shift_refs("=Sum(A5:A2)", Shift::DeleteRow(4)), "=Sum(A2:A4)");
assert_eq!(shift_refs("=Sum(C1:A1)", Shift::DeleteCol(0)), "=Sum(A1:B1)");
assert_eq!(shift_refs("=Sum(A5:A2)", Shift::InsertRow(2)), "=Sum(A2:A6)");
assert_eq!(shift_refs("=Sum(A5:A2)", Shift::InsertRow(9)), "=Sum(A5:A2)");
}
}
+338
View File
@@ -0,0 +1,338 @@
//! TXT2MTXT (Express Tools): single-line TEXT, and MTEXT when combining, turned into MTEXT.
//!
//! Combined, the texts become one MTEXT in place of the first of them (its handle, layer and
//! other properties kept), one line each, from the top down or in selection order, at the top
//! left of their extents. Otherwise each TEXT becomes its own MTEXT, attached at the point it
//! is justified by.
use cadcraft_doc::{EntityKind, HAlign, Handle, MText, Text, VAlign, entity_bounds, mtext_literal};
use cadcraft_geom::Bounds2;
use serde_json::{Value, json};
use super::curves::KW;
use super::helpers::v3;
use super::machines::{SelOutcome, SelectPhase};
use super::{CommandSpec, bad, bool_or, str_param, targets};
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("txt2mtxt", "Convert Text to Mtext", run_txt2mtxt)
.menu(&["Tools", "Express Tools", "Convert Text to Mtext"])
.params(
"{handles? (default the selection; TEXT, and MTEXT when combining), combine? (default true: one MTEXT; false: one per TEXT), \
sort?: \"topdown\" (default) | \"selection\", wrap? (default false: one line per text)} → {handles}",
)
.interactive(|s| Ok(Box::new(Txt2MtxtM::new(s)))),
]
}
/// TXT2MTXT's settings: one MTEXT or one per TEXT, the order of the lines and word wrap.
#[derive(Clone, Debug, PartialEq)]
pub struct Txt2MtxtOptions {
pub combine: bool,
pub top_down: bool,
pub wrap: bool,
}
impl Default for Txt2MtxtOptions {
fn default() -> Self {
Txt2MtxtOptions { combine: true, top_down: true, wrap: false }
}
}
/// Converts the TEXT (and, combining, MTEXT) objects among `hs`; objects on locked layers are
/// left alone. Returns the MTEXT objects made and how many texts were on a locked layer.
fn convert(s: &mut Session, hs: &[Handle], o: &Txt2MtxtOptions) -> Result<(Vec<Handle>, usize)> {
let d = s.doc()?;
let mut locked = 0;
let mut items: Vec<(Handle, EntityKind, Bounds2)> = Vec::new();
let mut seen = std::collections::HashSet::new();
for h in hs {
let Some(e) = d.entity(*h) else { continue };
let wanted = match &e.kind {
EntityKind::Text(_) => true,
EntityKind::MText(_) => o.combine,
_ => false,
};
if !wanted || !seen.insert(*h) {
continue;
}
if d.layer(&e.common.layer).is_some_and(|l| l.locked) {
locked += 1;
continue;
}
items.push((*h, e.kind.clone(), entity_bounds(d, e, 0)));
}
if !o.combine {
let mut made = Vec::new();
for (h, k, _) in items {
if let EntityKind::Text(t) = k {
s.doc_mut()?.modify_entity(h, |e| e.kind = EntityKind::MText(single(&t)))?;
made.push(h);
}
}
return Ok((made, locked));
}
if o.top_down {
// Highest first; a line shared by several texts reads left to right.
items.sort_by(|a, b| {
let (pa, pb) = (insertion(&a.1), insertion(&b.1));
pb.y.total_cmp(&pa.y).then(pa.x.total_cmp(&pb.x))
});
}
// A lone MTEXT is already what TXT2MTXT would make of it.
if let [(_, EntityKind::MText(_), _)] = &items[..] {
return Ok((Vec::new(), locked));
}
let Some((first, k0, _)) = items.first() else { return Ok((Vec::new(), locked)) };
let (height, style, rotation) = match k0 {
EntityKind::Text(t) => (t.height, t.style.clone(), t.rotation),
EntityKind::MText(m) => (m.height, m.style.clone(), m.rotation),
_ => return Ok((Vec::new(), locked)),
};
let ext = items.iter().fold(Bounds2::EMPTY, |b, (.., x)| b.union(x));
let corner = if ext.is_empty() { insertion(k0) } else { cadcraft_geom::Vec2::new(ext.min.x, ext.max.y) };
let sep = if o.wrap { " " } else { "\\P" };
let contents = items.iter().map(|(_, k, _)| contents(k, height)).collect::<Vec<_>>().join(sep);
let width = if o.wrap { items.iter().map(|(_, k, _)| line_width(k)).fold(0.0, f64::max) } else { 0.0 };
let m = MText {
insert: v3(corner),
height,
width,
attach: 1,
rotation,
style,
contents,
line_spacing: 1.0,
line_spacing_exact: false,
columns: None,
};
let first = *first;
let rest: Vec<Handle> = items.iter().skip(1).map(|(h, ..)| *h).collect();
let d = s.doc_mut()?;
d.modify_entity(first, |e| e.kind = EntityKind::MText(m))?;
for h in rest {
d.remove_entity(h);
}
Ok((vec![first], locked))
}
/// The point a TEXT or MTEXT is sorted by: its insertion point.
fn insertion(k: &EntityKind) -> cadcraft_geom::Vec2 {
match k {
EntityKind::Text(t) => t.insert.xy(),
EntityKind::MText(m) => m.insert.xy(),
_ => cadcraft_geom::Vec2::ZERO,
}
}
/// A text's lines as MTEXT contents of base height `base`: the TEXT's characters read literally
/// (its width factor and oblique angle as inline codes), an MTEXT's contents as they are; either
/// keeps its own height with an absolute `\H` code when that differs from `base`.
fn contents(k: &EntityKind, base: f64) -> String {
match k {
EntityKind::Text(t) => text_contents(t, base),
EntityKind::MText(m) if differs(m.height, base) => format!("{{\\H{};{}}}", m.height, m.contents),
EntityKind::MText(m) => m.contents.clone(),
_ => String::new(),
}
}
/// Whether a line of height `h` needs its own height code in an MTEXT of height `base`.
fn differs(h: f64, base: f64) -> bool {
h.is_finite() && h > 0.0 && (h - base).abs() > base.abs() * 1e-9
}
fn text_contents(t: &Text, base: f64) -> String {
let mut codes = String::new();
if differs(t.height, base) {
codes.push_str(&format!("\\H{};", t.height));
}
if t.width_factor.is_finite() && t.width_factor > 0.0 && (t.width_factor - 1.0).abs() > 1e-9 {
codes.push_str(&format!("\\W{};", t.width_factor));
}
if t.oblique.is_finite() && t.oblique.abs() > 1e-9 {
codes.push_str(&format!("\\Q{};", t.oblique.to_degrees()));
}
let lit = mtext_literal(&t.value);
if codes.is_empty() { lit } else { format!("{{{codes}{lit}}}") }
}
/// How wide a text's longest line is in the drafting font: word wrap's MTEXT width.
fn line_width(k: &EntityKind) -> f64 {
let w = match k {
EntityKind::Text(t) => cadcraft_fonts::line_width(&t.value, t.height, t.width_factor),
EntityKind::MText(m) if m.width > 0.0 => m.width,
EntityKind::MText(m) => {
cadcraft_fonts::plain_mtext(&m.contents).lines().map(|l| cadcraft_fonts::line_width(l, m.height, 1.0)).fold(0.0, f64::max)
}
_ => 0.0,
};
if w.is_finite() { w } else { 0.0 }
}
/// One TEXT as an MTEXT of its own, attached where the TEXT is justified (a baseline as the
/// bottom).
fn single(t: &Text) -> MText {
let col = match t.halign {
HAlign::Left | HAlign::Aligned | HAlign::Fit => 0,
HAlign::Center | HAlign::Middle => 1,
HAlign::Right => 2,
};
let row = match (t.halign, t.valign) {
(HAlign::Middle, _) | (_, VAlign::Middle) => 1,
(_, VAlign::Top) => 0,
_ => 2,
};
MText {
insert: v3(t.justify_point()),
height: t.height,
width: 0.0,
attach: row * 3 + col + 1,
rotation: t.rotation,
style: t.style.clone(),
contents: text_contents(t, t.height),
line_spacing: 1.0,
line_spacing_exact: false,
columns: None,
}
}
fn locked_note(n: usize) -> Option<String> {
(n > 0).then(|| format!("{n} object(s) on a locked layer."))
}
fn run_txt2mtxt(s: &mut Session, p: &Value) -> Result<Value> {
let hs = targets(s, p)?;
let top_down = match str_param(p, "sort") {
None | Some("topdown") => true,
Some("selection") => false,
Some(v) => return Err(bad("txt2mtxt", format!("`sort` must be \"topdown\" or \"selection\", got \"{v}\""))),
};
let o = Txt2MtxtOptions { combine: bool_or(p, "combine", true), top_down, wrap: bool_or(p, "wrap", false) };
let (made, locked) = convert(s, &hs, &o)?;
if made.is_empty() {
let why = locked_note(locked).unwrap_or_else(|| "No text objects selected.".into());
return Err(EngineError::Other(why));
}
s.set_selection(Vec::new());
let mut r = json!({ "handles": made.iter().map(|h| h.hex()).collect::<Vec<_>>() });
if let Some(m) = locked_note(locked) {
r["message"] = json!(m);
}
Ok(r)
}
#[derive(Clone, Copy, PartialEq)]
enum Ask {
Select,
Combine,
Sort,
Wrap,
}
struct Txt2MtxtM {
opts: Txt2MtxtOptions,
ask: Ask,
sel: SelectPhase,
}
impl Txt2MtxtM {
fn new(s: &Session) -> Self {
Txt2MtxtM { opts: s.last_used.txt2mtxt.clone(), ask: Ask::Select, sel: SelectPhase::default() }
}
fn finish(&self, s: &mut Session, hs: &[Handle]) -> Result<Step> {
let (made, locked) = convert(s, hs, &self.opts)?;
if let Some(m) = locked_note(locked) {
s.echo(m);
}
if made.is_empty() {
s.echo("No text objects selected.");
}
s.set_selection(Vec::new());
Ok(Step::Done)
}
}
fn yes_no(b: bool) -> &'static str {
if b { "Yes" } else { "No" }
}
impl Interactive for Txt2MtxtM {
fn name(&self) -> &'static str {
"TXT2MTXT"
}
fn begin(&mut self, s: &mut Session) -> Result<Step> {
let o = &self.opts;
s.echo(format!("Current settings: Combine= {}, Sort top-down= {}, Word-wrap= {}", yes_no(o.combine), yes_no(o.top_down), yes_no(o.wrap)));
self.sel = SelectPhase::begin(s);
if self.sel.done {
let hs = self.sel.picked.clone();
return self.finish(s, &hs);
}
Ok(Step::Continue)
}
fn prompt(&self, _s: &Session) -> Prompt {
match self.ask {
Ask::Select if self.sel.removing => self.sel.prompt(),
Ask::Select => Prompt::new("Select objects", Accept::SELECT).kw(&["SEttings"]),
Ask::Combine => Prompt::new("Combine text into a single MTEXT?", KW).kw(&["Yes", "No"]).default(yes_no(self.opts.combine)),
Ask::Sort => Prompt::new("Sort text top-down?", KW).kw(&["Yes", "No"]).default(yes_no(self.opts.top_down)),
Ask::Wrap => Prompt::new("Word-wrap text?", KW).kw(&["Yes", "No"]).default(yes_no(self.opts.wrap)),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
let k = match &i {
Input::Keyword(k) => Some(k.as_str()),
_ => None,
};
match self.ask {
Ask::Select => {
if k == Some("SEttings") {
self.ask = Ask::Combine;
return Ok(Step::Continue);
}
return match self.sel.feed(s, &i)? {
SelOutcome::More => Ok(Step::Continue),
SelOutcome::Empty => Ok(Step::Done),
SelOutcome::Done(hs) => self.finish(s, &hs),
};
}
Ask::Combine => {
match k {
Some("Yes") => self.opts.combine = true,
Some("No") => self.opts.combine = false,
_ if i == Input::Enter => {}
_ => return Ok(Step::Continue),
}
// Sorting and word wrap only apply to text combined into one MTEXT.
self.ask = if self.opts.combine { Ask::Sort } else { Ask::Select };
}
Ask::Sort => {
match k {
Some("Yes") => self.opts.top_down = true,
Some("No") => self.opts.top_down = false,
_ if i == Input::Enter => {}
_ => return Ok(Step::Continue),
}
self.ask = Ask::Wrap;
}
Ask::Wrap => {
match k {
Some("Yes") => self.opts.wrap = true,
Some("No") => self.opts.wrap = false,
_ if i == Input::Enter => {}
_ => return Ok(Step::Continue),
}
self.ask = Ask::Select;
}
}
s.last_used.txt2mtxt = self.opts.clone();
Ok(Step::Continue)
}
}
#[cfg(test)]
#[path = "txt2mtxt_tests.rs"]
mod tests;
+166
View File
@@ -0,0 +1,166 @@
use cadcraft_doc::{EntityKind, MText};
use serde_json::json;
use crate::Session;
/// Three TEXT lines made out of order: "B" lowest, "A" highest. The highest is on layer TOP with
/// height 2.5, the others on layer 0 with height 1, so the first text's properties are told apart.
fn three_lines(s: &mut Session) -> Vec<String> {
s.execute("layer.new", &json!({"name": "TOP"})).unwrap();
let h = |r: serde_json::Value| r["handle"].as_str().unwrap().to_string();
let b = h(s.execute("text", &json!({"at": [0, 0], "text": "B", "height": 1})).unwrap());
s.execute("layer.current", &json!({"name": "TOP"})).unwrap();
let a = h(s.execute("text", &json!({"at": [2, 10], "text": "A", "height": 2.5})).unwrap());
s.execute("layer.current", &json!({"name": "0"})).unwrap();
let c = h(s.execute("text", &json!({"at": [1, 5], "text": "C{1}", "height": 1})).unwrap());
vec![b, a, c]
}
fn handles(hs: &[String]) -> Vec<cadcraft_doc::Handle> {
hs.iter().map(|h| cadcraft_doc::Handle::parse_hex(h).unwrap()).collect()
}
fn mtexts(s: &Session) -> Vec<(String, MText)> {
s.doc()
.unwrap()
.model
.iter()
.filter_map(|e| match &e.kind {
EntityKind::MText(m) => Some((e.common.layer.clone(), m.clone())),
_ => None,
})
.collect()
}
#[test]
fn combines_texts_top_down_into_one_mtext_with_the_first_ones_properties() {
let mut s = Session::new();
let hs = three_lines(&mut s);
let r = s.execute("txt2mtxt", &json!({"handles": hs})).unwrap();
let doc = s.doc().unwrap();
assert_eq!(doc.model.len(), 1, "the three texts became one object");
let ms = mtexts(&s);
let [(layer, m)] = &ms[..] else { panic!("one MTEXT: {ms:?}") };
// Highest first whatever the selection order; braces are literal characters; lines of
// another height than the first keep theirs.
assert_eq!(m.contents, "A\\P{\\H1;C\\{1\\}}\\P{\\H1;B}");
assert_eq!((layer.as_str(), m.height, m.attach, m.width), ("TOP", 2.5, 1, 0.0));
// Top left of the texts: the leftmost start point, the top of the highest text.
assert_eq!((m.insert.x, m.insert.y), (0.0, 12.5));
assert_eq!(r["handles"], json!([hs[1]]), "the highest text's object holds the MTEXT");
s.undo().unwrap();
let kinds: Vec<_> = s.doc().unwrap().model.iter().map(|e| e.kind.type_name()).collect();
assert_eq!(kinds, ["Text", "Text", "Text"], "one undo step brings the three texts back");
}
#[test]
fn selection_order_wrap_and_one_mtext_per_text() {
let mut s = Session::new();
let hs = three_lines(&mut s);
s.execute("txt2mtxt", &json!({"handles": hs, "sort": "selection", "wrap": true})).unwrap();
let ms = mtexts(&s);
let [(layer, m)] = &ms[..] else { panic!("one MTEXT: {ms:?}") };
assert_eq!(m.contents, "B {\\H2.5;A} C\\{1\\}", "selection order, one wrapped paragraph");
assert_eq!((layer.as_str(), m.height), ("0", 1.0), "the first selected text's properties");
assert!(m.width > 0.0, "word wrap gives the MTEXT a width");
s.undo().unwrap();
s.execute("txt2mtxt", &json!({"handles": hs, "combine": false})).unwrap();
let ms = mtexts(&s);
assert_eq!(ms.len(), 3, "one MTEXT per TEXT: {ms:?}");
let a = ms.iter().find(|(_, m)| m.contents == "A").unwrap();
// A baseline-left TEXT is attached bottom left at its start point, keeping layer and height.
assert_eq!((a.0.as_str(), a.1.height, a.1.attach, a.1.insert.x, a.1.insert.y), ("TOP", 2.5, 7, 2.0, 10.0));
assert_eq!(s.doc().unwrap().model.len(), 3);
}
#[test]
fn command_line_settings_and_locked_layers() {
let mut s = Session::new();
let hs = three_lines(&mut s);
let steps = s.state().unwrap().undo.len();
s.start("txt2mtxt").unwrap();
assert!(s.prompt_text().contains("Select objects or [SEttings]"), "{}", s.prompt_text());
s.cmdline("se").unwrap();
assert!(s.prompt_text().contains("Combine text into a single MTEXT? [Yes/No] <Yes>"), "{}", s.prompt_text());
s.cmdline("n").unwrap();
assert!(s.prompt_text().contains("Select objects"), "sorting and wrapping are skipped: {}", s.prompt_text());
s.input(crate::Input::Pick(handles(&hs))).unwrap();
s.cmdline("").unwrap();
assert!(s.running.is_none(), "{}", s.prompt_text());
assert_eq!(mtexts(&s).len(), 3);
assert_eq!(s.state().unwrap().undo.len(), steps + 1, "one undo step");
s.undo().unwrap();
// The setting is remembered; a text on a locked layer is left alone.
s.execute("layer.set", &json!({"name": "TOP", "locked": true})).unwrap();
s.start("txt2mtxt").unwrap();
s.cmdline("se").unwrap();
assert!(s.prompt_text().contains("<No>"), "{}", s.prompt_text());
s.cmdline("y").unwrap();
assert!(s.prompt_text().contains("Sort text top-down? [Yes/No] <Yes>"), "{}", s.prompt_text());
s.cmdline("").unwrap();
assert!(s.prompt_text().contains("Word-wrap text? [Yes/No] <No>"), "{}", s.prompt_text());
s.cmdline("").unwrap();
s.input(crate::Input::Pick(handles(&hs))).unwrap();
s.cmdline("").unwrap();
let ms = mtexts(&s);
let [(_, m)] = &ms[..] else { panic!("one MTEXT: {ms:?}") };
assert_eq!(m.contents, "C\\{1\\}\\PB");
assert_eq!(s.doc().unwrap().model.len(), 2, "the locked text stays a TEXT");
let n = s.doc().unwrap().model.len();
for p in [json!({"handles": []}), json!({"handles": [hs[1]]}), json!({"handles": hs, "sort": "sideways"})] {
assert!(s.execute("txt2mtxt", &p).is_err(), "{p}");
}
assert_eq!(s.doc().unwrap().model.len(), n);
}
/// The vertical extent of each group of strokes that overlap vertically: one per line of
/// capitals, top line first.
fn line_extents(strokes: &[Vec<cadcraft_geom::Vec2>]) -> Vec<f64> {
let mut spans: Vec<(f64, f64)> = strokes
.iter()
.filter(|st| !st.is_empty())
.map(|st| st.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), p| (lo.min(p.y), hi.max(p.y))))
.collect();
spans.sort_by(|a, b| b.1.total_cmp(&a.1));
let mut lines: Vec<(f64, f64)> = Vec::new();
for (lo, hi) in spans {
match lines.last_mut() {
Some(l) if hi >= l.0 => l.0 = l.0.min(lo),
_ => lines.push((lo, hi)),
}
}
lines.iter().map(|(lo, hi)| hi - lo).collect()
}
#[test]
fn combined_lines_keep_their_own_heights() {
let mut s = Session::new();
for (y, t, h) in [(0, "LOW", 3.5), (20, "TOP", 5.0), (10, "MID", 3.5)] {
s.execute("text", &json!({"at": [0, y], "text": t, "height": h})).unwrap();
}
s.execute("txt2mtxt", &json!({"handles": s.doc().unwrap().model.iter().map(|e| e.handle.hex()).collect::<Vec<_>>()})).unwrap();
let ms = mtexts(&s);
let [(_, m)] = &ms[..] else { panic!("one MTEXT: {ms:?}") };
assert_eq!((m.height, m.contents.as_str()), (5.0, "TOP\\P{\\H3.5;MID}\\P{\\H3.5;LOW}"));
// Drawn, each line is as tall as the TEXT it came from.
let drawn = |c: &str, h: f64| cadcraft_fonts::layout_mtext(c, m.insert.xy(), h, 0.0, 1, 0.0, 1.0).strokes;
let got = line_extents(&drawn(&m.contents, m.height));
let want: Vec<f64> = [("TOP", 5.0), ("MID", 3.5), ("LOW", 3.5)].iter().flat_map(|(t, h)| line_extents(&drawn(t, *h))).collect();
assert_eq!(got.len(), 3, "{got:?}");
for (g, w) in got.iter().zip(&want) {
assert!((g - w).abs() < 1e-6, "line heights {got:?}, TEXT heights {want:?}");
}
assert!(want[1] < want[0] * 0.8, "the fixture's heights differ: {want:?}");
s.undo().unwrap();
// One MTEXT per TEXT: each its own height.
let hs: Vec<String> = s.doc().unwrap().model.iter().map(|e| e.handle.hex()).collect();
s.execute("txt2mtxt", &json!({"handles": hs, "combine": false})).unwrap();
let mut got: Vec<(String, f64)> = mtexts(&s).into_iter().map(|(_, m)| (m.contents, m.height)).collect();
got.sort_by(|a, b| a.0.cmp(&b.0));
assert_eq!(got, [("LOW".to_string(), 3.5), ("MID".to_string(), 3.5), ("TOP".to_string(), 5.0)]);
}
Loaded 100 of 340 files, more files were not shown because too many files have changed in this diff. Show more