60 Commits
Author SHA1 Message Date
Christopher OverbeckandClaude Opus 5.5 278e15405d style(ribbon): tint, rather than fill, the tab holding the running command
A full-strength fill on a large tab header read louder than the selected
tab's outline. A faint wash of the same blue still points at the tab
without looking selected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 15:41:39 -05:00
Christopher OverbeckandClaude Opus 5.5 1093900a49 feat(ribbon): highlight the running command's button
A command started from the command line, an alias, a shortcut or Repeat
now lights the same ribbon button a click on it would, so users learn
where the tools they type live. A button inside a dropdown lights the
dropdown, a slide-out tool lights its group title, a tool hidden in a
collapsed panel lights the panel's opener, and a button on another tab
lights that tab's header. Always on, in the existing active-button blue.

Buttons are matched by the command they dispatch, so ids that differ
from their command still resolve; the tab on show wins when a command
appears twice, and a clicked button keeps its highlight. Transparent
commands leave the running command lit. The #355 rule that a highlight
lasts only while something runs moves from the ribbon click handler into
dispatch_command, so typed commands are cleared the same way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 15:38:47 -05:00
Christopher OverbeckandClaude Opus 5.5 51abbd897c feat(offset): open on the live preview for pre-selected objects; signed typed distance
With objects already selected, OFFSET opens at the side step with the
preview following the cursor instead of asking for a distance first
(#422). At the side step a typed distance places the offset at once on
the cursor's side, and a negative one on the other side; Enter at the
start repeats the last distance (#418). Erase and Layer, which lived on
the skipped distance step, are offered there too. With nothing selected
the distance-first flow is unchanged.

The opposite side is found by mirroring the cursor through its nearest
point on the outline, capped at half the offset distance so it cannot
cross a narrow gap back to the cursor's own side. Two new prompts are
added to the catalog and every locale.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 15:35:54 -05:00
Christopher OverbeckandClaude Opus 5 37059cb0d6 Repair knot-less splines on every load path
load_file, used by headless export, xrefs and block import, finalizes a
drawing without purging, so the spline degree repair never ran there and
`--export drawing.dxf out.dwg` still hit the knot underflow. Run the repair
when any load is finalized.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 10:33:18 -05:00
Christopher OverbeckandClaude Opus 5 1ac9309f85 Guard constraint solving and DWG export against degenerate geometry
Retaining a line's length added a P2PDistance constraint, whose gradient
divides by the current distance. For a zero-length line the solver got a NaN
Jacobian and its SVD fallback never returned, freezing the editor (e.g.
ECONSTRAINT on a line drawn by clicking one point twice). Hold coincident
endpoints together with per-axis differences instead.

Control-point splines read without knots are given a degree their control
points can carry (at most n - 1) and a clamped knot vector when a drawing
loads. Generating knots for fewer points than the degree underflowed during
DWG write: a panic in debug builds, an unbounded allocation in release, and
reachable from autosave. A lone control point with no knots or fit points is
purged as corrupt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 10:33:18 -05:00
Christopher OverbeckandClaude Opus 5 a3d65e2fc8 Bound typed counts and values that could crash the editor
- ARRAYRECT, ARRAYPOLAR, ARRAYPATH, ARRAY3D and COPY Array reject item
  counts above 100,000 (checked as a product of every dimension) and keep
  the prompt open. Unbounded counts allocated one transform per item and
  aborted on allocation failure. Stored defaults are clamped as well.
- EXPLODE leaves a MINSERT above the same limit intact instead of
  materializing every cell.
- Typed lengths and angles reject non-finite values and magnitudes above
  1e15; SCALE rejects a non-finite factor.
- Zero-radius FILLET compares candidate intersections with total_cmp and
  rejects non-finite results; partial_cmp().unwrap() panicked on NaN.
- SOLID falls back to a minimal kernel tolerance when its corners' extent
  overflows, since Tolerance::new panics on infinity.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 10:33:18 -05:00
Christopher OverbeckandClaude Opus 5 38aa2ee34d Avoid slicing text inside multi-byte characters
Right-to-left line reordering walked each atom's byte range and sliced the
line at every byte, panicking when a neutral multi-byte character such as a
guillemet preceded the first strong character. Walk characters instead.

FIND/FINDALL located the REPLACE keyword in a Unicode-uppercased copy and
sliced the original with that offset; uppercasing can change byte length
(dotless i, ligatures). Uppercase ASCII only, which keeps offsets aligned.
FILLET and CHAMFER inline R/D shorthands now test the original text before
slicing past the first character.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 10:33:18 -05:00
Christopher Overbeck 77b793b250 docs(dimension): screenshots for arrow fit, outside text, and radial leader rules 2026-09-17 09:45:13 -05:00
Christopher Overbeck 5b06b6f3fb docs(dimension): before and after screenshots for regenerated dimension layout 2026-09-17 09:32:54 -05:00
Christopher Overbeck 0fdbc80e16 fix(dimension): measured cell width, DIMTMOVE 1 hook start, diameter sign, #1323 offset kept
- The per-character width estimate is 0.66 text heights, measured by
  rendering the text of 106 saved dimension pictures with the bundled
  fonts; the old 0.60 left every fit and hook estimate ten percent narrow.
  With it, the "text plus DIMGAP must clear the arrowheads" rule reproduces
  AutoCAD's arrow placement for 1047 of 1078 pictured linear dimensions
  whose text sits between the extension lines.
- DIMTMOVE 1 stores where the leader's hook starts, not where it ends: on 39
  such radial dimensions the stored point sits DIMASZ plus DIMGAP before the
  text. The hook now runs from the stored point one arrow toward the text.
- The diameter prefix is U+2205, matching the %%c expansion and the pictures.
- A diameter whose text the user dragged keeps the DIMTAD lift from #1323;
  the four AutoCAD-authored diameters with DIMTAD above in the sample all
  store the lifted point directly, so this applies only to dragged text.
2026-09-17 09:06:54 -05:00
Christopher Overbeck 8463c830f7 fix(text): resolve %%c to U+2205, the diameter sign AutoCAD writes
Saved dimension pictures across 1311 diameter dimensions carry U+2205 (806) or a
literal %%c (168); U+2300 never appears. The bundled romans and simplex fonts
have U+2205 and lack Latin O-stroke, so one character now serves the %%c
expansion and, in the dimension formatter, the diameter prefix.
2026-09-17 09:06:54 -05:00
Christopher Overbeck 9e18f855cc fix(dimension): size a limits stack from the MText stack layout
The fit and break estimates used two halves at STACK_HALF_SCALE; the stack
is laid out from the lower half's baseline to the upper half's cap, which is
STACK_RAISE + STACK_HALF_SCALE of the run height. The taller estimate flips
arrows outside for a few more stacked texts that AutoCAD drew that way.
2026-09-17 08:41:14 -05:00
Christopher Overbeck 533365dc91 fix(dimension): apply a negative DIMLFAC only where AutoCAD applied it
A negative DIMLFAC scales a paper-space dimension only when it measures
model space through a viewport. AutoCAD records the factor it used as
ACAD_DIMASSOC_CALC_DIMLFAC; a zero there marks a dimension of paper-space
geometry, which reads its sheet distance unscaled. Regenerating such a
dimension multiplied it by the factor anyway, turning 4.457 into 18.110.
2026-09-17 08:41:14 -05:00
Christopher Overbeck 2cf0fc2c42 fix(dimension): run the dimension line out to outside text, straight radial leaders near horizontal
Text placed past an extension line along the dimension's own axis pulls the
dimension line out to its near edge rather than stopping at the two-arrow
stub. A radial leader already within 15 degrees of horizontal runs straight to
the text instead of turning a hook, and a diameter with outside text draws its
single leader for every DIMTMOVE except 2, which keeps only the arrowhead.

Subset parity: 870 of 1076 pictured dimensions now regenerate as drawn.
2026-09-17 08:41:14 -05:00
Christopher Overbeck 85619f49d6 fix(dimension): lay out regenerated dimensions the way their pictures were drawn
Comparing the saved picture block of every dimension in a set of production
drawings with the geometry regenerated after a refresh showed half of them
differing. The differences trace to a few layout rules:

- The stored text middle point (group 11) is where the text was drawn, for
  automatic and hand-placed text alike; a regenerated dimension keeps it and
  only a cleared point is placed from the style.
- Arrows move outside when the text, where it is drawn, would run into an
  arrowhead. The text is measured along the dimension line, so horizontal
  text on a vertical dimension counts its height, a limits stack its two
  lines and `\P` continuation lines their number.
- Text pushed outside starts past the two-arrow stub and a gap; arrow
  length is DIMASZ, not the text height.
- Horizontal text outside a non-horizontal dimension gets a leg to its
  height and a hook one arrow long that it sits against, both when drawn
  and when auto-placed.
- A radius with outside text draws no line to the centre unless DIMTOFL,
  a leader to a hook the text sits against, and an arrowhead whose body
  points toward the text. A diameter with outside text and no DIMTOFL is a
  single leader with one arrowhead, no line across the circle.
- DIMTMOVE 1 stores the hook end; the leader is drawn to it exactly.
- Width estimates measure the widest `\P` line and skip MText format codes.

On a 30-drawing subset with 1076 pictured dimensions the regenerated
drawing now matches the picture for 838 of them, up from 584.
2026-09-17 08:41:14 -05:00
Christopher Overbeck 3e98e17512 fix(dimension): keep the limits stack when both offsets are zero
Production drawings show AutoCAD stacking two equal limits for a DIMLIM
style with zero offsets; the plain-value shortcut matched one drawing from
another authoring tool and broke twenty-five that AutoCAD wrote.
2026-09-16 19:33:06 -05:00
Christopher Overbeck 7b5b86469e fix(dimension): round printed values half away from zero, scale alternate units, stack alternate limits
AutoCAD prints 0.4255 as 0.426; Rust's formatter rounds the binary value,
which sits a hair below the decimal tie, and printed 0.425. Decimal
dimension text, tolerances and limits now settle the tie the way the
drawings do.

Alternate units multiplied the raw measurement instead of the displayed
DIMLFAC-scaled value, so a paper-space dimension's bracketed value was off
by the viewport factor, and alternate limits were written high/low with a
slash where AutoCAD stacks them like the primary limits.
2026-09-16 19:20:47 -05:00
Christopher Overbeck a30db2d4f3 fix(dimension): plain value for DIMLIM without offsets, stored angle for degenerate angular text
A limits style whose upper and lower offsets are both zero prints the plain
value in AutoCAD, not a stack of two equal numbers. A three-point angular
dimension whose extension point sits on its vertex has no measurable angle;
AutoCAD keeps showing the angle it stored, and the text now does too instead
of reading 0 degrees. Both cases came out of comparing saved picture blocks
with regenerated text across a set of production drawings.
2026-09-16 18:28:58 -05:00
Christopher Overbeck 1474376924 docs(dimension): before and after screenshots for DIMLIM limits stack 2026-09-16 17:29:32 -05:00
Christopher Overbeck 86f63f5fd1 fix(dimension): DIMLIM replaces the nominal with a limits stack
Dimensions with DIMLIM on were regenerated as the nominal value plus a
separate stacked pair, and the pair was built from the unscaled
measurement. AutoCAD prints only the upper limit over the lower one, with
the offsets applied to the DIMLFAC-scaled, DIMRND-rounded value, and keeps
both halves at the same number of decimals under DIMTZIN. Drawings with a
limits-only style looked right on load, from their saved picture blocks,
and gained an extra number as soon as an associative refresh dropped the
picture.

Fit and fill estimates now count visible cells, so a stack is as wide as
its wider half at DIMTFAC, and the text-to-line offset uses the stacked
height.
2026-09-16 17:06:14 -05:00
Christopher Overbeck 7cd5aefc17 docs(dimension): add placement preview screenshots
Before and after captures of DIMLINEAR at the second-point and placement
stages and DIMANGULAR at arc placement, taken from the upstream base and
this branch with the same automation steps.
2026-09-16 13:58:44 -05:00
Christopher Overbeck acbbb23297 feat(dimension): styled placement previews for creation commands
Show the styled dimension and its value while placing a dimension, using
the same builder the command commits with. Each creation command
(DIMLINEAR, DIMALIGNED, DIMANGULAR, DIMRADIUS, DIMDIAMETER, DIMARC,
DIMJOGGED, DIMORDINATE, DIMCONTINUE, DIMBASELINE, QDIM) reports the
dimensions it would commit at the current stage through
`CadCommand::dimension_preview`. The application applies the current
creation style, or the base dimension's style for DIMCONTINUEMODE=1,
and the same viewport measurement scale as commit, then tessellates the
transient entity through the normal dimension renderer.

While the second definition point is being acquired, a hovered model
snap supplies a provisional viewport scale; once both points are fixed,
hovering another viewport does not change the value. Angular dimensions
never receive length compensation. Degenerate geometry, an invalid
scale, or conflicting viewports fall back to the plain rubber-band
preview so no misleading value is shown.
2026-09-16 13:58:44 -05:00
Christopher Overbeck 3196d185a1 Add dimension placement before and after screenshots 2026-09-16 10:40:35 -05:00
Christopher Overbeck c05d6ba44f Hide dynamic input during dimension line placement 2026-09-16 10:36:32 -05:00
Christopher OverbeckandClaude Fable 5.1 8e14b10104 fix(io): keep zero-radius circles and arcs on open
Problem

The open guard rejects circles and arcs with a radius below 1e-10. A normal
open fails when a drawing contains one. The user gets the recovery prompt.
AutoCAD and nanoCAD open these drawings.

The check came from ace99541. Its comment says the tessellator cannot sample
a degenerate curve. That was true of truck's parameter_division. Commit
8c7ebdc9 removed truck. Commit 96be8214 made the kernel sample circles and
arcs by sweep angle with a floored segment count. The reason for the check
no longer exists.

Change

- Remove the near-zero radius check for circles and arcs.
- Keep the checks for non-finite values and radii above 1e10.
- Update the guard comments. The purge rejects parser desync. It does not
  reject degenerate geometry. Be generous in what you accept and strict in
  what you produce.
- Add tests: a zero radius passes the guard, an absurd radius does not.
- Change the automation purge test sample from radius 0 to radius 1e11.

Behavior

- Zero-radius circles and arcs load. They render as dots.
- Tessellation, hit testing, snapping, and hatch boundaries accept a zero
  radius.
- The DXF writer does not change.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 16:17:29 -05:00
Christopher Overbeck 4816652c1f Remove comments that repeat association operations 2026-09-15 16:03:17 -05:00
Christopher Overbeck 27967e0433 Integrate viewport dimension creation fixes
# Conflicts:
#	docs/viewport-dimensions.md
#	src/app/update/viewport_snap.rs
#	src/scene/viewport_dimension_pick.rs
#	src/snap.rs
2026-09-15 16:01:02 -05:00
Christopher Overbeck 5424a19482 Track mixed paper and viewport dimension references 2026-09-15 16:00:11 -05:00
Christopher Overbeck 6de4ed5a06 Support paper centerline dimensions through viewports 2026-09-15 15:56:33 -05:00
Christopher Overbeck bd00d679a9 Integrate updated creation branch and upstream constraint fixes 2026-09-15 14:56:10 -05:00
Christopher Overbeck 71aae72219 Integrate upstream constraint editing fixes with viewport dimensions 2026-09-15 14:56:00 -05:00
Christopher Overbeck 6ed9714c6a Integrate final viewport creation branch and retain association regression coverage 2026-09-15 14:54:17 -05:00
Christopher Overbeck 64ce0b2e8c Resolve viewport dimension features and preserve valid measurements on refresh 2026-09-15 14:53:45 -05:00
Christopher Overbeck 3274d6d0fd Validate viewport object picks and cover angular arc acquisition 2026-09-15 14:53:29 -05:00
Christopher Overbeck 7f018ab0f1 Merge viewport creation fixes and current upstream into associations 2026-09-15 14:14:33 -05:00
Christopher Overbeck 2ee3782850 Fix viewport dimension acquisition and preserve measurement scaling 2026-09-15 14:13:19 -05:00
Christopher Overbeck 369e0c9fc5 Merge remote-tracking branch 'origin/main' into feat/viewport-dimensions
# Conflicts:
#	src/app/command_driver.rs
#	src/scene/mod.rs
2026-09-15 13:53:12 -05:00
Christopher Overbeck 6a63726eff Add Windows SpaceMouse navigation through 3DxWare 2026-09-15 13:20:19 -05:00
Christopher OverbeckandClaude Fable 5.1 be7ba3d68e Record viewport associations for new dimensions
Removes PR3's `viewport_frame` seam in `viewport_ref.rs`; the frame is
PR1's camera-derived one everywhere.

The five dimension commands (DIMLINEAR / DIMALIGNED / DIMRADIUS /
DIMDIAMETER / DIMANGULAR) now record a real association when they
measure through a viewport. The commit path's one association gate
(`dimension_association_allowed`, added when PR2 was stacked) gains an
else-branch: where PR2 committed the dimension unassociated and PR1's
inference guard returned an empty source list, `Scene::attach_viewport_
dimension_association` now stores the whole chain — dimension ->
viewport -> block path -> entity -> feature — from the AcceptedSnaps PR1
captured, in definition-point slot order.

* `Scene::dimension_association_slot_points` names that order, including
  the radial dimensions `dimension_reference_points` does not cover.
* `OpenCADStudio::viewport_dimension_snaps` builds the slot array. A snap
  whose paper point is no longer the definition point it fills has its
  model point recomputed from that definition point through the frame:
  the object pick records one click for both extension origins, and a
  rotated DIMLINEAR projects its origins onto the measuring axis.
* Radial dimensions take their own path inside
  `attach_viewport_dimension_association`. Their single reference is an
  osnap type 10 carrying the radial marker (which circle / which polyline
  arc segment) and the angle around it; `source_reference` would hand
  back the generic curve-parameter marker -2, which
  `radial_source_for_marker` cannot find.

One measurement rule, documented in `viewport_ref::measurement_rule`:
`actual_measurement` is always the RAW measurement in the dimension's own
space (paper, for a viewport dimension), and the viewport compensation
lives only in the magnitude of the negative DIMLFAC override, applied
once by the formatter. PR2's persistence already followed it; PR3's
refresh did not — it substituted the model distance times
`model_factor()`, which made a refreshed dimension read `user_lfac` times
too much versus an identical freshly-created one. Refresh now writes the
paper measurement (a debug assert pins resolved paper points to the model
points through the frame), and `axis_distance` goes with it.

Block sub-entity snaps: verified that `block_cache` stamps the top-level
INSERT's handle onto every expanded and clipped sub-wire, so
`wire_source` can only ever report the INSERT — nested inserts included.
`OpenCADStudio::resolve_source_block_path` now descends from that INSERT
at accept time (using the object-pick path's existing
`resolve_measurable_entity`, so arbitrary depth, not just one level) and
fills `SnapSourceRef.block_path` with the real inner entity. Where the
descent cannot identify one — a non-planar-pickable type such as text,
hatch, spline or solid, or nesting past eight levels — the source stays
the INSERT with an empty path, which is coarser but never wrong. Both the
limitation and where it bites are documented on
`Scene::resolve_block_snap_source` and `SnapResult::source`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 12:52:00 -05:00
Christopher OverbeckandClaude Fable 5.1 f2c9e90c34 Add a synthetic viewport-association fixture
A hand-written DXF small enough to read: one layout viewport at a 1:2
paper-per-model scale, one line, circle, arc, bulged lightweight
polyline, spline and block instance in model space, and one associative
aligned dimension per feature drawn on the sheet through the viewport.

Each association exercises a different part of the chain and feature
resolution: endpoints on a line, quadrants on a circle, endpoints on an
arc, an endpoint and a bulge segment on a polyline, a tangent-to-spline
reference (the 115448 shape), a viewport -> insert -> line chain through
a block instance, and an intersection with a second object chain. Every
association is marked trans_space, so all of them fail on the pre-PR3
resolver and all of them are expected to resolve now.

The expected values are checkable by hand: the viewport maps model
(x, y) to paper (150 + (x - 150)/2, 100 + (y - 150)/2), so a 100-unit
model length is 50 paper units and must still display as 100.

Verification tests come in the next pass; the fixture is committed now
so they have something to read.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 12:46:04 -05:00
Christopher OverbeckandClaude Fable 5.1 f80130560e Regenerate viewport dimensions when the viewport view changes
Panning, zooming, twisting or rescaling a layout viewport moves model
geometry across the sheet without touching that geometry, so nothing in
the ordinary entity-change path noticed and the dimensions drawn through
the viewport stayed where they were — detached from what they measure.

`Scene::notify_viewport_changed` announces the viewport itself as a
modified entity. That is all it takes: every trans-space reference
carries the viewport handle as the first link of its chain, so the
dependency lookup in `refresh_associative_dimensions` already finds the
associations from there, and the refresh re-places and re-measures them.

Called from each mutator that changes the paper<->model mapping: pan,
zoom, fit-to-bounds, view normalisation on entering MSPACE, the
view-direction/twist snaps, the viewcube camera path, and both
annotation-scale setters.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 12:46:04 -05:00
Christopher OverbeckandClaude Fable 5.1 e8246031fd Remap dimension associations across a copy
COPY, MIRROR and ARRAY duplicated a dimension's baked block but left the
copy with no association at all. Now a copy keeps one, on one condition:
every handle in every chain — the viewport, each INSERT on the block
path, the source entity, and an intersection's second chain — must have
been copied too. Then the whole chain is remapped onto the copies, so
the copy measures the copied geometry.

The condition is all-or-nothing on purpose. A partially-remapped chain
would leave a copy quietly driven by the original's geometry, which is
worse than no association; and copying a dimension on its own still
produces an unassociated dimension, which is exactly what the model
space path did before. It is the same rule `copy_complete_groups` uses
for groups, applied to a longer chain.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 12:46:04 -05:00
Christopher OverbeckandClaude Fable 5.1 2697b60d21 Create, resolve and refresh associations through viewports
This wires the chain walker into the association system proper, and adds
the entry point PR2 calls.

Resolution. `resolve_reference_chain` walks the chain, evaluates the
osnap feature in the entity's own coordinates, lifts it to model space
through the block path, and — when the chain goes through a viewport —
projects it onto the sheet with that viewport's `ViewportFrame`. Both
points are kept: the dimension holds paper points, the value it displays
is a model length. `ChainMap` is the one place that conversion lives,
including a `map_radial` that carries a radial source's plane into the
dimension's space so the radius/diameter/arc paths need no viewport
special-casing of their own. Which space a stored osnap point was
written in is not recoverable from the record — our own writer used
model/WCS, AutoCAD's trans-space records use the dimension's space, and
both turn up in the same drawing — so both readings are mapped down to
the entity and the one that lands on the geometry wins. It is only ever
a hint that disambiguates candidates, never the answer.

Creation. `Scene::attach_viewport_dimension_association` takes one
`AcceptedSnap` per definition point and writes the whole chain: viewport
handle, block path, entity, osnap type, sub-entity marker and parameter,
plus `trans_space` on the association. Every link becomes a reactor
target, not just the innermost entity, so a change anywhere along a
chain reaches the refresh through the ordinary dependency index.

Refresh. `refresh_associative_dimensions` now matches a changed handle
anywhere in a chain (including the second chain of an intersection), so
a viewport change reaches the dimensions drawn through it. Perpendicular
and tangent references resolve in a second pass, measured from the other
definition point. A dimension carries its sheet annotation with it: the
dimension-line standoff and a user-dragged text position move by the
centroid shift of the points that actually moved, so a one-ended edit
keeps the dimension line centred instead of collapsing it. For a
trans-space linear or aligned dimension the displayed value is
recomputed from the model points and multiplied only by the user's
DIMLFAC (`MeasurementScale::model_factor`) — the viewport compensation
is inherent in having measured in model space, and applying it again is
the double-scaling this three-PR effort exists to stop.

Broken references. Brokenness is derived, never written: a reference
whose source was erased simply stops resolving, the dimension keeps its
last valid appearance, and the record is left byte-for-byte as it was.
An undo that restores the source restores the link with no extra
bookkeeping. `Scene::dimension_association_status` reports it per slot.
`dimension_is_associative` now walks the chain too, instead of accepting
any surviving handle in `xrefs` — which was true even for a trans-space
reference whose actual geometry had gone, because the viewport was still
there.

Undo. Creating an association journals the new object and every reactor
list it touches; refreshing a dimension journals the dimension's
before-image. Both were previously invisible to the delta recorder.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 12:46:03 -05:00
Christopher OverbeckandClaude Fable 5.1 64c534f511 Resolve dimension references through viewport and block chains
An `AssocDimensionReference` addresses a feature through a chain of
handles — dimension -> [viewport] -> [insert ...] -> entity -> feature —
stored in `xrefs` (AutoCAD's `mainObjectIds`). The existing resolver read
only `xrefs[0]`. That is right for a model-space dimension on top-level
geometry and wrong for everything else: a paper-space dimension measuring
through a layout viewport has the VIEWPORT first, so resolution stopped
on a viewport entity, found no curve and gave up. Nine of the ten
association objects in the 115448 regression drawing are that shape.

`dimension_assoc_chain` walks the chain positionally, which is all the
file gives us: a leading VIEWPORT is the trans-space hop, INSERTs after
it are the block-instance path (with their composed transform), and the
first handle that is neither is the geometry. A chain of nothing but
INSERTs ends on the innermost one, which is how a block reference's own
insertion point is dimensioned. A handle that is no longer in the
document comes back as `ChainError::Missing` rather than as a silent
failure, so the caller can tell a broken link from an unsupported one.

The module also evaluates the osnap feature itself, in the entity's own
coordinates: centre, midpoint, quadrant, node/insertion, nearest,
perpendicular, tangent (closed form on a circle or arc; a Newton
refinement of (C(u) - P) . C'(u) = 0 on a spline, seeded from the stored
parameter — the shape of the 115448 tangent-to-spline reference), and
intersection / apparent intersection against the second object chain.
Types we do not evaluate return None and keep their stored data, so an
unsupported reference is left unresolved instead of being replaced by a
guess.

`Transform` has no inverse of its own and a block path is always affine,
so the 3x3 cofactor inverse lives here too. All arithmetic is f64.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 12:46:03 -05:00
Christopher OverbeckandClaude Fable 5.1 417e8e3676 Add a viewport frame seam for dimension association resolution
PR3 has to map a resolved model point onto the sheet, which needs the
layout viewport's paper<->model mapping. PR1 (feat/viewport-snap) owns
`Scene::viewport_frame` and is not visible from this branch, so this adds
the minimal stand-in the shared contract in `viewport_ref.rs` describes:
paper_center from the viewport rectangle's centre, model_target from the
saved view target, scale from `vp_effective_scale`, twist from the saved
view twist.

It is fenced off in one clearly marked block with merge instructions.
PR1's version is camera-derived, because it must agree with what is
actually drawn — including the auto-fit rescue `camera_for_viewport`
applies to stale saved views. Nothing else in PR3 reads viewport fields
directly, so replacing this one function is the whole merge.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 12:46:03 -05:00
Christopher OverbeckandClaude Fable 5.1 43dea41498 Use viewport snap frame and accepted snaps for dimension measurement
Removes PR2's two seams now that PR1 (viewport snapping) is underneath.

* `Scene::viewport_frame` is PR1's camera-derived implementation. The
  viewport-record version is gone; PR1's returns `None` for oblique /
  perspective viewports, which is what we want — every entry point in
  `viewport_dim_seam` then declines rather than guessing a planar
  mapping. `viewport_dim_seam` is now just the dimension object-pick
  path, and its module doc says so.

* There is one accepted-snap store: PR1's `OpenCADStudio::accepted_snaps`.
  PR2's parallel `dim_accepted_snaps` (and its command-name ownership
  tracking, which PR1's clear-on-command-start/end already does) is
  deleted, along with the `record_dimension_accepted_snap` seam.
  `accepted_snap_for_point` reads PR1's list.

  Two things layer on top:
  - the explicit object pick is not a point step, so it still records its
    own snaps, now through `push_accepted_snap` into PR1's list, and now
    carrying the picked entity handle + block path as a `SnapSourceRef`
    so PR3 can associate it;
  - a typed coordinate carries no snap, so `measure_snap` re-attaches the
    content viewport under a snap that is free *and* source-less. That
    keeps `DIMLINEAR` with typed coordinates inside a 1:10 viewport
    measuring the model, without ever reinterpreting a real paper-sheet
    snap that happens to sit over a viewport.

* One association gate, not two. `dimension_association_allowed` is the
  single predicate, derived from the same `dimension_measure_space`
  classification that decides whether to compensate the measurement, so
  the two can never disagree. `infer_dimension_sources_guarded` delegates
  to it, and the commit path's "commit unassociated when measured through
  a viewport" condition is now that predicate rather than a second,
  independently-computed bool. It also covers the explicit-pick and
  explicit-source-list branches, which the old inference-only guard did
  not. Because the classification ignores the trailing dimension-line
  placement click, an ordinary paper dimension whose placement click lands
  inside a viewport rectangle keeps its association.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 12:45:59 -05:00
Christopher OverbeckandClaude Fable 5.1 848f789018 Stamp the layout owner so compensated text survives every commit path
The negative DIMLFAC convention is resolved from the dimension's owner block,
but the entity only gets one when `add_entity_to_layout` commits it. With
DIMASSOC = 0 the dimension is exploded to its pieces first, and the baked
text would then have read as a model-space dimension and dropped the
compensation.

Stamp the layout block - the same handle the add assigns - as soon as the
override is written, so the entity is self-describing whichever path consumes
it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 12:40:44 -05:00
Christopher OverbeckandClaude Fable 5.1 773365a1b9 Keep the persisted representation and the seam tests consistent
Colocated unit tests pin the persisted representation: a 1:10 viewport with
no user factor writes DIMLFAC -10, a 10-unit paper span then formats as 100,
and the same dimension read back in model space does not scale. A positive
user factor folds into the magnitude, and a drawing-wide negative DIMLFAC
that already carries the compensation writes no override at all. Another
test pins the viewport model->paper affine against ViewportFrame's own
mapping, so object-pick projection and snap projection cannot drift.

Also clears the recorded snaps when a multi-dimension command commits.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 12:40:44 -05:00
Christopher OverbeckandClaude Fable 5.1 fcf1b96fdf Resolve dimension object picks against model geometry inside a viewport
Paper-space hit testing only sees sheet entities, so DIMLINEAR / DIMALIGNED /
DIMRADIUS / DIMDIAMETER / DIMANGULAR in "select object" mode found nothing
when the user clicked the model geometry a viewport displays.

A pick that lands inside a content viewport is now mapped into model space
through the viewport frame and tested against that viewport's resident model
wires - the same tessellation it draws, so the pick matches what the user
sees. Ordinary block instances are descended into, with the instance
transform (recursively, up to eight levels) baked into the extracted
geometry, so a line inside a block measures its placed length.

The picked entity is handed to the command already projected onto the sheet,
so all five commands keep working entirely in paper coordinates and unchanged;
the pick is recorded as a viewport snap, so the committed dimension is
compensated exactly like a point-input one. A miss falls through to the
ordinary paper-space pick.

Also narrows mixed-space classification to the measuring points: the final
click of each of these commands is the dimension-line placement, which lands
on bare sheet for viewport measurements and inside a viewport rectangle for
paper ones, and says nothing about what is being measured.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 12:40:44 -05:00
Christopher OverbeckandClaude Fable 5.1 30c5ccbba1 Measure paper-space dimensions through the viewport they were picked in
DIMLINEAR / DIMALIGNED / DIMRADIUS / DIMDIAMETER / DIMANGULAR created on a
sheet now report the model measurement of the geometry seen through a
viewport, at any viewport scale, while definition points, the dimension line
and the text stay in paper coordinates and the annotation keeps its sheet
size (DIMSCALE is untouched).

A viewport frame is a similarity - uniform scale plus in-plane twist - so
projecting the model span onto the paper axis and compensating gives exactly
the raw paper measurement times 1/scale. No geometry is rewritten: the
compensation is persisted once as a negative DIMLFAC override on the
dimension and applied once by the linear formatter, so preview text,
dimension text, the properties measurement row and DXF/DWG export all agree.
User measurement multipliers fold into the same magnitude, and an explicit
text override still bypasses formatting entirely, so it is never re-scaled.
Angular dimensions are never compensated - a similarity preserves angles.

Mixed input (one point through viewport A, another through viewport B or on
the sheet) is refused rather than silently mis-measured: the command line
explains why and the dimension falls back to plain paper measurement.

A dimension measured through a viewport is left unassociated: its definition
points are projected paper coordinates, so inferring a paper-space
association would bind it to geometry that is not there. PR3 records the real
dimension -> viewport -> entity chain.

The result is one ordinary dimension entity carrying one style override, so
undo/redo works through the existing history with no new out-of-document
state.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 12:40:44 -05:00
Christopher OverbeckandClaude Fable 5.1 9ee809d389 Resolve the DIMLFAC sign convention and add the viewport measurement seams
DXF gives DIMLFAC a sign convention: a positive value scales every
dimension, a negative one applies |DIMLFAC| only to dimensions that live in
a paper-space layout (where it compensates a viewport's scale). The linear
formatter multiplied by the raw signed value, so a drawing with negative
DIMLFAC (e.g. 115448.dxf) rendered negative measurements.

Resolve the sign once, in `resolved_dimension_style`, the single place an
effective style is derived, so dimension text, alternate units, exploded and
baked text and the properties panel all agree. `extended_data` keeps the raw
signed value, so DXF/DWG export is unchanged. The properties "Measurement"
row now reports the value the text shows rather than the raw distance, while
`actual_measurement` (group 42) stays raw as the format requires.

Also adds the PR2 seams the dimension work builds on:
  * MeasurementScale::user_lfac_for_space / viewport_dimlfac_override —
    the persisted representation of viewport compensation.
  * Scene::viewport_frame and paper-point viewport resolution.
  * Model-geometry hit testing through a content viewport, including
    descent into block instances with the instance transform baked in.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 12:40:44 -05:00
Christopher Overbeck e52e565b1f fix(renderer): keep draw-order offsets within clipping planes 2026-09-15 12:36:05 -05:00
Christopher OverbeckandClaude Fable 5.1 dbdddb7dc2 feat(app): record accepted snaps per point step and guard dimension inference
Point input now keeps an `AcceptedSnap` alongside each point a command
collects: the paper point, the model point it came from, the viewport and
frame it was seen through, and the geometry identity the snap engine
attributed. Interactive picks record in the click handler, typed / dynamic /
headless points record in `feed_command`, and the list is cleared whenever a
command starts or the active space changes. Commands that don't care keep
consuming the plain paper point unchanged; PR2 reads the model point and
frame, PR3 the source.

Dimension association inference is suppressed when the dimension's points came
through a viewport. Inference matches geometry against the dimension's own
definition points in whatever space it is handed, so paper definition points
projected from a viewport would associate with unrelated paper-sheet geometry
at those coordinates, or match model geometry using paper coordinates. An
empty source list makes `attach_dimension_association` a no-op. PR3 replaces
this with real viewport-aware associations.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 12:27:33 -05:00
Christopher OverbeckandClaude Fable 5.1 6def465ba5 feat(snap): snap paper space through layout viewports
Paper-space commands could not pick model geometry displayed by a layout
viewport: `hit_test_wires` deliberately excludes viewport content in PSPACE,
and that has to stay true for selection. This adds the capability as a separate
query rather than by changing those semantics.

`OpenCADStudio::paper_viewport_snap` walks the current layout's displayed
content viewports top-most first, skips those whose display is off or whose
clip boundary excludes the cursor, and for each one runs the EXISTING snap
engine against that viewport's model wires using the viewport's own camera and
screen rectangle (`viewport_edit_frame_for`). The accepted model point is
mapped back onto the sheet with `ViewportFrame::model_to_paper`, so everything
downstream keeps working in paper coordinates — LINE still draws at the
projected paper point. Grid snap and the paper-space rubber-band origin are
suppressed for that pass; results outside the clip are rejected so no snap
lands on a clip cut. Locked viewports snap like unlocked ones.

No projected geometry is rebuilt per pointer event: the wire set is the
renderer's resident per-viewport Arc (`model_wires_for_viewport_arc`) and the
broad phase reuses the shared interaction index over it.

The result is merged with the ordinary paper-sheet snap by `snap::merge_snap`,
which applies the engine's own priority-then-distance rule, in both the
cursor-move and the click paths.

`SnapResult` gains Copy fields `viewport: Option<Handle>` and
`source: Option<DimensionAssociationSource>`; the engine fills `source` from
the owning wire wherever it knows it. `AcceptedSnap::from_snap` converts a
result plus frame into the shared PR2/PR3 contract type.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 12:27:25 -05:00
Christopher OverbeckandClaude Fable 5.1 53576fabdf feat(scene): build a planar ViewportFrame from a layout viewport's live camera
`Scene::viewport_frame` derives the paper<->model mapping (paper_center,
model_target, paper-per-model scale, in-plane twist, lock state) from
`camera_for_viewport` — the same camera the renderer displays the viewport's
contents with — so a snap computed through the frame lands on the pixels the
GPU drew. Oblique / mirrored / perspective viewports return None.

Also generalises the MSPACE editing adapter: `viewport_edit_frame_for(handle)`
returns the camera + screen rect for any viewport, and `viewport_edit_frame`
becomes a wrapper over it for the active one. Adds
`viewport_displays_paper_point` (rectangular bounds plus a non-rectangular clip
boundary polygon, tested in paper coordinates) and public accessors for the
layout's content viewports and their VP ON state.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 12:27:13 -05:00
Christopher OverbeckandClaude Fable 5.1 49fe48f89c Add shared viewport frame, accepted snap, and measurement scale interfaces
Contract module for the viewport snapping, viewport dimension measurement,
and dimension association work that builds on it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 12:10:28 -05:00
Christopher Overbeck e16ab7c86f Preserve narrow spline clearances when tessellating solid fills
Recover failed small spline boundaries with the kernel chord-error
sampler, using the same coordinate frame as the fill mesh. Preserve
source curves and enforce recovery and total hatch vertex limits.

Fixes #1268
2026-09-14 17:09:33 -05:00
Christopher Overbeck 37cfa06b02 Preserve fractional viewport placement when compositing paper space
Keep fractional raster placement and matching integer texture coverage
through compositing. Apply parent clipping without changing the scale.

Fixes #1267
2026-09-14 17:07:57 -05:00
Christopher Overbeck c93cc17592 Include bitmap images in PDF plots, preview, and printing
Include decoded IMAGE/OLE content in the shared PDF plotting path,
with clipping, transforms, transparency, draw order, and resource reuse.
Use named page content for export and printing.

Fixes #1266
2026-09-14 17:07:57 -05:00
Christopher Overbeck f85b23bf74 Enumerate installed Windows printers in the plot dialog
Populate the plot-dialog printer list from the Windows spooler cache.
Validate record counts and UTF-16 name bounds before reading the buffer.

Refs #1261
2026-09-14 17:07:56 -05:00