diff --git a/Cargo.lock b/Cargo.lock
index 75d2c4495..d153311ef 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -921,7 +921,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "cadkernel"
version = "0.1.0"
-source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=dcf0840#dcf0840d2e62afdde35cf38c0a3aa914ff305eed"
+source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=7ce27f2#7ce27f2980da88734dff922c169eb2b9cd5dc404"
dependencies = [
"acadrust",
"cavalier_contours",
@@ -932,7 +932,7 @@ dependencies = [
[[package]]
name = "cadkernel-constraints"
version = "0.1.0"
-source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=dcf0840#dcf0840d2e62afdde35cf38c0a3aa914ff305eed"
+source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=7ce27f2#7ce27f2980da88734dff922c169eb2b9cd5dc404"
dependencies = [
"nalgebra",
]
diff --git a/Cargo.toml b/Cargo.toml
index 05031cca2..1c1536f7d 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -34,8 +34,8 @@ clap = { version = "4", features = ["derive", "string"] }
env_logger = "0.11"
log = "0.4"
acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "568a12c", features = ["serde"] }
-cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "dcf0840", features = ["acis", "offset"] }
-cadkernel-constraints = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "dcf0840" }
+cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "7ce27f2", features = ["acis", "offset"] }
+cadkernel-constraints = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "7ce27f2" }
dwg-thumbnailer = { path = "crates/dwg-thumbnailer" }
flate2 = "1"
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] }
diff --git a/docs/viewport-dimension-association.svg b/docs/viewport-dimension-association.svg
new file mode 100644
index 000000000..8a7a06428
--- /dev/null
+++ b/docs/viewport-dimension-association.svg
@@ -0,0 +1,19 @@
+
diff --git a/docs/viewport-dimension-creation.svg b/docs/viewport-dimension-creation.svg
new file mode 100644
index 000000000..efd1464c1
--- /dev/null
+++ b/docs/viewport-dimension-creation.svg
@@ -0,0 +1,22 @@
+
diff --git a/docs/viewport-dimensions.md b/docs/viewport-dimensions.md
new file mode 100644
index 000000000..2190bdeb2
--- /dev/null
+++ b/docs/viewport-dimensions.md
@@ -0,0 +1,74 @@
+# Dimensions through layout viewports
+
+In paper space, snap to model geometry displayed through a planar, orthographic
+viewport. `DIMLINEAR`, `DIMALIGNED`, `DIMANGULAR`, `DIMRADIUS`, and `DIMDIAMETER`
+also support their usual object-pick modes through these viewports.
+
+## Measurement
+
+- Model geometry supplies the viewport scale. A 100-unit model length displayed
+ at 1:10 measures 100, although it occupies 10 paper units.
+- A paper-space centerline or free sheet point can be combined with model
+ geometry through one viewport, in either pick order. The paper-space span is
+ measured using that viewport's scale.
+- Sheet-only dimensions measure paper units, even inside a viewport rectangle.
+- Model picks from a second viewport are rejected without advancing the command.
+ Dimension-line and text placement do not change the measurement scale.
+- The current dimension style's unit conversion is preserved. Angular dimensions
+ do not use length compensation. `DIMASSOC=0` explodes the compensated dimension.
+
+Snapping and object picking respect visibility, clipping, and viewport draw order.
+Paper geometry has priority in object picks. Ordinary selection uses the active
+drawing space. Object picks reuse the resident interaction index and resolve
+nested block transforms.
+
+
+
+## Saved scale
+
+Length dimensions store paper-space definition points and a negative `DIMLFAC`
+override: user unit factor multiplied by `1 / viewport scale`.
+`OCS_VIEWPORT_MEASUREMENT` XData stores version `1` (integer16), the positive user
+factor (real), and viewport compensation (real). Keeping the factors separate
+allows compensation to change without losing the user's unit conversion.
+Both the override and XData survive DXF/DWG saves and undo/redo.
+
+## Associations
+
+With `DIMASSOC=2`, acquired features retain their viewport, block-instance path,
+source entity, and snap feature. Intersections retain both source chains.
+Source edits and viewport changes update the dimension and its compensation,
+preserving style overrides and user-positioned text on the sheet.
+
+A sheet centerline stays on the sheet; a model endpoint follows its viewport.
+Moving the viewport therefore changes the distance between them. Undo/redo and
+DXF/DWG saves preserve the references.
+
+Properties shows association status. If a required reference is broken or cannot
+be resolved, the entire dimension keeps its last valid geometry and reference
+records. A successful refresh regenerates its displayed and exported geometry.
+
+
+
+## Limits
+
+Unsupported cases preserve saved dimensions:
+
+- Perspective or oblique views; missing or malformed reference chains.
+- Array instances without an addressable instance index, nesting beyond eight
+ levels, and radial measurements of nonuniformly scaled circles or arcs.
+- Intersections without two identifiable source chains inside a batched block.
+- Ambiguous imported endpoint, midpoint, or quadrant markers. A stored point can
+ validate a feature; it cannot establish an otherwise unknown identity.
+- Tangency without a verified solution, or two perpendicular/tangent references
+ requiring a coupled solve.
+- Imported viewport scaling without OCS scale metadata or
+ `ACAD_DIMASSOC_CALC_DIMLFAC` identifying the saved compensation.
+
+## Tests
+
+Run `cargo test --locked --lib viewport_dimension`. Tests cover the five commands,
+mixed sheet/model input, styles, clipping, object picks, source/viewport edits,
+snap features, nested paths, intersections, preserved overrides, undo/redo, and
+DXF/DWG persistence. The synthetic association fixture is in
+`tests/fixtures/dim_assoc/`.
diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs
index 11ee678ff..57c36eef4 100644
--- a/src/app/command_driver.rs
+++ b/src/app/command_driver.rs
@@ -97,6 +97,10 @@ impl OpenCADStudio {
self.tabs[i].scene.clear_preview_wire();
self.tabs[i].snap_result = None;
self.last_point = None;
+ // Points collected in the space being left are meaningless in the new
+ // one.
+ self.clear_accepted_snaps();
+ self.pending_click_snap = None;
self.snapper.from_point = None;
self.snapper.clear_tracking();
self.otrack_active = None;
@@ -417,6 +421,13 @@ impl OpenCADStudio {
if !self.command_point_allowed(i, *point) {
return Task::none();
}
+ // Typed / dynamic-input / headless points carry no snap, but the
+ // accepted-snap list must stay index-parallel with the points the
+ // command collects. Interactive picks record themselves in
+ // the click handler and never reach here.
+ if !self.record_accepted_snap(i, None, None, *point) {
+ return Task::none();
+ }
}
if default_start {
let StepInput::Point(point) = &input else {
@@ -430,6 +441,9 @@ impl OpenCADStudio {
self.push_ucs_to_cmd(i);
}
if let StepInput::EntityPick(handle, point) = &input {
+ if !self.dimension_acquisition_allowed(i, None) {
+ return Task::none();
+ }
let solid_pick = matches!(
self.tabs[i].scene.document.get_entity(*handle),
Some(
@@ -502,6 +516,11 @@ impl OpenCADStudio {
}
let ctrl = self.ctrl_down;
let shift = self.shift_down;
+ let picked_handle = if let StepInput::EntityPick(h, _) = &input {
+ Some(*h)
+ } else {
+ None
+ };
let result: Option = {
let Some(cmd) = self.tabs[i].active_cmd.as_mut() else {
return Task::none();
@@ -520,6 +539,10 @@ impl OpenCADStudio {
StepInput::Escape => Some(cmd.on_escape()),
}
};
+ if let Some(handle) = picked_handle {
+ self.record_dimension_entity_points(i, None, handle, Vec::new());
+ }
+ self.sync_dimension_snaps(i);
match result {
Some(r) => self.apply_cmd_result(r),
None => Task::none(),
@@ -1876,7 +1899,7 @@ impl OpenCADStudio {
};
if is_associative_dimension && association_enabled {
if let Some(handle) = committed {
- let sources = self.tabs[i].scene.infer_dimension_sources(handle);
+ let sources = self.infer_dimension_sources_guarded(i, handle);
self.tabs[i]
.scene
.attach_dimension_association(handle, sources);
@@ -2383,7 +2406,7 @@ impl OpenCADStudio {
let committed = self.commit_entity_handle(entity);
if is_associative_dimension && association_enabled {
if let Some(handle) = committed {
- let sources = self.tabs[i].scene.infer_dimension_sources(handle);
+ let sources = self.infer_dimension_sources_guarded(i, handle);
self.tabs[i]
.scene
.attach_dimension_association(handle, sources);
@@ -2416,6 +2439,20 @@ impl OpenCADStudio {
&entity,
acadrust::EntityType::Dimension(acadrust::entities::Dimension::Ordinate(_))
);
+ // A dimension placed on the sheet but measuring model geometry
+ // through a viewport carries the compensation as a negative
+ // DIMLFAC override.
+ if !preserve_base_style {
+ crate::scene::creation_style::apply_current_creation_styles(
+ &self.tabs[i].scene.document,
+ &mut entity,
+ );
+ }
+ if !self.apply_viewport_dimension_measurement(i, &mut entity) {
+ return Task::none();
+ }
+ // Projected points cannot use direct paper-space source inference.
+ let association_allowed = self.dimension_association_allowed(i);
let inherited_dimension = if preserve_base_style {
match &entity {
acadrust::EntityType::Dimension(dimension) => Some((
@@ -2479,12 +2516,12 @@ impl OpenCADStudio {
if let Some(handle) =
self.commit_entity_handle_with_dimension_policy(entity, preserve_base_style)
{
- if association_mode == 2 {
+ if association_mode == 2 && association_allowed {
let mut changes = vec![(handle, crate::scene::ChangeKind::Modified)];
match association {
crate::command::DimensionAssociationInput::Infer(source) => {
let sources: Vec<_> = source.map_or_else(
- || self.tabs[i].scene.infer_dimension_sources(handle),
+ || self.infer_dimension_sources_guarded(i, handle),
|source| {
if single_source_dimension {
vec![Some(source)]
@@ -2513,6 +2550,9 @@ 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 {
+ // Preserve the acquired viewport and source paths.
+ self.attach_viewport_dimension_association(i, handle);
}
}
pending
@@ -2599,9 +2639,7 @@ impl OpenCADStudio {
crate::command::DimensionAssociationInput::Infer(source) => {
source.map_or_else(
|| {
- self.tabs[i]
- .scene
- .infer_dimension_sources(handle)
+ self.infer_dimension_sources_guarded(i, handle)
.into_iter()
.map(|source| {
source.map(
diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs
index a1cd82915..3a079ef6e 100644
--- a/src/app/commands/mod.rs
+++ b/src/app/commands/mod.rs
@@ -123,6 +123,9 @@ impl OpenCADStudio {
// Reset the last committed point so the first click of the new command
// is not constrained by ortho/polar relative to a previous command's endpoint.
self.last_point = None;
+ // A new command collects its own points, so the previous command's
+ // accepted snaps must not leak into it.
+ self.clear_accepted_snaps();
// Starting a command restarts the right-click cycle, so its first
// right-click acts as Enter rather than opening the context menu.
self.tabs[i]
diff --git a/src/app/dim_viewport.rs b/src/app/dim_viewport.rs
new file mode 100644
index 000000000..841b784e1
--- /dev/null
+++ b/src/app/dim_viewport.rs
@@ -0,0 +1,256 @@
+//! Paper-space dimension acquisition and measurement. Acquiring model geometry
+//! supplies a viewport scale; sheet-only dimensions retain paper units.
+
+use super::OpenCADStudio;
+use crate::command::DimensionAssociationSource;
+use crate::entities::dim_override;
+use crate::scene::viewport_ref::{AcceptedSnap, MeasurementScale, SnapSourceRef, ViewportFrame};
+use acadrust::entities::Dimension;
+use acadrust::types::Handle;
+use acadrust::EntityType;
+use glam::DVec3;
+
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub(crate) enum DimensionMeasureSpace {
+ Direct,
+ Viewport(ViewportFrame),
+ ConflictingViewports,
+}
+
+impl OpenCADStudio {
+ pub(crate) fn finish_command_click(&mut self, i: usize) {
+ let mut selection = self.tabs[i].scene.selection.borrow_mut();
+ selection.left_down = false;
+ selection.left_press_pos = None;
+ selection.left_press_time = None;
+ selection.left_dragging = false;
+ }
+
+ pub(crate) fn dimension_measure_space(&self, i: usize) -> DimensionMeasureSpace {
+ let scene = &self.tabs[i].scene;
+ if scene.current_layout == "Model" || scene.active_viewport.is_some() {
+ return DimensionMeasureSpace::Direct;
+ }
+ let mut frame: Option = None;
+ for f in self.accepted_snaps().iter().filter_map(|snap| snap.frame) {
+ match frame {
+ None => frame = Some(f),
+ Some(previous) if previous.viewport == f.viewport => {}
+ Some(_) => return DimensionMeasureSpace::ConflictingViewports,
+ }
+ }
+ frame.map_or(
+ DimensionMeasureSpace::Direct,
+ DimensionMeasureSpace::Viewport,
+ )
+ }
+
+ /// Reject an incompatible measuring input before it advances the command.
+ pub(crate) fn dimension_acquisition_allowed(
+ &mut self,
+ i: usize,
+ viewport: Option,
+ ) -> bool {
+ if !self.tabs[i]
+ .active_cmd
+ .as_ref()
+ .is_some_and(|c| c.measures_through_viewports())
+ {
+ return true;
+ }
+ // Sheet geometry and free sheet points may accompany one viewport.
+ // A second viewport would make the measurement scale ambiguous.
+ if viewport.is_some_and(|viewport| {
+ self.accepted_snaps()
+ .iter()
+ .filter_map(|s| s.viewport)
+ .any(|previous| previous != viewport)
+ }) {
+ self.command_line.push_error(crate::t!("Model geometry must come from one viewport. Paper-space points can be included.").as_ref());
+ return false;
+ }
+ true
+ }
+
+ /// A command can reject a degenerate point without advancing its step.
+ pub(crate) fn sync_dimension_snaps(&mut self, i: usize) {
+ if let Some(cmd) = self.tabs[i]
+ .active_cmd
+ .as_ref()
+ .filter(|c| c.measures_through_viewports())
+ {
+ self.accepted_snaps
+ .truncate(cmd.dimension_acquired_points().len());
+ }
+ }
+
+ /// Record each definition point actually acquired by an object pick.
+ /// Arcs may supply three angular slots, lines two, and radial picks one.
+ pub(crate) fn record_dimension_entity_points(
+ &mut self,
+ i: usize,
+ frame: Option,
+ entity: Handle,
+ block_path: Vec,
+ ) {
+ let Some(cmd) = self.tabs[i]
+ .active_cmd
+ .as_ref()
+ .filter(|c| c.measures_through_viewports())
+ else {
+ return;
+ };
+ let points = cmd.dimension_acquired_points();
+ for paper_point in points.into_iter().skip(self.accepted_snaps.len()) {
+ self.push_accepted_snap(AcceptedSnap {
+ paper_point,
+ model_point: frame.map_or(paper_point, |f| f.paper_to_model(paper_point)),
+ viewport: frame.map(|f| f.viewport),
+ frame,
+ source: Some(SnapSourceRef {
+ source: DimensionAssociationSource::inferred(entity),
+ block_path: block_path.clone(),
+ snap_type: crate::snap::SnapType::ObjectPick,
+ intersection: None,
+ }),
+ });
+ }
+ }
+
+ pub(crate) fn apply_viewport_dimension_measurement(
+ &mut self,
+ i: usize,
+ entity: &mut EntityType,
+ ) -> bool {
+ let EntityType::Dimension(dimension) = entity else {
+ return true;
+ };
+ let angular = matches!(
+ dimension,
+ Dimension::Angular2Ln(_) | Dimension::Angular3Pt(_)
+ );
+ let frame = match self.dimension_measure_space(i) {
+ DimensionMeasureSpace::Direct => return true,
+ DimensionMeasureSpace::ConflictingViewports => {
+ self.command_line.push_error(crate::t!("Model geometry must come from one viewport. Paper-space points can be included.").as_ref());
+ return false;
+ }
+ DimensionMeasureSpace::Viewport(f) => f,
+ };
+ if angular {
+ return true;
+ }
+ let dimlfac = self.pending_dimension_style_dimlfac(i, entity);
+ let scale = MeasurementScale {
+ user_lfac: MeasurementScale::user_lfac_for_space(dimlfac, true),
+ viewport_compensation: frame.paper_to_model_length_factor(),
+ };
+ if !scale.paper_factor().is_finite() || scale.paper_factor() <= 0.0 {
+ return false;
+ }
+ // Owner is needed even by DIMASSOC=0's explode path, before insertion.
+ entity.common_mut().owner_handle = self.tabs[i].scene.current_layout_block_handle_pub();
+ scale.write_to_entity(entity);
+ true
+ }
+
+ pub(crate) fn try_dimension_viewport_entity_pick(
+ &mut self,
+ i: usize,
+ paper: DVec3,
+ aperture_paper: f64,
+ ) -> Option {
+ let pick = self.tabs[i]
+ .scene
+ .dimension_pick_through_viewport(paper, aperture_paper)?;
+ if !self.dimension_acquisition_allowed(i, Some(pick.frame.viewport)) {
+ return Some(crate::command::CmdResult::NeedPoint);
+ }
+ let command = self.tabs[i].active_cmd.as_mut()?;
+ command.inject_picked_entity(pick.paper_entity);
+ let result = command.on_entity_pick(pick.entity_handle, pick.paper_point);
+ self.record_dimension_entity_points(
+ i,
+ Some(pick.frame),
+ pick.entity_handle,
+ pick.block_path,
+ );
+ Some(result)
+ }
+
+ fn pending_dimension_style_dimlfac(&self, i: usize, entity: &EntityType) -> f64 {
+ let EntityType::Dimension(dimension) = entity else {
+ return 1.0;
+ };
+ dim_override::real(
+ &dimension.base().common.extended_data,
+ dim_override::DIMLFAC,
+ )
+ .or_else(|| {
+ self.tabs[i]
+ .scene
+ .document
+ .dim_styles
+ .iter()
+ .find(|s| s.name.eq_ignore_ascii_case(&dimension.base().style_name))
+ .map(|s| s.dimlfac)
+ })
+ .unwrap_or(1.0)
+ }
+}
+
+impl OpenCADStudio {
+ pub(crate) fn viewport_dimension_snaps(
+ &self,
+ i: usize,
+ dimension: Handle,
+ ) -> Vec