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 <[email protected]>
This commit is contained in:
Christopher OverbeckandClaude Fable 5.1 committed 2026-09-15 12:52:00 -05:00
1 parent f2c9e90c34
commit be7ba3d68e
8 files changed
+336 -95

No files matched your search

+9 -1
View File
@@ -376,7 +376,7 @@ impl OpenCADStudio {
// accepted-snap list must stay index-parallel with the points the
// command collects (PR1). Interactive picks record themselves in
// the click handler and never reach here.
self.record_accepted_snap(None, None, *point);
self.record_accepted_snap(i, None, None, *point);
}
if default_start {
let StepInput::Point(point) = &input else {
@@ -2220,6 +2220,14 @@ impl OpenCADStudio {
changes.sort_by_key(|(handle, _)| handle.value());
changes.dedup_by_key(|(handle, _)| handle.value());
self.tabs[i].scene.bump_entities(&changes);
} else if association_mode == 2 {
// Measured through a layout viewport: a paper-space
// association would bind projected coordinates to
// geometry that is not on the sheet. Record the real
// chain instead — dimension -> viewport -> block
// path -> entity -> feature — from the AcceptedSnaps
// PR1 captured, in definition-point slot order.
self.attach_viewport_dimension_association(i, handle);
}
}
pending
+77
View File
@@ -183,6 +183,83 @@ impl OpenCADStudio {
}
}
/// The slot array [`Scene::attach_viewport_dimension_association`] wants,
/// built from the snaps PR1 recorded for the command that just committed
/// `dimension`.
///
/// Slot `k` describes the feature at
/// `Scene::dimension_association_slot_points()[k]`, so the measuring snaps
/// are taken in collection order and truncated / padded to that length.
///
/// A snap whose paper point is not (still) the definition point it would
/// fill has its `model_point` recomputed from that definition point
/// through the frame. Two cases need it:
///
/// * the explicit object pick, which records one click twice — once per
/// extension origin — so neither recorded point is the endpoint the
/// command derived from the entity;
/// * a point the command moved after the snap (a rotated DIMLINEAR
/// projects its origins onto the measuring axis).
///
/// The source identity is kept in both cases: it is the entity that was
/// acquired, and the feature marker is re-derived from the corrected
/// point.
pub(crate) fn viewport_dimension_snaps(
&self,
i: usize,
dimension: Handle,
) -> Vec<Option<AcceptedSnap>> {
let slots = self.tabs[i].scene.dimension_association_slot_points(dimension);
if slots.is_empty() {
return Vec::new();
}
let measuring = self.dimension_measuring_snaps(i);
slots
.iter()
.enumerate()
.map(|(index, slot)| {
let snap = measuring.get(index)?.clone();
let frame = snap.frame?;
let slot = DVec3::new(slot.x, slot.y, slot.z);
if snap.paper_point.truncate().distance_squared(slot.truncate()) <= 1e-12 {
return Some(snap);
}
Some(AcceptedSnap {
paper_point: slot,
model_point: frame.paper_to_model(slot),
..snap
})
})
.collect()
}
/// Record the viewport association for a dimension that has just been
/// committed while measuring through a layout viewport.
///
/// Announces the whole chain — the viewport, every INSERT on the block
/// path and the source entity — so the dependency index picks the new
/// association up immediately.
pub(crate) fn attach_viewport_dimension_association(&mut self, i: usize, dimension: Handle) {
let snaps = self.viewport_dimension_snaps(i, dimension);
if snaps.iter().flatten().all(|snap| snap.source.is_none()) {
return;
}
self.tabs[i]
.scene
.attach_viewport_dimension_association(dimension, &snaps);
let mut changes = vec![(dimension, crate::scene::ChangeKind::Modified)];
changes.extend(
self.tabs[i]
.scene
.dimension_association_sources(dimension)
.into_iter()
.map(|handle| (handle, crate::scene::ChangeKind::Modified)),
);
changes.sort_by_key(|(handle, _)| handle.value());
changes.dedup_by_key(|(handle, _)| handle.value());
self.tabs[i].scene.bump_entities(&changes);
}
/// Apply viewport compensation to a dimension about to be committed.
///
/// Whether the dimension may also take a paper-space association is a
+1
View File
@@ -4173,6 +4173,7 @@ impl OpenCADStudio {
// `world_pt` exactly as before.
let pending = self.pending_click_snap.take();
self.record_accepted_snap(
i,
pending.map(|(hit, _)| hit),
pending.and_then(|(_, frame)| frame),
world_pt,
+35 -1
View File
@@ -102,11 +102,12 @@ impl OpenCADStudio {
/// after ortho / polar / axis-lock / dynamic-input resolution.
pub(crate) fn record_accepted_snap(
&mut self,
tab: usize,
snap: Option<SnapResult>,
frame: Option<ViewportFrame>,
committed: glam::DVec3,
) {
let accepted = match snap {
let mut accepted = match snap {
// A viewport snap keeps its frame only when the hit really came
// through it, so a paper-sheet snap never claims a model point.
Some(hit) => {
@@ -115,9 +116,42 @@ impl OpenCADStudio {
}
None => AcceptedSnap::free(committed),
};
self.resolve_source_block_path(tab, &mut accepted);
self.push_accepted_snap(accepted);
}
/// Replace a snap source that is a block INSTANCE with the entity inside
/// it that actually owns the snapped feature, plus the INSERT path.
///
/// The snap engine can only report what the wire carries, and the
/// renderer stamps every wire of an expanded block with the *top-level
/// INSERT's* handle — nested inserts included. `SnapSourceRef` promises
/// the innermost entity in `source.handle` and the instance chain in
/// `block_path`, so the descent happens here, once, at accept time (not
/// per pointer move).
///
/// When the descent cannot identify the inner entity the source stays the
/// INSERT with an empty path. See
/// [`crate::scene::Scene::resolve_block_snap_source`] for exactly when
/// that happens; the resulting association is coarser (it tracks the whole
/// block instance) but never wrong.
fn resolve_source_block_path(&self, tab: usize, accepted: &mut AcceptedSnap) {
let Some(source) = accepted.source.as_mut() else {
return;
};
if !source.block_path.is_empty() {
return;
}
if let Some((entity, path)) = self.tabs[tab]
.scene
.resolve_block_snap_source(source.source.handle, accepted.model_point)
{
source.source =
crate::command::DimensionAssociationSource::inferred(entity);
source.block_path = path;
}
}
/// Append an already-built accepted snap, keeping the retention cap.
///
/// Used by paths that are not point steps (the dimension object pick