forked from 1b-agent/opencadstudio-upstream
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 <[email protected]>
This commit is contained in:
1 parent
9ee809d389
commit
30c5ccbba1
10 files changed
+395
-1
No files matched your search
@@ -454,6 +454,17 @@ impl OpenCADStudio {
|
||||
}
|
||||
}
|
||||
if matches!(&input, StepInput::Point(_)) { self.refresh_command_point_pick_context(i); }
|
||||
// PR2: remember which viewport (if any) each dimension point was
|
||||
// picked through, before the command turns it into geometry.
|
||||
if let StepInput::Point(p) = &input {
|
||||
if self.tabs[i]
|
||||
.active_cmd
|
||||
.as_ref()
|
||||
.is_some_and(|command| command.measures_through_viewports())
|
||||
{
|
||||
self.record_dimension_accepted_snap(i, *p);
|
||||
}
|
||||
}
|
||||
let ctrl = self.ctrl_down;
|
||||
let shift = self.shift_down;
|
||||
let result: Option<CmdResult> = {
|
||||
@@ -2106,6 +2117,15 @@ impl OpenCADStudio {
|
||||
acadrust::entities::Dimension::Ordinate(_)
|
||||
)
|
||||
);
|
||||
// PR2: a dimension placed on the sheet but measuring model
|
||||
// geometry through a viewport carries the compensation as a
|
||||
// negative DIMLFAC override. It is deliberately *not*
|
||||
// associated: 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.
|
||||
let measured_through_viewport =
|
||||
self.apply_viewport_dimension_measurement(i, &mut entity);
|
||||
let inherited_dimension = if preserve_base_style {
|
||||
match &entity {
|
||||
acadrust::EntityType::Dimension(dimension) => Some((
|
||||
@@ -2172,7 +2192,7 @@ impl OpenCADStudio {
|
||||
entity,
|
||||
preserve_base_style,
|
||||
) {
|
||||
if association_mode == 2 {
|
||||
if association_mode == 2 && !measured_through_viewport {
|
||||
let mut changes = vec![
|
||||
(handle, crate::scene::ChangeKind::Modified),
|
||||
];
|
||||
@@ -2216,6 +2236,7 @@ impl OpenCADStudio {
|
||||
self.tabs[i].dirty = true;
|
||||
self.tabs[i].scene.clear_preview_wire();
|
||||
self.tabs[i].snap_result = None;
|
||||
self.clear_dimension_accepted_snaps();
|
||||
if let Some(pd) = pending {
|
||||
self.commit_undo_delta(i, pd);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
//! Viewport-aware dimension measurement (PR2).
|
||||
//!
|
||||
//! A dimension created in a paper-space layout while measuring geometry seen
|
||||
//! through a content viewport must *place* itself in paper coordinates but
|
||||
//! *report* the model measurement. Because a viewport frame is a similarity
|
||||
//! (uniform scale plus in-plane twist), the model measurement of any linear,
|
||||
//! aligned or radial span is exactly the raw paper measurement times the
|
||||
//! viewport compensation `1 / scale` — projecting the model span onto the
|
||||
//! rotated paper axis and compensating is the same number. So no geometry is
|
||||
//! rewritten: the compensation is persisted once, as a negative DIMLFAC
|
||||
//! override on the dimension (see
|
||||
//! [`MeasurementScale::viewport_dimlfac_override`]), and applied once, by the
|
||||
//! linear formatter.
|
||||
//!
|
||||
//! Angular dimensions are never compensated — a similarity preserves angles.
|
||||
//!
|
||||
//! ## PR1 seam
|
||||
//! [`OpenCADStudio::record_dimension_accepted_snap`] is the one function PR1
|
||||
//! replaces. PR1 records a real [`AcceptedSnap`] per accepted point (with the
|
||||
//! snapped source and the frame in effect at snap time); this branch derives
|
||||
//! the same information from the cursor's paper position and the content
|
||||
//! viewport under it. Everything downstream consumes `AcceptedSnap` only.
|
||||
|
||||
use acadrust::entities::Dimension;
|
||||
use acadrust::xdata::XDataValue;
|
||||
use acadrust::EntityType;
|
||||
use glam::DVec3;
|
||||
|
||||
use crate::entities::dim_override;
|
||||
use crate::scene::viewport_ref::{AcceptedSnap, MeasurementScale, ViewportFrame};
|
||||
|
||||
use super::OpenCADStudio;
|
||||
|
||||
/// How a pending dimension should be measured.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(crate) enum DimensionMeasureSpace {
|
||||
/// Plain measurement in the space the definition points live in.
|
||||
Direct,
|
||||
/// Every measuring point came through one viewport; compensate by its
|
||||
/// scale.
|
||||
Viewport(ViewportFrame),
|
||||
/// The points came from different viewports, or from a viewport and the
|
||||
/// bare sheet. Falls back to `Direct` with a warning — never a silently
|
||||
/// wrong number.
|
||||
Mixed,
|
||||
}
|
||||
|
||||
impl OpenCADStudio {
|
||||
// ───────────────────────── PR1 SEAM: BEGIN ──────────────────────────
|
||||
|
||||
/// Record the snap accepted for one collected point of the active
|
||||
/// command.
|
||||
///
|
||||
/// PR1 replaces the body with the real snap result (which knows the
|
||||
/// snapped entity, the block path and the frame at snap time). Until then
|
||||
/// the viewport is resolved from the paper position of the accepted point,
|
||||
/// which is correct for every point that lands inside a content viewport
|
||||
/// and yields a free snap everywhere else.
|
||||
pub(crate) fn record_dimension_accepted_snap(&mut self, i: usize, point: DVec3) {
|
||||
self.sync_dimension_snap_owner(i);
|
||||
let snap = match self.tabs[i].scene.viewport_frame_at_paper_point(point) {
|
||||
Some(frame) => AcceptedSnap {
|
||||
paper_point: point,
|
||||
model_point: frame.paper_to_model(point),
|
||||
viewport: Some(frame.viewport),
|
||||
frame: Some(frame),
|
||||
source: None,
|
||||
},
|
||||
None => AcceptedSnap::free(point),
|
||||
};
|
||||
self.dim_accepted_snaps.push(snap);
|
||||
}
|
||||
|
||||
// ────────────────────────── PR1 SEAM: END ───────────────────────────
|
||||
|
||||
/// Record an accepted snap produced by the explicit object-pick path (a
|
||||
/// pick resolved through a content viewport). Independent of PR1.
|
||||
pub(crate) fn record_dimension_viewport_pick(
|
||||
&mut self,
|
||||
i: usize,
|
||||
frame: ViewportFrame,
|
||||
paper_point: DVec3,
|
||||
model_point: DVec3,
|
||||
) {
|
||||
self.sync_dimension_snap_owner(i);
|
||||
// An object pick supplies both extension origins at once; record it
|
||||
// twice so the "every measuring point came through one viewport"
|
||||
// test sees a complete set.
|
||||
for _ in 0..2 {
|
||||
self.dim_accepted_snaps.push(AcceptedSnap {
|
||||
paper_point,
|
||||
model_point,
|
||||
viewport: Some(frame.viewport),
|
||||
frame: Some(frame),
|
||||
source: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// The accepted snap for the `idx`-th collected point of the active
|
||||
/// command, if one was recorded.
|
||||
pub(crate) fn accepted_snap_for_point(&self, idx: usize) -> Option<&AcceptedSnap> {
|
||||
self.dim_accepted_snaps.get(idx)
|
||||
}
|
||||
|
||||
/// Drop recorded snaps when the active command changes, so one command
|
||||
/// never sees another's points.
|
||||
fn sync_dimension_snap_owner(&mut self, i: usize) {
|
||||
let name = self.tabs[i]
|
||||
.active_cmd
|
||||
.as_ref()
|
||||
.map(|command| command.name().to_string());
|
||||
if self.dim_accepted_snaps_owner != name {
|
||||
self.dim_accepted_snaps.clear();
|
||||
self.dim_accepted_snaps_owner = name;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn clear_dimension_accepted_snaps(&mut self) {
|
||||
self.dim_accepted_snaps.clear();
|
||||
self.dim_accepted_snaps_owner = None;
|
||||
}
|
||||
|
||||
/// Classify how the pending dimension should be measured from the snaps
|
||||
/// recorded for it.
|
||||
fn dimension_measure_space(&self, i: usize) -> DimensionMeasureSpace {
|
||||
let scene = &self.tabs[i].scene;
|
||||
// Only a dimension being created *on the sheet* can measure through a
|
||||
// viewport. Inside MSPACE the command already works in model space.
|
||||
if scene.current_layout == "Model" || scene.active_viewport.is_some() {
|
||||
return DimensionMeasureSpace::Direct;
|
||||
}
|
||||
let snaps = &self.dim_accepted_snaps;
|
||||
if snaps.is_empty() {
|
||||
return DimensionMeasureSpace::Direct;
|
||||
}
|
||||
let mut frame: Option<ViewportFrame> = None;
|
||||
let mut any_free = false;
|
||||
for snap in snaps {
|
||||
match snap.frame {
|
||||
Some(f) => match frame {
|
||||
None => frame = Some(f),
|
||||
Some(existing) if existing.viewport == f.viewport => {}
|
||||
Some(_) => return DimensionMeasureSpace::Mixed,
|
||||
},
|
||||
None => any_free = true,
|
||||
}
|
||||
}
|
||||
match (frame, any_free) {
|
||||
(None, _) => DimensionMeasureSpace::Direct,
|
||||
// A dimension-line / text placement point on bare sheet is normal
|
||||
// and expected, so only *measuring* points matter. The commands
|
||||
// collect the measuring points first, so a trailing free point is
|
||||
// the placement click; a free point before a viewport point is a
|
||||
// genuine mixed-space measurement.
|
||||
(Some(f), false) => DimensionMeasureSpace::Viewport(f),
|
||||
(Some(f), true) => {
|
||||
if self.dimension_leading_points_share_viewport(f) {
|
||||
DimensionMeasureSpace::Viewport(f)
|
||||
} else {
|
||||
DimensionMeasureSpace::Mixed
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// True when every snap up to the first free one came through `frame`'s
|
||||
/// viewport and no later snap came through a viewport.
|
||||
fn dimension_leading_points_share_viewport(&self, frame: ViewportFrame) -> bool {
|
||||
let mut seen_free = false;
|
||||
for snap in &self.dim_accepted_snaps {
|
||||
match snap.frame {
|
||||
Some(f) => {
|
||||
if seen_free || f.viewport != frame.viewport {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
None => seen_free = true,
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Apply viewport compensation to a dimension about to be committed.
|
||||
///
|
||||
/// Returns `true` when the dimension measures through a viewport, which
|
||||
/// tells the caller not to infer a paper-space association from the
|
||||
/// projected definition points (PR3 creates the real viewport
|
||||
/// associations).
|
||||
pub(crate) fn apply_viewport_dimension_measurement(
|
||||
&mut self,
|
||||
i: usize,
|
||||
entity: &mut EntityType,
|
||||
) -> bool {
|
||||
let EntityType::Dimension(dimension) = entity else {
|
||||
return false;
|
||||
};
|
||||
let is_angular = matches!(
|
||||
dimension,
|
||||
Dimension::Angular2Ln(_) | Dimension::Angular3Pt(_)
|
||||
);
|
||||
let frame = match self.dimension_measure_space(i) {
|
||||
DimensionMeasureSpace::Direct => return false,
|
||||
DimensionMeasureSpace::Mixed => {
|
||||
self.command_line.push_warning(
|
||||
crate::t!(
|
||||
"Mixed-space measurement is not supported: the points come from different viewports, or from a viewport and the sheet. Measuring on the sheet; viewport scale is not compensated."
|
||||
)
|
||||
.as_ref(),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
DimensionMeasureSpace::Viewport(frame) => frame,
|
||||
};
|
||||
|
||||
// A similarity preserves angles, so an angular dimension reads the
|
||||
// model angle already. It still measured through a viewport, so the
|
||||
// caller must skip association inference.
|
||||
if is_angular {
|
||||
return true;
|
||||
}
|
||||
|
||||
let compensation = frame.paper_to_model_length_factor();
|
||||
let style_dimlfac = self.pending_dimension_style_dimlfac(i, entity);
|
||||
if let Some(value) =
|
||||
MeasurementScale::viewport_dimlfac_override(style_dimlfac, compensation)
|
||||
{
|
||||
dim_override::set_on_entity(
|
||||
entity,
|
||||
dim_override::DIMLFAC,
|
||||
Some(XDataValue::Real(value)),
|
||||
);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// The DIMLFAC the pending dimension would inherit *without* a viewport
|
||||
/// override: its own existing override if it has one, otherwise its named
|
||||
/// dimension style's value.
|
||||
fn pending_dimension_style_dimlfac(&self, i: usize, entity: &EntityType) -> f64 {
|
||||
let EntityType::Dimension(dimension) = entity else {
|
||||
return 1.0;
|
||||
};
|
||||
if let Some(value) = dim_override::real(
|
||||
&dimension.base().common.extended_data,
|
||||
dim_override::DIMLFAC,
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
let name = dimension.base().style_name.as_str();
|
||||
self.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.dim_styles
|
||||
.iter()
|
||||
.find(|style| {
|
||||
style.name.eq_ignore_ascii_case(name)
|
||||
|| (name.trim().is_empty() && style.name.eq_ignore_ascii_case("Standard"))
|
||||
})
|
||||
.map(|style| style.dimlfac)
|
||||
.unwrap_or(1.0)
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ pub(crate) mod config;
|
||||
pub use automation::{export_headless, serve};
|
||||
mod command_driver;
|
||||
pub(crate) mod commands;
|
||||
mod dim_viewport;
|
||||
mod document;
|
||||
mod drafting_settings;
|
||||
pub(crate) mod expr_eval;
|
||||
@@ -508,6 +509,12 @@ pub(super) struct OpenCADStudio {
|
||||
cursor_size: i32,
|
||||
/// Selection-box size setting (PICKBOX, 0..=50).
|
||||
pick_box: i32,
|
||||
/// PR2: accepted snaps for the points the active dimension command has
|
||||
/// collected, in collection order. Transient command state — the committed
|
||||
/// result is an ordinary dimension entity, so undo/redo needs nothing here.
|
||||
pub(crate) dim_accepted_snaps: Vec<crate::scene::viewport_ref::AcceptedSnap>,
|
||||
/// Name of the command `dim_accepted_snaps` belongs to.
|
||||
pub(crate) dim_accepted_snaps_owner: Option<String>,
|
||||
/// Use REFEDIT rather than BEDIT when double-clicking an attribute-free block.
|
||||
double_click_block_refedit: bool,
|
||||
/// Open ATTEDIT when double-clicking a block with attributes.
|
||||
@@ -3634,6 +3641,8 @@ impl OpenCADStudio {
|
||||
zoom_factor: 60,
|
||||
cursor_size: 5,
|
||||
pick_box: 3,
|
||||
dim_accepted_snaps: Vec::new(),
|
||||
dim_accepted_snaps_owner: None,
|
||||
double_click_block_refedit: false,
|
||||
double_click_block_attedit: true,
|
||||
grip_object_limit: settings::DEFAULT_GRIP_OBJECT_LIMIT,
|
||||
|
||||
@@ -2163,6 +2163,14 @@ pub trait CadCommand: Send {
|
||||
}
|
||||
|
||||
/// Returns `true` when the command needs entity picking (hit-test) instead of point picking.
|
||||
/// PR2: this command creates a dimension whose measurement must be
|
||||
/// compensated when its points are picked through a paper-space viewport,
|
||||
/// and whose explicit object pick may land on model geometry displayed
|
||||
/// inside one. See `crate::app::dim_viewport`.
|
||||
fn measures_through_viewports(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn needs_entity_pick(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
@@ -235,6 +235,69 @@ pub fn set(doc: &mut CadDocument, handle: Handle, code: i16, value: Option<XData
|
||||
write_pairs(doc, handle, kept);
|
||||
}
|
||||
|
||||
/// Set — or, with `value: None`, clear — a single override on an entity that
|
||||
/// is **not yet in the document** (a command's pending commit), leaving the
|
||||
/// other overrides intact.
|
||||
///
|
||||
/// The document-backed [`set`] additionally registers the `ACAD` APPID; that
|
||||
/// APPID is present in every drawing OCS writes, and committing the entity
|
||||
/// goes through the normal add path, so an uncommitted write needs no
|
||||
/// document. Keeping the override on the entity means it is part of the single
|
||||
/// committed object and rides the ordinary undo/redo history.
|
||||
pub fn set_on_entity(entity: &mut EntityType, code: i16, value: Option<XDataValue>) {
|
||||
let common = entity.common_mut();
|
||||
let mut kept: Vec<(i16, XDataValue)> = pairs(&common.extended_data)
|
||||
.into_iter()
|
||||
.filter(|(c, _)| *c != code)
|
||||
.collect();
|
||||
if let Some(v) = value {
|
||||
kept.push((code, v));
|
||||
}
|
||||
|
||||
let legacy = common.extended_data.get_record("ACAD_DSTYLE").is_some()
|
||||
&& common
|
||||
.extended_data
|
||||
.get_record("ACAD")
|
||||
.is_some_and(|rec| {
|
||||
!matches!(rec.values.first(), Some(XDataValue::String(n)) if n == "DSTYLE")
|
||||
});
|
||||
let app = if legacy { "ACAD_DSTYLE" } else { "ACAD" };
|
||||
|
||||
let values = if kept.is_empty() {
|
||||
None
|
||||
} else {
|
||||
let mut vals = Vec::new();
|
||||
if !legacy {
|
||||
vals.push(XDataValue::String("DSTYLE".to_string()));
|
||||
}
|
||||
vals.push(XDataValue::ControlString("{".to_string()));
|
||||
for (code, value) in kept {
|
||||
vals.push(XDataValue::Integer16(code));
|
||||
vals.push(value);
|
||||
}
|
||||
vals.push(XDataValue::ControlString("}".to_string()));
|
||||
Some(vals)
|
||||
};
|
||||
|
||||
let mut rebuilt = ExtendedData::new();
|
||||
for record in common.extended_data.records() {
|
||||
if record.application_name != app
|
||||
&& !(!legacy && record.application_name == "ACAD_DSTYLE")
|
||||
{
|
||||
rebuilt.add_record(record.clone());
|
||||
}
|
||||
}
|
||||
if let Some(vals) = values {
|
||||
let mut record = acadrust::xdata::ExtendedDataRecord::new(app);
|
||||
for v in vals {
|
||||
record.add_value(v);
|
||||
}
|
||||
rebuilt.add_record(record);
|
||||
}
|
||||
rebuilt.raw_dwg_eed = common.extended_data.raw_dwg_eed.clone();
|
||||
common.extended_data = rebuilt;
|
||||
}
|
||||
|
||||
pub fn property_real_code(field: &str) -> Option<i16> {
|
||||
Some(match field {
|
||||
"dim_arrow_size" => DIMASZ,
|
||||
|
||||
@@ -161,6 +161,12 @@ impl CadCommand for AlignedDimensionCommand {
|
||||
CmdResult::Cancel
|
||||
}
|
||||
|
||||
/// PR2: points and object picks may come through a paper-space viewport;
|
||||
/// the committed dimension then reports the model measurement.
|
||||
fn measures_through_viewports(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn input_kind(&self) -> InputKind {
|
||||
if self.awaiting_text {
|
||||
InputKind::FreeText
|
||||
|
||||
@@ -482,6 +482,12 @@ impl CadCommand for AngularDimensionCommand {
|
||||
CmdResult::Cancel
|
||||
}
|
||||
|
||||
/// PR2: points and object picks may come through a paper-space viewport;
|
||||
/// the committed dimension then reports the model measurement.
|
||||
fn measures_through_viewports(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn on_escape(&mut self) -> CmdResult {
|
||||
CmdResult::Cancel
|
||||
}
|
||||
|
||||
@@ -132,6 +132,12 @@ impl CadCommand for DiameterDimensionCommand {
|
||||
CmdResult::Cancel
|
||||
}
|
||||
|
||||
/// PR2: points and object picks may come through a paper-space viewport;
|
||||
/// the committed dimension then reports the model measurement.
|
||||
fn measures_through_viewports(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn on_escape(&mut self) -> CmdResult {
|
||||
CmdResult::Cancel
|
||||
}
|
||||
|
||||
@@ -217,6 +217,12 @@ impl CadCommand for LinearDimensionCommand {
|
||||
CmdResult::Cancel
|
||||
}
|
||||
|
||||
/// PR2: points and object picks may come through a paper-space viewport;
|
||||
/// the committed dimension then reports the model measurement.
|
||||
fn measures_through_viewports(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn on_escape(&mut self) -> CmdResult {
|
||||
CmdResult::Cancel
|
||||
}
|
||||
|
||||
@@ -130,6 +130,12 @@ impl CadCommand for RadiusDimensionCommand {
|
||||
CmdResult::Cancel
|
||||
}
|
||||
|
||||
/// PR2: points and object picks may come through a paper-space viewport;
|
||||
/// the committed dimension then reports the model measurement.
|
||||
fn measures_through_viewports(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn on_escape(&mut self) -> CmdResult {
|
||||
CmdResult::Cancel
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user