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>
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>
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>
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>
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>
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>
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>
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>
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>
`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>
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>
Keep fractional raster placement and matching integer texture coverage
through compositing. Apply parent clipping without changing the scale.
Fixes#1267
Optimize InteractionIndex::build by eliminating per-wire vector allocation churn
and combining wire handle / ordinal resolution:
1. Introduced WireIndexBatch to chunk wires into batches of 256 wires. Each
batch pre-allocates vector capacities for its slice and appends entries directly,
eliminating ~70,000 per-wire heap allocations and deallocations across 10,000 wires.
2. Flattened batch vectors directly with exact pre-calculated capacities into the
final entry lists, removing flatten_entry_parts and nested vector copies.
3. Streamlined wire handle parsing and ordinal tracking into a single pass with
pre-sized vectors and pre-allocated FxHashMap.
Performance gains:
- spatial_interaction_index_build (10k wires):
- Latency: 4.88 ms -> 4.01 ms (-17.8%)
- Mean latency: 4.93 ms -> 4.05 ms (-17.8%)
- Throughput: 2.05M wires/s -> 2.53M wires/s (+23.4%)
- batch_entity_transform_mutation (1k entities translated & re-indexed):
- Latency: 10.92 ms -> 8.95 ms (-18.0%)
- Mean latency: 11.28 ms -> 9.52 ms (-15.6%)
- Throughput: 92.4k entities/s -> 111.3k entities/s (+20.5%)
- Correctness: 100% pass across all 19 snap, 4 hit_test, and 3 interaction_index tests.
Optimize themed_handle in src/ui/icons.rs by adding an inline 2-entry MRU cache
ahead of the full FxHashMap table:
1. Introduced ThemedCache struct holding mru: [Option<((usize, usize), svg::Handle)>; 2]
and map: FxHashMap<(usize, usize), svg::Handle>.
2. Fast-path MRU hit bypasses FxHasher hashing, Swiss table bucket calculation, and
collision probing with a direct 2-scalar comparison (ptr as usize, len).
3. Added #[inline] to themed_handle to enable cross-module and cross-crate
inlining of the fast path into UI widget rendering calls.
4. Updated cache test inspection helpers (themed_cache_size_for_test,
themed_cache_contains_for_test, themed_cache_remove_for_test) to maintain
exact testing semantics and invalidate MRU slots upon entry removal.
Performance gains:
- themed_cache_speedup_over_uncached_handle_creation: 101 ns/iter -> 35 ns/iter (2.9x speedup)
- ui_icon_handle_cached: 80.19M lookups/s (12.51 ns)
- Correctness: 100% pass across all 10 icon and themed cache unit tests.
The vertex-normal fallback compared the cross product's length with
f64::EPSILON, an area-scale threshold, so a valid triangle small enough
fell back to the leaning vertex normal again. Fall back only when the
cross product is zero.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The vertex-normal fallback compared the cross product's length with
f32::EPSILON, an area-scale threshold, so a valid facet with 1e-4 edges
(cross product 1e-8) took the leaning vertex normal again. Fall back only
when the cross product is zero.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Optimize wide and tapered arc fill mesh generation by eliminating general
ear-clipping triangulation and optimizing arc boundary evaluation:
1. Replaced triangulate_planar with triangulate_band_ring and direct
quad-strip vertex construction in wide_band_tris. Polyline band fills
possess strict quad-strip topology (straight segments: 4-vertex trapezoid;
arc segments: 2m-vertex annular sector). Generating triangles directly in
O(N) time avoids Newell normal calculation, planar projection, Delaunay
triangulation, and O(N^2) ear-clipping searches.
2. Exact buffer capacity pre-allocation in wide_band_tris based on ring
lengths, eliminating dynamic reallocation during triangle emission.
3. Replaced top and bottom cap triangulation in thick_band_tube with
triangulate_band_ring.
4. Unified inner and outer arc point calculation in polyline_segment_fill
into a single loop using ang.sin_cos(), halving trigonometric calls and
eliminating dynamic vector resizing.
Performance gains (wide_and_tapered_arc_tessellation benchmark across 7,500 entities):
- Latency: 27.34 ms -> 23.30 ms (-14.8%)
- Mean latency: 28.08 ms -> 24.15 ms (-14.0%)
- Throughput: 265.6k entities/s -> 321.9k entities/s (+21.2%)
- Correctness: 100% pass across all 45 polyline and 5 tessellation unit tests.
PlotStyleTable::to_text wrote scale_factor with one decimal, and saving
from the plot style panel rewrites the whole loaded table. A table with
apply_factor on and scale_factor=0.25 came back as 0.2, thinning every
scaled lineweight by a fifth.
Write the factor in full, keeping the decimal point on a whole value.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
In a .lin file the description is optional and the comma goes with it, so
"*PLAIN" followed by "A,.5,-.25" is a complete definition. parse only kept
a header that contained a comma, so such a linetype was dropped silently
and a CENTERLTYPE naming it then reported that it was not loaded.
Take the whole header as the name when there is no comma.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
build_stl took the facet normal from the first vertex's normal whenever the
mesh had normals. On smoothed meshes (tessellated curved solids, OBJ imports
with vn) that normal leans off the triangle, while STL defines the facet
normal as the triangle's own unit normal.
Use the triangle's own normal, and keep the vertex normal only for a
triangle with no area. Meshes without normals export as before.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
build_step took the PLANE normal from the first vertex's normal whenever
the mesh had normals. On smoothed meshes (tessellated curved solids, OBJ
imports with vn) that normal leans off the triangle, so the plane missed
two of the face's corners and the first edge was no longer perpendicular
to the placement axis.
Use the triangle's own normal, and keep the vertex normal only for a
triangle with no area. Meshes without normals export as before.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
With a working plane other than world XY, ROTATE and MIRROR handed a
non-ordinate dimension to acadrust's Entity::apply_transform, which only
translates those dimension types by where the transform sends the origin.
The definition points, plane and angles stayed unturned, so the dimension
jumped back once its block was baked again.
Send both through the existing Affine branch, which ALIGN already uses and
which transforms points, normal and stored angles together. The world-Z
paths are unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>