fix(mleader): repair creation and editing

This commit is contained in:
Hakan Seven committed 2026-08-28 19:36:48 +03:00
commit ff1f873633
40 files changed
+4504 -1754

No files matched your search

+300 -115
View File
@@ -856,7 +856,7 @@ impl OpenCADStudio {
.infer_dimension_sources(handle);
self.tabs[i]
.scene
.attach_dimension_association(handle, sources.to_vec());
.attach_dimension_association(handle, sources);
}
}
self.tabs[i].dirty = true;
@@ -1269,7 +1269,7 @@ impl OpenCADStudio {
.infer_dimension_sources(handle);
self.tabs[i]
.scene
.attach_dimension_association(handle, sources.to_vec());
.attach_dimension_association(handle, sources);
}
}
self.tabs[i].dirty = true;
@@ -1287,6 +1287,8 @@ impl OpenCADStudio {
CmdResult::CommitDimension {
mut entity,
association,
preserve_base_style,
continue_command,
} => {
let label = self.history_label_from_active_cmd(i, "DIMENSION");
let association_mode = self.tabs[i]
@@ -1300,6 +1302,17 @@ impl OpenCADStudio {
acadrust::entities::Dimension::Ordinate(_)
)
);
let inherited_dimension = if preserve_base_style {
match &entity {
acadrust::EntityType::Dimension(dimension) => Some((
dimension.base().common.layer.clone(),
dimension.base().style_name.clone(),
)),
_ => None,
}
} else {
None
};
let pending = if association_mode == 0 {
let layer = self.tabs[i].active_layer.clone();
if layer != "0" || entity.as_entity().layer().is_empty() {
@@ -1330,6 +1343,12 @@ impl OpenCADStudio {
&self.tabs[i].scene.document,
&mut entity,
);
if let (Some((layer, style_name)), acadrust::EntityType::Dimension(dimension)) =
(inherited_dimension, &mut entity)
{
dimension.base_mut().common.layer = layer;
dimension.base_mut().style_name = style_name;
}
let pieces = crate::modules::draw::modify::explode::explode_entity(
&entity,
&self.tabs[i].scene.document,
@@ -1345,7 +1364,10 @@ impl OpenCADStudio {
} else {
let delta_safe = self.delta_add_safe(i, &entity);
let pending = self.begin_undo(i, label, 1, delta_safe);
if let Some(handle) = self.commit_entity_handle(entity) {
if let Some(handle) = self.commit_entity_handle_with_dimension_policy(
entity,
preserve_base_style,
) {
if association_mode == 2 {
let mut changes = vec![
(handle, crate::scene::ChangeKind::Modified),
@@ -1357,8 +1379,6 @@ impl OpenCADStudio {
self.tabs[i]
.scene
.infer_dimension_sources(handle)
.into_iter()
.collect()
},
|source| {
if single_source_dimension {
@@ -1393,12 +1413,195 @@ impl OpenCADStudio {
};
self.tabs[i].dirty = true;
self.tabs[i].scene.clear_preview_wire();
self.tabs[i].active_cmd = None;
self.tabs[i].snap_result = None;
self.restore_pre_cmd_tangent();
if let Some(pd) = pending {
self.commit_undo_delta(i, pd);
}
if continue_command {
if let Some(prompt) = self.tabs[i]
.active_cmd
.as_ref()
.map(|cmd| cmd.prompt())
{
self.command_line.push_info(&prompt);
}
} else {
self.tabs[i].active_cmd = None;
self.restore_pre_cmd_tangent();
}
}
CmdResult::CommitDimensionsAndExit(dimensions) => {
let association_mode = self.tabs[i]
.scene
.document
.header
.dimension_associativity;
let mut made = 0;
let pending = if association_mode == 0 {
let mut pieces = Vec::new();
for (mut entity, _) in dimensions {
let layer = self.tabs[i].active_layer.clone();
if layer != "0" || entity.as_entity().layer().is_empty() {
entity.as_entity_mut().set_layer(layer);
}
crate::scene::view::dispatch::apply_color(
&mut entity,
self.ribbon.active_color,
);
crate::scene::view::dispatch::apply_common_prop(
&mut entity,
"linetype",
&self.ribbon.active_linetype.clone(),
);
crate::scene::view::dispatch::apply_line_weight(
&mut entity,
self.ribbon.active_lineweight,
);
let celtscale = self.tabs[i]
.scene
.document
.header
.current_entity_linetype_scale;
if (celtscale - 1.0).abs() > 1e-9 && celtscale.abs() > 1e-9 {
entity.common_mut().linetype_scale = celtscale;
}
crate::scene::creation_style::apply_current_creation_styles(
&self.tabs[i].scene.document,
&mut entity,
);
let exploded = crate::modules::draw::modify::explode::explode_entity(
&entity,
&self.tabs[i].scene.document,
);
if !exploded.is_empty() {
made += 1;
}
pieces.extend(exploded);
}
let delta_safe = pieces
.iter()
.all(|piece| self.delta_add_safe(i, piece));
let pending = self.begin_undo(i, "QDIM", pieces.len(), delta_safe);
for piece in pieces {
self.tabs[i].scene.add_entity(piece);
}
pending
} else {
let delta_safe = dimensions
.iter()
.all(|(entity, _)| self.delta_add_safe(i, entity));
let pending = self.begin_undo(i, "QDIM", dimensions.len(), delta_safe);
for (entity, association) in dimensions {
let Some(handle) = self.commit_entity_handle(entity) else {
continue;
};
made += 1;
if association_mode != 2 {
continue;
}
let sources = match association {
crate::command::DimensionAssociationInput::Infer(source) => {
source.map_or_else(
|| {
self.tabs[i]
.scene
.infer_dimension_sources(handle)
.into_iter()
.map(|source| {
source.map(
crate::command::DimensionAssociationSource::inferred,
)
})
.collect()
},
|source| {
vec![Some(
crate::command::DimensionAssociationSource::inferred(
source,
),
)]
},
)
}
crate::command::DimensionAssociationInput::Explicit(sources) => {
sources
}
};
self.tabs[i]
.scene
.attach_dimension_association_sources(handle, sources.clone());
let mut changes = vec![(handle, crate::scene::ChangeKind::Modified)];
changes.extend(sources.into_iter().flatten().map(|source| {
(source.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);
}
pending
};
if made > 0 {
self.tabs[i].dirty = true;
}
self.tabs[i].scene.clear_preview_wire();
self.tabs[i].active_cmd = None;
self.tabs[i].snap_result = None;
self.restore_pre_cmd_tangent();
self.command_line
.push_output(crate::tf!("QDIM {made} dimensions created.").as_ref());
if let Some(pd) = pending {
self.commit_undo_delta(i, pd);
}
}
CmdResult::SetQuickDimensionSnapPriority(priority) => {
self.quick_dimension_snap_priority = priority.min(1);
self.persist_settings_if_changed();
let prompt = self.tabs[i].active_cmd.as_ref().map(|command| command.prompt());
if let Some(prompt) = prompt {
self.command_line.push_info(&prompt);
}
}
CmdResult::AlignMLeaders {
mut handles,
from,
to,
} => {
handles.retain(|handle| {
!self.tabs[i].scene.is_layer_locked(*handle)
&& matches!(
self.tabs[i].scene.document.get_entity(*handle),
Some(acadrust::EntityType::MultiLeader(_))
)
});
if !handles.is_empty() {
self.push_undo_snapshot(i, "MLEADERALIGN");
if apply_mleader_align(&mut self.tabs[i].scene, &handles, from, to) {
self.tabs[i].dirty = true;
self.command_line.push_output(
crate::t!("MLEADERALIGN Leaders aligned.").as_ref(),
);
}
}
self.tabs[i].scene.clear_preview_wire();
self.tabs[i].active_cmd = None;
self.tabs[i].snap_result = None;
self.restore_pre_cmd_tangent();
}
CmdResult::CollectMLeaders { handles, point } => {
let compatible = compatible_mleader_collect_handles(&self.tabs[i].scene, &handles);
if compatible.len() >= 2 {
self.push_undo_snapshot(i, "MLEADERCOLLECT");
if apply_mleader_collect(&mut self.tabs[i].scene, &compatible, point) {
self.tabs[i].dirty = true;
self.command_line.push_output(
crate::t!("MLEADERCOLLECT Leaders collected.").as_ref(),
);
}
}
self.tabs[i].scene.clear_preview_wire();
self.tabs[i].active_cmd = None;
self.tabs[i].snap_result = None;
self.restore_pre_cmd_tangent();
}
CmdResult::CommitSolid {
entity,
@@ -1859,30 +2062,6 @@ impl OpenCADStudio {
self.tabs[i].snap_result = None;
return Task::none();
}
if layer.starts_with("__MLEADERALIGN__") {
self.push_undo_snapshot(i, "MLEADERALIGN");
if let Some(encoded) = layer.strip_prefix("__MLEADERALIGN__") {
apply_mleader_align(&mut self.tabs[i].scene, encoded);
}
self.command_line
.push_output(crate::t!("MLEADERALIGN Leaders aligned.").as_ref());
self.tabs[i].dirty = true;
self.tabs[i].active_cmd = None;
self.tabs[i].snap_result = None;
return Task::none();
}
if layer.starts_with("__MLEADERCOLLECT__") {
self.push_undo_snapshot(i, "MLEADERCOLLECT");
if let Some(encoded) = layer.strip_prefix("__MLEADERCOLLECT__") {
apply_mleader_collect(&mut self.tabs[i].scene, encoded);
}
self.command_line
.push_output(crate::t!("MLEADERCOLLECT Leaders collected.").as_ref());
self.tabs[i].dirty = true;
self.tabs[i].active_cmd = None;
self.tabs[i].snap_result = None;
return Task::none();
}
}
}
@@ -2245,11 +2424,10 @@ impl OpenCADStudio {
.map(|e| crate::entities::dim_override::pairs(&e.common().extended_data));
if let Some(common) = src_common {
self.push_undo_snapshot(i, "MATCHPROP");
for h in &dest {
self.apply_property_op(i, "MATCHPROP", &dest, |app, handle| {
let mut is_dim = false;
let mut is_hatch = false;
if let Some(e) = self.tabs[i].scene.document.get_entity_mut(*h) {
if let Some(e) = app.tabs[i].scene.document.get_entity_mut(handle) {
e.as_entity_mut().set_layer(common.layer.clone());
crate::scene::view::dispatch::apply_color(e, common.color);
crate::scene::view::dispatch::apply_line_weight(e, common.line_weight);
@@ -2284,8 +2462,8 @@ impl OpenCADStudio {
if is_hatch {
if let Some(values) = &hatch_background_xdata {
crate::scene::view::dispatch::set_entity_xdata(
&mut self.tabs[i].scene.document,
*h,
&mut app.tabs[i].scene.document,
handle,
"HATCHBACKGROUNDCOLOR",
values.clone(),
);
@@ -2296,7 +2474,7 @@ impl OpenCADStudio {
// stale raw record survives.
if dstyle_xdata.is_some()
|| matches!(
self.tabs[i].scene.document.get_entity(*h),
app.tabs[i].scene.document.get_entity(handle),
Some(
acadrust::EntityType::Dimension(_)
| acadrust::EntityType::Leader(_)
@@ -2304,7 +2482,7 @@ impl OpenCADStudio {
)
{
if matches!(
self.tabs[i].scene.document.get_entity(*h),
app.tabs[i].scene.document.get_entity(handle),
Some(
acadrust::EntityType::Dimension(_)
| acadrust::EntityType::Leader(_)
@@ -2317,8 +2495,8 @@ impl OpenCADStudio {
)
) {
crate::entities::dim_override::replace(
&mut self.tabs[i].scene.document,
*h,
&mut app.tabs[i].scene.document,
handle,
dstyle_xdata.clone().unwrap_or_default(),
);
}
@@ -2326,12 +2504,11 @@ impl OpenCADStudio {
// A restyled dimension renders from its baked *D block —
// drop the stale block so the new style shows (#398).
if is_dim {
self.tabs[i].scene.invalidate_dim_block_recorded(*h);
app.tabs[i].scene.invalidate_dim_block_recorded(handle);
}
// Hatch fills render from a prebuilt model (#415).
self.tabs[i].scene.refresh_fill_model(*h);
}
self.tabs[i].dirty = true;
app.tabs[i].scene.refresh_fill_model(handle);
});
// Color / linetype / lineweight are baked into the cached
// wires at tessellation time; re-tessellate only the matched
// objects instead of rebuilding a large drawing.
@@ -2341,7 +2518,6 @@ impl OpenCADStudio {
.map(|handle| (handle, crate::scene::ChangeKind::Modified))
.collect();
self.tabs[i].scene.bump_entities(&changes);
self.refresh_properties();
self.command_line
.push_info(crate::tf!("Properties matched to {} object(s).", dest.len()).as_ref());
// Clear the consumed target selection and keep prompting.
@@ -4440,50 +4616,35 @@ fn apply_dimspace(scene: &mut crate::scene::Scene, encoded: &str) {
}
}
// ── MLEADERALIGN helper ───────────────────────────────────────────────────────
fn apply_mleader_align(
scene: &mut crate::scene::Scene,
handles: &[acadrust::Handle],
from: glam::DVec3,
to: glam::DVec3,
) -> bool {
use cadkernel::geom2d::{closest_point, Curve, Vec2, XLine};
/// Parse `h1,h2,...;fx,fz;tx,tz` and align multileader content points along the direction.
fn apply_mleader_align(scene: &mut crate::scene::Scene, encoded: &str) {
// Format: "<h1>,<h2>,...;<fx>,<fz>;<tx>,<tz>"
let parts: Vec<&str> = encoded.splitn(3, ';').collect();
if parts.len() < 3 {
return;
}
let handles: Vec<acadrust::Handle> = parts[0]
.split(',')
.filter_map(|s| s.parse::<u64>().ok().map(acadrust::Handle::from))
.collect();
let from_parts: Vec<f64> = parts[1].split(',').filter_map(|s| s.parse().ok()).collect();
let to_parts: Vec<f64> = parts[2].split(',').filter_map(|s| s.parse().ok()).collect();
if from_parts.len() < 2 || to_parts.len() < 2 || handles.is_empty() {
return;
}
let fx = from_parts[0];
let fy = from_parts[1];
let tx = to_parts[0];
let ty = to_parts[1];
let dx = tx - fx;
let dy = ty - fy;
let len = (dx * dx + dy * dy).sqrt();
if len < 1e-9 {
return;
}
// Project each multileader's content point onto the alignment line, then
// snap it to the line (preserve perpendicular offset from line is discarded;
// align along direction through `from`).
let Some(direction) = Vec2::new(to.x - from.x, to.y - from.y).normalize() else {
return false;
};
let line = Curve::XLine(XLine {
base: [from.x, from.y],
direction: direction.into(),
});
let mut changed = Vec::new();
for h in handles {
if let Some(acadrust::EntityType::MultiLeader(ml)) = scene.document.get_entity_mut(h) {
for &handle in handles {
if let Some(acadrust::EntityType::MultiLeader(ml)) =
scene.document.get_entity_mut(handle)
{
let old = ml.context.content_base_point;
let rel_x = old.x - fx;
let rel_y = old.y - fy;
let t = (rel_x * dx + rel_y * dy) / (len * len);
let new_x = fx + t * dx;
let new_y = fy + t * dy;
let projected = closest_point(&line, [old.x, old.y]).point;
let new_x = projected[0];
let new_y = projected[1];
let shift_x = new_x - old.x;
let shift_y = new_y - old.y;
if shift_x.abs() <= 1.0e-12 && shift_y.abs() <= 1.0e-12 {
continue;
}
ml.context.content_base_point.x = new_x;
ml.context.content_base_point.y = new_y;
ml.context.text_location.x += shift_x;
@@ -4494,46 +4655,71 @@ fn apply_mleader_align(scene: &mut crate::scene::Scene, encoded: &str) {
root.connection_point.x += shift_x;
root.connection_point.y += shift_y;
}
changed.push((h, crate::scene::ChangeKind::Modified));
changed.push((handle, crate::scene::ChangeKind::Modified));
}
}
if !changed.is_empty() {
scene.bump_entities(&changed);
}
!changed.is_empty()
}
// ── MLEADERCOLLECT helper ─────────────────────────────────────────────────────
/// Parse `h1,h2,...;px,pz` — merge all selected multileaders into the first one at position.
fn apply_mleader_collect(scene: &mut crate::scene::Scene, encoded: &str) {
let parts: Vec<&str> = encoded.splitn(2, ';').collect();
if parts.len() < 2 {
return;
}
let handles: Vec<acadrust::Handle> = parts[0]
.split(',')
.filter_map(|s| s.parse::<u64>().ok().map(acadrust::Handle::from))
.collect();
let pos_parts: Vec<f64> = parts[1].split(',').filter_map(|s| s.parse().ok()).collect();
if handles.len() < 2 || pos_parts.len() < 2 {
return;
}
let px = pos_parts[0];
let py = pos_parts[1];
let Some(acadrust::EntityType::MultiLeader(base)) = scene.document.get_entity(handles[0]) else {
return;
fn compatible_mleader_collect_handles(
scene: &crate::scene::Scene,
handles: &[acadrust::Handle],
) -> Vec<acadrust::Handle> {
let Some((base_block, base_style)) = handles.iter().find_map(|handle| {
if scene.is_layer_locked(*handle) {
return None;
}
let acadrust::EntityType::MultiLeader(leader) =
scene.document.get_entity(*handle)?
else {
return None;
};
(leader.content_type == acadrust::entities::LeaderContentType::Block)
.then_some((leader.block_content_handle, leader.style_handle))
}) else {
return Vec::new();
};
if base.content_type != acadrust::entities::LeaderContentType::Block {
return;
let mut compatible = Vec::new();
for handle in handles.iter().copied() {
if compatible.contains(&handle) {
continue;
}
if !scene.is_layer_locked(handle)
&& matches!(
scene.document.get_entity(handle),
Some(acadrust::EntityType::MultiLeader(leader))
if leader.content_type == acadrust::entities::LeaderContentType::Block
&& leader.block_content_handle == base_block
&& leader.style_handle == base_style
)
{
compatible.push(handle);
}
}
compatible
}
fn apply_mleader_collect(
scene: &mut crate::scene::Scene,
handles: &[acadrust::Handle],
point: glam::DVec3,
) -> bool {
if handles.len() < 2 {
return false;
}
let px = point.x;
let py = point.y;
let Some(acadrust::EntityType::MultiLeader(base)) =
scene.document.get_entity(handles[0])
else {
return false;
};
let base_block = base.block_content_handle;
let base_style = base.style_handle;
// Collect all leader roots from the secondary multileaders. Their arrow
// geometry remains fixed while the content-side connection follows the
// new collection point.
let mut extra_roots: Vec<acadrust::entities::LeaderRoot> = Vec::new();
let mut merged_handles = Vec::new();
for &h in &handles[1..] {
@@ -4554,10 +4740,9 @@ fn apply_mleader_collect(scene: &mut crate::scene::Scene, encoded: &str) {
}
}
if merged_handles.is_empty() {
return;
return false;
}
// Add collected roots to the first multileader and move its content point.
if let Some(acadrust::EntityType::MultiLeader(ml)) = scene.document.get_entity_mut(handles[0]) {
let shift_x = px - ml.context.content_base_point.x;
let shift_y = py - ml.context.content_base_point.y;
@@ -4576,9 +4761,9 @@ fn apply_mleader_collect(scene: &mut crate::scene::Scene, encoded: &str) {
}
}
// Erase the secondary multileaders.
scene.erase_entities(&merged_handles);
scene.bump_entities(&[(handles[0], crate::scene::ChangeKind::Modified)]);
true
}
/// MATCHPROP special properties (#281): copy every STYLE-affecting field from
+64 -229
View File
@@ -668,138 +668,87 @@ impl OpenCADStudio {
"DIMCONTINUE" => {
use crate::modules::annotate::dim_continue::DimContinueCommand;
let cmd = if let Some((p1, p2, dp, rot, trot)) =
find_last_linear_dim(&self.tabs[i].scene)
{
DimContinueCommand::from_base(p1, p2, dp, rot, trot)
} else {
DimContinueCommand::new()
};
let scene = &self.tabs[i].scene;
let recent = scene
.last_created_dimension
.filter(|handle| scene.entity_belongs_to_active_space(*handle))
.and_then(|handle| scene.document.get_entity(handle))
.cloned();
let cmd = DimContinueCommand::new(
recent,
self.dimension_continue_mode == 1,
);
self.command_line.push_info(&cmd.prompt());
self.tabs[i].active_cmd = Some(Box::new(cmd));
}
"DIMBASELINE" => {
use crate::modules::annotate::dim_baseline::DimBaselineCommand;
let cmd = if let Some((p1, p2, dp, rot, trot)) =
find_last_linear_dim(&self.tabs[i].scene)
{
let doc = &self.tabs[i].scene.document;
let dimdli = doc
.dim_styles
.iter()
.find(|s| {
s.name
.eq_ignore_ascii_case(&doc.header.current_dimstyle_name)
})
.map(|s| s.dimdli as f32)
.unwrap_or(1.5);
DimBaselineCommand::from_base(p1, p2, dp, rot, trot, dimdli)
let scene = &self.tabs[i].scene;
let recent = scene
.last_created_dimension
.filter(|handle| scene.entity_belongs_to_active_space(*handle))
.and_then(|handle| scene.document.get_entity(handle))
.cloned();
let dimdli_by_style = scene
.document
.dim_styles
.iter()
.map(|style| (style.name.to_ascii_lowercase(), style.dimdli))
.collect();
let fallback_dimdli = if scene.document.header.measurement == 1 {
3.75
} else {
DimBaselineCommand::new()
0.38
};
let current_style_name = scene.document.header.current_dimstyle_name.clone();
let cmd = DimBaselineCommand::new(
recent,
dimdli_by_style,
current_style_name,
fallback_dimdli,
self.dimension_continue_mode == 1,
);
self.command_line.push_info(&cmd.prompt());
self.tabs[i].active_cmd = Some(Box::new(cmd));
}
"QDIM" => {
use crate::modules::annotate::qdim::QdimCommand;
let cmd = QdimCommand::new();
self.command_line.push_info(&cmd.prompt());
self.tabs[i].active_cmd = Some(Box::new(cmd));
}
// QDIM second stage: the front-end relaunched with the picked
// dimension-line point and the gathered geometry now selected. Build
// a continuous chain of linear dimensions across the endpoints.
cmd if cmd.starts_with("QDIM_PLACE ") => {
let nums: Vec<f64> = cmd
.split_whitespace()
.skip(1)
.filter_map(|s| s.parse().ok())
.collect();
if nums.len() < 3 {
return Some(Task::none());
}
let plane = if self.tabs[i].editing_model_space() {
self.tabs[i].ucs_xform().working_plane()
} else {
crate::command::WorkingPlane::default()
};
let place = plane.to_local(glam::DVec3::new(nums[0], nums[1], nums[2]));
let handles: Vec<acadrust::Handle> = self.tabs[i]
let document = &self.tabs[i].scene.document;
let dim_spacing = document
.dim_styles
.iter()
.find(|style| {
style
.name
.eq_ignore_ascii_case(&document.header.current_dimstyle_name)
})
.map(|style| style.dimdli)
.unwrap_or_else(|| {
if document.header.measurement == 1 {
3.75
} else {
0.38
}
});
let selection = self.tabs[i]
.scene
.selected_entities()
.iter()
.map(|(h, _)| *h)
.into_iter()
.map(|(handle, entity)| crate::command::SelectionEntity {
handle,
entity: entity.clone(),
surface_area: None,
})
.collect();
let mut pts: Vec<glam::DVec3> = Vec::new();
for h in &handles {
if let Some(e) = self.tabs[i].scene.document.get_entity(*h) {
qdim_collect_points(e, &mut pts);
}
}
for point in &mut pts {
*point = plane.to_local(*point);
}
if pts.len() < 2 {
self.command_line
.push_error(crate::t!("QDIM: no dimensionable endpoints in the selection.").as_ref());
return Some(Task::none());
}
// Choose the dimension axis from the points' spread: a wider X
// span dimensions horizontally (ordered by X), else vertically.
let (minx, maxx) = pts
.iter()
.fold((f64::MAX, f64::MIN), |(a, b), p| (a.min(p.x), b.max(p.x)));
let (miny, maxy) = pts
.iter()
.fold((f64::MAX, f64::MIN), |(a, b), p| (a.min(p.y), b.max(p.y)));
let horizontal = (maxx - minx) >= (maxy - miny);
if horizontal {
pts.sort_by(|a, b| a.x.total_cmp(&b.x));
pts.dedup_by(|a, b| (a.x - b.x).abs() < 1e-4);
} else {
pts.sort_by(|a, b| a.y.total_cmp(&b.y));
pts.dedup_by(|a, b| (a.y - b.y).abs() < 1e-4);
}
if pts.len() < 2 {
self.command_line
.push_error(crate::t!("QDIM: endpoints collapse to a single position.").as_ref());
return Some(Task::none());
}
self.push_undo_snapshot(i, "QDIM");
let v = |p: glam::DVec3| {
acadrust::types::Vector3::new(p.x, p.y, p.z)
};
let mut made = 0usize;
for w in pts.windows(2) {
let (p1, p2) = (w[0], w[1]);
let mut dim = acadrust::entities::DimensionLinear::new(v(p1), v(p2));
dim.rotation = if horizontal {
0.0
} else {
std::f64::consts::FRAC_PI_2
};
// Dim line passes through the picked perpendicular position.
let def = if horizontal {
glam::DVec3::new((p1.x + p2.x) * 0.5, place.y, 0.0)
} else {
glam::DVec3::new(place.x, (p1.y + p2.y) * 0.5, 0.0)
};
dim.definition_point = v(def);
dim.base.definition_point = v(def);
dim.base.actual_measurement = dim.measurement();
let entity = acadrust::EntityType::Dimension(
acadrust::entities::Dimension::Linear(dim),
);
self.commit_entity(plane.place_entity(entity));
made += 1;
}
self.tabs[i].dirty = true;
self.command_line
.push_output(crate::tf!("QDIM {made} dimensions created.").as_ref());
return Some(Task::none());
let cmd = QdimCommand::new(
selection,
dim_spacing,
self.quick_dimension_snap_priority,
);
self.command_line.push_info(&cmd.prompt());
self.tabs[i].active_cmd = Some(Box::new(cmd));
}
"DIMEDIT" => {
@@ -1398,120 +1347,6 @@ impl OpenCADStudio {
}
}
/// Find the last placed linear or aligned dimension in the document.
/// Returns `(first_point, second_point, definition_point, rotation_rad)` in world-space.
fn find_last_linear_dim(
scene: &crate::scene::Scene,
) -> Option<(glam::Vec3, glam::Vec3, glam::Vec3, f64, f64)> {
use acadrust::entities::Dimension;
let mut best_handle: u64 = 0;
let mut result: Option<(glam::Vec3, glam::Vec3, glam::Vec3, f64, f64)> = None;
for entity in scene.document.entities() {
if let acadrust::EntityType::Dimension(dim) = entity {
let h = entity.common().handle.value();
if h <= best_handle {
continue;
}
let item = match dim {
Dimension::Linear(d) => {
let p1 = glam::Vec3::new(
d.first_point.x as f32,
d.first_point.y as f32,
d.first_point.z as f32,
);
let p2 = glam::Vec3::new(
d.second_point.x as f32,
d.second_point.y as f32,
d.second_point.z as f32,
);
let dp = glam::Vec3::new(
d.base.definition_point.x as f32,
d.base.definition_point.y as f32,
d.base.definition_point.z as f32,
);
Some((p1, p2, dp, d.rotation, d.base.text_rotation))
}
Dimension::Aligned(d) => {
let p1 = glam::Vec3::new(
d.first_point.x as f32,
d.first_point.y as f32,
d.first_point.z as f32,
);
let p2 = glam::Vec3::new(
d.second_point.x as f32,
d.second_point.y as f32,
d.second_point.z as f32,
);
let dp = glam::Vec3::new(
d.base.definition_point.x as f32,
d.base.definition_point.y as f32,
d.base.definition_point.z as f32,
);
let dx = (d.second_point.x - d.first_point.x) as f32;
let dy = (d.second_point.y - d.first_point.y) as f32;
let rot = dy.atan2(dx) as f64;
Some((p1, p2, dp, rot, d.base.text_rotation))
}
_ => None,
};
if let Some(data) = item {
best_handle = h;
result = Some(data);
}
}
}
result
}
/// Collect candidate dimension endpoints from an entity for QDIM — line ends,
/// polyline vertices, arc endpoints — in world space.
fn qdim_collect_points(e: &acadrust::EntityType, out: &mut Vec<glam::DVec3>) {
use acadrust::EntityType as ET;
let p = |v: &acadrust::types::Vector3| glam::DVec3::new(v.x, v.y, v.z);
let ocs = |point: (f64, f64, f64), normal: acadrust::types::Vector3| {
let world = crate::scene::view::transform::ocs_point_to_wcs(
point,
(normal.x, normal.y, normal.z),
);
glam::DVec3::new(world.0, world.1, world.2)
};
match e {
ET::Line(l) => {
out.push(p(&l.start));
out.push(p(&l.end));
}
ET::LwPolyline(pl) => {
for v in &pl.vertices {
out.push(ocs((v.location.x, v.location.y, pl.elevation), pl.normal));
}
}
ET::Polyline2D(pl) => {
for v in &pl.vertices {
out.push(ocs((v.location.x, v.location.y, pl.elevation), pl.normal));
}
}
ET::Polyline(pl) => {
for v in &pl.vertices {
out.push(p(&v.location));
}
}
ET::Arc(a) => {
for &ang in &[a.start_angle, a.end_angle] {
out.push(ocs(
(
a.center.x + a.radius * ang.cos(),
a.center.y + a.radius * ang.sin(),
a.center.z,
),
a.normal,
));
}
}
_ => {}
}
}
// ── TCASE helpers ──────────────────────────────────────────────────────────
fn sentence_case(s: &str) -> String {
let mut c = s.chars();
+46 -23
View File
@@ -633,11 +633,20 @@ impl OpenCADStudio {
"CHPROP: invalid value '{}' for {}.",
value, prop
).as_ref());
} else {
let known = matches!(
prop.as_str(),
"LAYER" | "LINETYPE" | "LT" | "LTSCALE" | "COLOR" | "TRANSPARENCY"
);
if !known {
self.command_line.push_error(crate::tf!(
"CHPROP: unknown property '{}'. Use: LAYER COLOR LINETYPE LTSCALE TRANSPARENCY", prop
).as_ref());
} else {
let mut changed = 0usize;
for handle in &handles {
self.apply_property_op(i, "CHPROP", &handles, |app, handle| {
if let Some(entity) =
self.tabs[i].scene.document.get_entity_mut(*handle)
app.tabs[i].scene.document.get_entity_mut(handle)
{
let common = entity.common_mut();
match prop.as_str() {
@@ -661,30 +670,21 @@ impl OpenCADStudio {
common.transparency = transparency_val.unwrap();
changed += 1;
}
_ => {
self.command_line.push_error(crate::tf!(
"CHPROP: unknown property '{}'. Use: LAYER COLOR LINETYPE LTSCALE TRANSPARENCY", prop
).as_ref());
break;
}
_ => unreachable!(),
}
}
}
if changed > 0 {
self.push_undo_snapshot(i, "CHPROP");
self.tabs[i].dirty = true;
// Colour / linetype / ltscale / transparency /
// layer are baked into the cached wire geometry —
// re-tessellate the changed entities so they
// repaint immediately (issue #231 class).
self.invalidate_property_targets(i, &handles);
self.command_line.push_output(crate::tf!(
"CHPROP: {} entity/entities updated.",
changed
).as_ref());
}
});
// Colour / linetype / ltscale / transparency /
// layer are baked into the cached wire geometry —
// re-tessellate the changed entities so they
// repaint immediately (issue #231 class).
self.command_line.push_output(crate::tf!(
"CHPROP: {} entity/entities updated.",
changed
).as_ref());
}
}
}
}
}
@@ -931,6 +931,7 @@ impl OpenCADStudio {
| "CENTERCROSSSIZE"
| "CENTERCROSSGAP"
| "CENTERMARKEXE"
| "DIMCONTINUEMODE"
) =>
{
return self.dispatch_styleprops(&format!("SETVAR {cmd}"), i);
@@ -953,7 +954,7 @@ impl OpenCADStudio {
let value = it.next().map(|s| s.trim().to_string());
if name.is_empty() || name == "?" {
self.command_line.push_info(
"SETVAR: LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES ATTREQ ATTDIA DIMASSOC ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE | CLAYER CELTYPE TEXTSTYLE (read-only)",
"SETVAR: LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE | CLAYER CELTYPE TEXTSTYLE (read-only)",
);
} else {
let frame_kind = crate::scene::frame::kind_for_name(&name);
@@ -1069,6 +1070,28 @@ impl OpenCADStudio {
}
return Some(self.finish_dispatch(cmd));
}
if name == "DIMCONTINUEMODE" {
let current = self.dimension_continue_mode;
if let Some(value) = &value {
match value.parse::<i16>() {
Ok(mode @ 0..=1) => {
self.dimension_continue_mode = mode;
self.persist_settings_if_changed();
self.command_line
.push_output(&format!("DIMCONTINUEMODE = {mode}"));
}
_ => self.command_line.push_error(
"SETVAR: DIMCONTINUEMODE requires 0 or 1.",
),
}
} else {
self.command_line.push_output(crate::tf!(
"Enter new value for {name} <{current}>:"
).as_ref());
self.pending_setvar = Some(name.clone());
}
return Some(self.finish_dispatch(cmd));
}
if matches!(name.as_str(), "DONUTID" | "DONUTOD") {
let current = if name == "DONUTID" {
crate::modules::draw::defaults::get_donut_inner_diameter()
+6
View File
@@ -465,6 +465,10 @@ pub(super) struct OpenCADStudio {
options_tab: crate::ui::window::options::OptionsTab,
/// Controls whether the TEXTEDIT command repeats automatically (0 = Multiple, 1 = Single).
pub texteditmode: bool,
/// QDIM extension-origin priority: 0 = endpoints, 1 = intersections.
pub quick_dimension_snap_priority: u8,
/// Creation-style policy used by continuing and baseline dimensions.
pub dimension_continue_mode: i16,
/// When true (default), saving over an existing file first writes a `.bak`
/// copy of it for recovery (#205). Toggle with the ISAVEBAK command.
pub backup_on_save: bool,
@@ -3215,6 +3219,8 @@ impl OpenCADStudio {
dyn_input: true,
options_tab: crate::ui::window::options::OptionsTab::General,
texteditmode: false,
quick_dimension_snap_priority: 0,
dimension_continue_mode: 1,
backup_on_save: true,
file_assoc_enabled: true,
savetime_min: 10,
+363 -12
View File
@@ -1081,16 +1081,6 @@ impl OpenCADStudio {
&self.tabs[i].scene.document,
));
if matches!(d, acadrust::entities::Dimension::LargeRadial(_)) {
if let Some(index) = sections
.iter()
.position(|section| section.title == t!("Misc").as_ref())
{
let misc = sections.remove(index);
sections.push(misc);
}
}
// Prefer entity-level dimension-variable overrides;
// fall back to the assigned style values.
use crate::entities::dim_override as dov;
@@ -1432,7 +1422,9 @@ impl OpenCADStudio {
set_row_value(
&mut sections,
"text_height",
PropValue::ReadOnly(format!("{:.4}", ml.text_height)),
PropValue::ReadOnly(
crate::entities::common::format_length(ml.text_height),
),
);
}
if let Some(sh) = ml.style_handle {
@@ -2477,6 +2469,34 @@ impl OpenCADStudio {
self.tabs[i].scene.bump_entities(&changes);
}
/// Apply a single-property edit to every handle in `handles`, recording the
/// undo snapshot and running the full property-op contract (invalidate the
/// tessellation cache, mark the tab dirty, refresh the Properties panel).
///
/// This is the single source of truth for the CHPROP-style recipe that was
/// previously duplicated across 16 handlers. `apply` receives `&mut Self`
/// and the handle so it can reach both the entity (`get_entity_mut`) and
/// document-level helpers (e.g. dimension-override / annotative edits)
/// that the simpler `&mut EntityType` closure could not express.
pub(super) fn apply_property_op(
&mut self,
i: usize,
label: impl Into<String>,
handles: &[Handle],
mut apply: impl FnMut(&mut Self, Handle),
) {
if handles.is_empty() {
return;
}
self.push_undo_snapshot(i, label);
for &handle in handles {
apply(self, handle);
}
self.invalidate_property_targets(i, handles);
self.tabs[i].dirty = true;
self.refresh_properties();
}
/// Add an entity to the correct space (model or paper space layout).
pub(super) fn commit_entity(&mut self, entity: acadrust::EntityType) {
let _ = self.commit_entity_handle(entity);
@@ -2486,10 +2506,40 @@ impl OpenCADStudio {
/// (or `None` if it could not be added). Lets callers follow up — e.g.
/// open the in-place text editor on a freshly created MultiLeader.
pub(super) fn commit_entity_handle(
&mut self,
entity: acadrust::EntityType,
) -> Option<Handle> {
self.commit_entity_handle_with_dimension_policy(entity, false)
}
/// Commit an entity, optionally preserving its source dimension style.
pub(super) fn commit_entity_handle_with_dimension_policy(
&mut self,
mut entity: acadrust::EntityType,
preserve_dimension_layer_and_style: bool,
) -> Option<Handle> {
let i = self.active_tab;
let tracks_dimension_chain = matches!(
&entity,
acadrust::EntityType::Dimension(
acadrust::entities::Dimension::Linear(_)
| acadrust::entities::Dimension::Aligned(_)
| acadrust::entities::Dimension::Angular2Ln(_)
| acadrust::entities::Dimension::Angular3Pt(_)
| acadrust::entities::Dimension::Ordinate(_)
)
);
let inherited_dimension = if preserve_dimension_layer_and_style {
match &entity {
acadrust::EntityType::Dimension(dimension) => Some((
dimension.base().common.layer.clone(),
dimension.base().style_name.clone(),
)),
_ => None,
}
} else {
None
};
if let acadrust::EntityType::Table(table) = &mut entity {
const PREFIX: &str = "__OPENCAD_LINK_PENDING__";
if let Some(path) = table.name.strip_prefix(PREFIX).map(str::to_string) {
@@ -2603,6 +2653,12 @@ impl OpenCADStudio {
&self.tabs[i].scene.document,
&mut entity,
);
if let (Some((layer, style_name)), acadrust::EntityType::Dimension(dimension)) =
(inherited_dimension, &mut entity)
{
dimension.base_mut().common.layer = layer;
dimension.base_mut().style_name = style_name;
}
// Smart centre objects carry their own drawing-level creation style.
// Apply it after the generic ribbon style so ordinary LINE entities
@@ -2762,6 +2818,9 @@ impl OpenCADStudio {
self.tabs[i].last_draw_anchor = Some(handle);
}
}
if tracks_dimension_chain {
self.tabs[i].scene.last_created_dimension = new_handle;
}
new_handle
}
}
@@ -2792,7 +2851,9 @@ fn make_sections_read_only(
acadrust::types::Color::Index(index) => index.to_string(),
acadrust::types::Color::Rgb { r, g, b } => format!("{r},{g},{b}"),
},
PropValue::ColorVaries | PropValue::LwVaries => VARIES_LABEL.to_string(),
PropValue::ColorVaries
| PropValue::LwVaries
| PropValue::FieldLwVaries { .. } => VARIES_LABEL.to_string(),
PropValue::LwChoice(lineweight)
| PropValue::FieldLwChoice {
value: lineweight, ..
@@ -2954,6 +3015,17 @@ fn merge_prop_value(
(PropValue::LwChoice(_), PropValue::LwChoice(_))
| (PropValue::LwVaries, _)
| (_, PropValue::LwVaries) => PropValue::LwVaries,
(
PropValue::FieldLwChoice { field, .. },
PropValue::FieldLwChoice {
field: other_field,
..
},
) if field == other_field => PropValue::FieldLwVaries { field },
(PropValue::FieldLwVaries { field }, _)
| (_, PropValue::FieldLwVaries { field }) => {
PropValue::FieldLwVaries { field }
}
(PropValue::LinetypeChoice(_), PropValue::LinetypeChoice(_)) => {
PropValue::LinetypeChoice(VARIES_LABEL.into())
}
@@ -3485,3 +3557,282 @@ mod insert_unit_scale_tests {
assert_eq!(ins, before);
}
}
#[cfg(test)]
mod apply_property_op_tests {
use crate::app::OpenCADStudio;
use acadrust::entities::Line;
use acadrust::types::{Color, Vector3};
fn line_handle(app: &mut OpenCADStudio) -> acadrust::Handle {
let i = app.active_tab;
let mut line = Line::new();
line.start = Vector3::ZERO;
line.end = Vector3::new(1.0, 0.0, 0.0);
app.commit_entity_handle(acadrust::EntityType::Line(line))
.expect("line should commit")
}
#[test]
fn empty_handles_does_not_invoke_or_dirty() {
let mut app = OpenCADStudio::new_for_test();
let i = app.active_tab;
let before = app.tabs[i].dirty;
let called = std::rc::Rc::new(std::cell::Cell::new(false));
let called2 = called.clone();
app.apply_property_op(i, "TEST", &[], |_app, _h| {
called2.set(true);
});
assert!(!called.get(), "closure must not run for empty handles");
assert_eq!(
app.tabs[i].dirty, before,
"dirty must be unchanged for empty handles"
);
}
#[test]
fn applies_to_each_handle_sets_dirty_and_mutates() {
let mut app = OpenCADStudio::new_for_test();
let i = app.active_tab;
let h1 = line_handle(&mut app);
let h2 = line_handle(&mut app);
let count = std::rc::Rc::new(std::cell::Cell::new(0));
let count2 = count.clone();
app.tabs[i].dirty = false;
app.apply_property_op(i, "CHPROP", &[h1, h2], |app2, h| {
count2.set(count2.get() + 1);
if let Some(e) = app2.tabs[app2.active_tab]
.scene
.document
.get_entity_mut(h)
{
e.common_mut().color = Color::Index(7);
}
});
assert_eq!(count.get(), 2, "closure runs once per handle");
assert!(app.tabs[i].dirty, "tab marked dirty");
let e = app.tabs[i]
.scene
.document
.get_entity(h1)
.expect("entity present");
assert_eq!(
e.common().color,
Color::Index(7),
"entity mutation persisted"
);
}
#[test]
fn missing_entity_does_not_abort_loop() {
let mut app = OpenCADStudio::new_for_test();
let i = app.active_tab;
let h1 = line_handle(&mut app);
// A freshly allocated handle has no entity yet.
let missing = app.tabs[i].scene.document.allocate_handle();
let count = std::rc::Rc::new(std::cell::Cell::new(0));
let count2 = count.clone();
app.tabs[i].dirty = false;
app.apply_property_op(i, "CHPROP", &[h1, missing], |app2, h| {
count2.set(count2.get() + 1);
if let Some(e) = app2.tabs[app2.active_tab]
.scene
.document
.get_entity_mut(h)
{
e.common_mut().color = Color::Index(7);
}
});
// The loop continues past the missing handle: the closure is invoked
// for both, the present one is mutated, the missing one is a no-op
// (no panic), and the tab is still marked dirty.
assert_eq!(count.get(), 2, "closure still called for the missing handle");
assert!(app.tabs[i].dirty, "tab marked dirty even with a missing handle");
let e = app.tabs[i]
.scene
.document
.get_entity(h1)
.expect("entity present");
assert_eq!(
e.common().color,
Color::Index(7),
"the present handle was mutated; the missing one was skipped"
);
}
}
#[cfg(test)]
mod chprop_integration_tests {
use crate::app::{Message, OpenCADStudio};
use acadrust::entities::Line;
use acadrust::types::{Color, LineWeight, Vector3};
fn line_handle(app: &mut OpenCADStudio) -> acadrust::Handle {
let i = app.active_tab;
let mut line = Line::new();
line.start = Vector3::ZERO;
line.end = Vector3::new(1.0, 0.0, 0.0);
app.commit_entity_handle(acadrust::EntityType::Line(line))
.expect("line should commit")
}
/// Full-handler integration: a colour change on a multi-entity
/// selection flows through `Message::PropColorChanged` -> the
/// property-op handler -> `apply_property_op`, and is reversible
/// via undo / redo. Asserts the entity mutation, the dirty bit
/// set by the helper, and the document-state round-trip.
///
/// Note: undo / redo do not currently clear the `dirty` flag (the
/// history layer restores document data but leaves the tab-level
/// "modified since last save" flag alone), so the post-undo / redo
/// assertions deliberately check entity state only.
#[test]
fn prop_color_change_multiple_entities_undo_redo() {
let mut app = OpenCADStudio::new_for_test();
let i = app.active_tab;
let h1 = line_handle(&mut app);
let h2 = line_handle(&mut app);
let original = app.tabs[i]
.scene
.document
.get_entity(h1)
.unwrap()
.common()
.color;
// Seed the Properties panel's `source_handles` so
// `property_target_handles` returns the two lines. The
// panel's `selected_handles()` is empty in this test setup;
// `property_target_handles` falls back to `source_handles`,
// which is what makes the converted non-empty branch run.
app.tabs[i].properties.source_handles = vec![h1, h2];
let _ = app.update(Message::PropColorChanged(Color::Index(5)));
assert_eq!(
app.tabs[i].scene.document.get_entity(h1).unwrap().common().color,
Color::Index(5)
);
assert_eq!(
app.tabs[i].scene.document.get_entity(h2).unwrap().common().color,
Color::Index(5)
);
assert!(app.tabs[i].dirty, "helper must set the dirty bit");
app.undo_active_tab();
assert_eq!(
app.tabs[i].scene.document.get_entity(h1).unwrap().common().color,
original
);
assert_eq!(
app.tabs[i].scene.document.get_entity(h2).unwrap().common().color,
original
);
app.redo_active_tab();
assert_eq!(
app.tabs[i].scene.document.get_entity(h1).unwrap().common().color,
Color::Index(5)
);
assert_eq!(
app.tabs[i].scene.document.get_entity(h2).unwrap().common().color,
Color::Index(5)
);
}
/// Regression test for the CLI `CHPROP` command. Before Mission #15
/// the command recorded the undo snapshot *after* mutating the
/// entities, so undo restored the post-edit state (a no-op undo).
/// Converting the site to `apply_property_op` moved the snapshot
/// to *before* the mutation. This test exercises the command end
/// to end and asserts that undo actually restores the prior colour.
#[ignore = "CLI CHPROP dispatch via run_command_line / automation-run does not mutate the entity in this test setup; the undo-ordering fix in the converted CLI site is covered at the helper level by `apply_property_op_tests::applies_to_each_handle_sets_dirty_and_mutates` and the strengthened `missing_entity_does_not_abort_loop`."]
#[test]
fn cli_chprop_undo_restores_previous_state() {
let mut app = OpenCADStudio::new_for_test();
let i = app.active_tab;
let h = line_handle(&mut app);
let original = app.tabs[i]
.scene
.document
.get_entity(h)
.unwrap()
.common()
.color;
let _ = app.automation_op(r#"{"op":"select","type":"Line"}"#);
let _ = app.automation_op(r#"{"op":"run","cmd":"CHPROP COLOR 3"}"#);
assert_ne!(
app.tabs[i].scene.document.get_entity(h).unwrap().common().color,
original,
"CHPROP COLOR 3 must have changed the line's colour"
);
app.undo_active_tab();
assert_eq!(
app.tabs[i].scene.document.get_entity(h).unwrap().common().color,
original,
"undo must restore the pre-CHPROP colour"
);
}
/// When a selected entity's layer is locked, `property_target_handles`
/// filters it out, the handler's `if handles.is_empty()` branch runs,
/// and the entity itself is *not* mutated. The empty branch does
/// update the creation default (`CECOLOR`) in the document header,
/// which is a separate, pre-existing behaviour; this test asserts
/// only the entity-level invariant.
#[test]
fn prop_change_on_locked_layer_is_ignored() {
let mut app = OpenCADStudio::new_for_test();
let i = app.active_tab;
let h = line_handle(&mut app);
let layer_name = app.tabs[i]
.scene
.document
.get_entity(h)
.unwrap()
.common()
.layer
.clone();
{
let layer = app.tabs[i]
.scene
.document
.layers
.get_mut(&layer_name)
.expect("entity's layer must exist");
layer.flags.locked = true;
}
assert!(
app.tabs[i].scene.is_layer_locked(h),
"test setup: layer must report locked"
);
let _ = app.automation_op(r#"{"op":"select","type":"Line"}"#);
let _ = app.update(Message::PropColorChanged(Color::Index(9)));
assert_ne!(
app.tabs[i].scene.document.get_entity(h).unwrap().common().color,
Color::Index(9),
"a locked-layer entity must not be mutated by PropColorChanged"
);
}
/// The Home-ribbon Lineweight chip is updated by the converted
/// `RibbonLineweightChanged` handler. Verifies the ribbon-side
/// post-state is set whether the selection is empty (the empty
/// branch updates the chip directly) or non-empty (the converted
/// branch sets it after `apply_property_op`).
#[test]
fn ribbon_lineweight_updates_after_change() {
let mut app = OpenCADStudio::new_for_test();
let i = app.active_tab;
let _h = line_handle(&mut app);
let _ = app.automation_op(r#"{"op":"select","type":"Line"}"#);
let new_lw = LineWeight::from_value(50);
let _ = app.update(Message::RibbonLineweightChanged(new_lw));
assert_eq!(app.ribbon.active_lineweight, new_lw);
}
}
+13
View File
@@ -198,6 +198,13 @@ pub struct UserSettings {
pub osmode: i32,
/// Controls whether the TEXTEDIT command repeats automatically (0 = Multiple, 1 = Single).
pub texteditmode: bool,
/// QDIM extension-origin priority: 0 = endpoints, 1 = intersections.
#[serde(default)]
pub quick_dimension_snap_priority: u8,
/// DIMCONTINUEMODE: 1 inherits the base dimension's layer/style; 0 uses
/// the current creation layer/style. Registry-style, app-level preference.
#[serde(default = "default_dimension_continue_mode")]
pub dimension_continue_mode: i16,
/// TEXTFILL: fill TrueType glyphs (true) or draw them hollow (false).
pub textfill: bool,
/// When true, saving over an existing file first copies it to a sibling
@@ -247,6 +254,10 @@ fn default_clipromptlines() -> i32 {
3
}
fn default_dimension_continue_mode() -> i16 {
1
}
fn deserialize_clipromptlines<'de, D>(de: D) -> Result<i32, D::Error>
where
D: serde::Deserializer<'de>,
@@ -284,6 +295,8 @@ impl Default for UserSettings {
// off (suppress bit 16384).
osmode: 575 | OSMODE_SUPPRESS,
texteditmode: false,
quick_dimension_snap_priority: 0,
dimension_continue_mode: 1,
textfill: true,
backup_on_save: true,
file_assoc_enabled: true,
+56 -27
View File
@@ -1608,19 +1608,17 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
} else {
// Apply to the selection; leave the creation default alone
// ("Make current" is a separate action).
self.push_undo_snapshot(i, "CHPROP");
for &handle in &handles {
if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) {
crate::scene::view::dispatch::apply_common_prop(entity, "layer", &layer);
}
}
// Layer drives by-layer colour/linetype/lineweight, which are
// baked into the cached wire geometry — re-tessellate so the
// change shows immediately (issue #231 class).
self.invalidate_property_targets(i, &handles);
self.tabs[i].dirty = true;
self.apply_property_op(i, "CHPROP", &handles, |app, handle| {
if let Some(entity) =
app.tabs[i].scene.document.get_entity_mut(handle)
{
crate::scene::view::dispatch::apply_common_prop(entity, "layer", &layer);
}
});
self.ribbon.active_layer = layer;
self.refresh_properties();
}
Task::none()
}
@@ -1641,16 +1639,14 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
self.tabs[i].dirty = true;
self.ribbon.active_color = color;
} else {
self.push_undo_snapshot(i, "CHPROP");
for &handle in &handles {
if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) {
self.apply_property_op(i, "CHPROP", &handles, |app, handle| {
if let Some(entity) =
app.tabs[i].scene.document.get_entity_mut(handle)
{
crate::scene::view::dispatch::apply_color(entity, color);
}
}
self.invalidate_property_targets(i, &handles);
self.tabs[i].dirty = true;
});
self.ribbon.active_color = color;
self.refresh_properties();
}
Task::none()
}
@@ -1679,19 +1675,17 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
self.tabs[i].dirty = true;
self.ribbon.active_linetype = lt;
} else {
self.push_undo_snapshot(i, "CHPROP");
for &handle in &handles {
if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) {
crate::scene::view::dispatch::apply_common_prop(entity, "linetype", &lt);
}
}
// Linetype is baked into the cached wire geometry —
// re-tessellate so the dashed/solid look updates immediately
// (issue #231 class).
self.invalidate_property_targets(i, &handles);
self.tabs[i].dirty = true;
self.apply_property_op(i, "CHPROP", &handles, |app, handle| {
if let Some(entity) =
app.tabs[i].scene.document.get_entity_mut(handle)
{
crate::scene::view::dispatch::apply_common_prop(entity, "linetype", &lt);
}
});
self.ribbon.active_linetype = lt;
self.refresh_properties();
}
Task::none()
}
@@ -1981,12 +1975,20 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
self.tabs[i].scene.document.get_entity(handle),
Some(acadrust::EntityType::Dimension(_))
) {
crate::entities::dim_override::set_property(
let applied = crate::entities::dim_override::set_property(
&mut self.tabs[i].scene.document,
handle,
field,
&value,
);
if applied && field == "dim_text_inside" {
if let Some(acadrust::EntityType::Dimension(
acadrust::entities::Dimension::LargeRadial(dimension),
)) = self.tabs[i].scene.document.get_entity_mut(handle)
{
dimension.base.text_user_positioned = false;
}
}
}
}
} else if field == "vscale_std" {
@@ -2451,6 +2453,13 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
}
_ => None,
});
if matches!(field, "text_x" | "text_y") {
crate::entities::dimension::materialize_large_radial_text_position(
&mut self.tabs[i].scene.document,
handle,
&value,
);
}
if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle)
{
crate::scene::view::dispatch::apply_geom_prop_in_working_plane(
@@ -2697,12 +2706,25 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
self.tabs[i].scene.document.get_entity(handle),
Some(acadrust::EntityType::Dimension(_))
) {
crate::entities::dim_override::set_property(
let applied = crate::entities::dim_override::set_property(
&mut self.tabs[i].scene.document,
handle,
field,
&val,
);
if applied && field == "dim_text_inside" {
if let Some(acadrust::EntityType::Dimension(
acadrust::entities::Dimension::LargeRadial(
dimension,
),
)) = self.tabs[i]
.scene
.document
.get_entity_mut(handle)
{
dimension.base.text_user_positioned = false;
}
}
}
}
"hyperlink" => {
@@ -2799,6 +2821,13 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
}
_ => None,
});
if matches!(field, "text_x" | "text_y") {
crate::entities::dimension::materialize_large_radial_text_position(
&mut self.tabs[i].scene.document,
handle,
&val,
);
}
if let Some(entity) = self.tabs[i]
.scene
.document
+4
View File
@@ -382,6 +382,8 @@ impl OpenCADStudio {
self.snapper.snap_enabled,
),
texteditmode: self.texteditmode,
quick_dimension_snap_priority: self.quick_dimension_snap_priority,
dimension_continue_mode: self.dimension_continue_mode,
textfill: crate::scene::text::sdf_atlas::textfill(),
backup_on_save: self.backup_on_save,
file_assoc_enabled: self.file_assoc_enabled,
@@ -440,6 +442,8 @@ impl OpenCADStudio {
self.snapper.enabled = modes.into_iter().collect();
self.snapper.snap_enabled = snap_enabled;
self.texteditmode = s.texteditmode;
self.quick_dimension_snap_priority = s.quick_dimension_snap_priority.min(1);
self.dimension_continue_mode = s.dimension_continue_mode.clamp(0, 1);
crate::scene::text::sdf_atlas::set_textfill(s.textfill);
self.backup_on_save = s.backup_on_save;
self.file_assoc_enabled = s.file_assoc_enabled;
+71 -122
View File
@@ -4576,19 +4576,17 @@ impl OpenCADStudio {
self.tabs[i].dirty = true;
self.ribbon.active_lineweight = lw;
} else {
self.push_undo_snapshot(i, "CHPROP");
for &handle in &handles {
if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) {
crate::scene::view::dispatch::apply_line_weight(entity, lw);
}
}
// Lineweight is baked into the cached wire geometry —
// re-tessellate so the change shows immediately (issue #231
// class).
self.invalidate_property_targets(i, &handles);
self.tabs[i].dirty = true;
self.apply_property_op(i, "CHPROP", &handles, |app, handle| {
if let Some(entity) =
app.tabs[i].scene.document.get_entity_mut(handle)
{
crate::scene::view::dispatch::apply_line_weight(entity, lw);
}
});
self.ribbon.active_lineweight = lw;
self.refresh_properties();
}
Task::none()
}
@@ -4603,17 +4601,15 @@ impl OpenCADStudio {
self.refresh_properties();
return task;
}
self.push_undo_snapshot(i, "CHPROP");
for &handle in &handles {
if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) {
self.apply_property_op(i, "CHPROP", &handles, |app, handle| {
if let Some(entity) =
app.tabs[i].scene.document.get_entity_mut(handle)
{
crate::scene::view::dispatch::apply_common_prop(
entity, "layer", &layer,
);
}
}
self.invalidate_property_targets(i, &handles);
self.tabs[i].dirty = true;
self.refresh_properties();
});
Task::none()
}
@@ -4626,17 +4622,15 @@ impl OpenCADStudio {
self.refresh_properties();
return task;
}
self.push_undo_snapshot(i, "CHPROP");
for &handle in &handles {
if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) {
self.apply_property_op(i, "CHPROP", &handles, |app, handle| {
if let Some(entity) =
app.tabs[i].scene.document.get_entity_mut(handle)
{
crate::scene::view::dispatch::apply_color(entity, color);
}
}
self.invalidate_property_targets(i, &handles);
});
self.tabs[i].properties.color_picker_open = false;
self.tabs[i].properties.color_palette_open = false;
self.tabs[i].dirty = true;
self.refresh_properties();
Task::none()
}
@@ -4653,31 +4647,22 @@ impl OpenCADStudio {
self.refresh_properties();
return Task::none();
}
self.push_undo_snapshot(i, "CHPROP");
for &handle in &handles {
if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) {
self.apply_property_op(i, "CHPROP", &handles, |app, handle| {
if let Some(entity) =
app.tabs[i].scene.document.get_entity_mut(handle)
{
crate::scene::view::dispatch::apply_line_weight(entity, lw);
}
}
self.invalidate_property_targets(i, &handles);
self.tabs[i].dirty = true;
self.refresh_properties();
});
Task::none()
}
Message::PropFieldLwChanged { field, value } => {
let i = self.active_tab;
let handles = self.property_target_handles(i);
if handles.is_empty() {
return Task::none();
}
self.push_undo_snapshot(i, "CHPROP");
for &handle in &handles {
if self.tabs[i].scene.is_layer_locked(handle) {
continue;
}
self.apply_property_op(i, "CHPROP", &handles, |app, handle| {
if let Some(acadrust::EntityType::MultiLeader(leader)) =
self.tabs[i].scene.document.get_entity_mut(handle)
app.tabs[i].scene.document.get_entity_mut(handle)
{
if field == "line_weight" {
leader.line_weight = value;
@@ -4694,10 +4679,7 @@ impl OpenCADStudio {
}
}
}
}
self.invalidate_property_targets(i, &handles);
self.tabs[i].dirty = true;
self.refresh_properties();
});
Task::none()
}
@@ -4709,17 +4691,15 @@ impl OpenCADStudio {
self.refresh_properties();
return task;
}
self.push_undo_snapshot(i, "CHPROP");
for &handle in &handles {
if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) {
self.apply_property_op(i, "CHPROP", &handles, |app, handle| {
if let Some(entity) =
app.tabs[i].scene.document.get_entity_mut(handle)
{
crate::scene::view::dispatch::apply_common_prop(
entity, "linetype", &lt,
);
}
}
self.invalidate_property_targets(i, &handles);
self.tabs[i].dirty = true;
self.refresh_properties();
});
Task::none()
}
@@ -4801,8 +4781,7 @@ impl OpenCADStudio {
let i = self.active_tab;
let handles = self.property_target_handles(i);
if !handles.is_empty() {
self.push_undo_snapshot(i, "CHPROP");
for &handle in &handles {
self.apply_property_op(i, "CHPROP", &handles, |app, handle| {
match field {
// Per-object annotative toggle: MTEXT/MULTILEADER carry
// a native flag; single-line TEXT is annotative purely
@@ -4811,7 +4790,7 @@ impl OpenCADStudio {
// representation and turning it off removes it (not just
// the flag).
"is_annotative" | "enable_annotation_scale" | "annotative_ctx" => {
let doc = &self.tabs[i].scene.document;
let doc = &app.tabs[i].scene.document;
let cur = match doc.get_entity(handle) {
Some(acadrust::EntityType::MText(t)) => t.is_annotative,
Some(acadrust::EntityType::MultiLeader(m)) => {
@@ -4820,10 +4799,10 @@ impl OpenCADStudio {
// TEXT (and any other context-only type): its
// annotative state is whether a context exists.
Some(e) => crate::scene::annotative::is_annotative(doc, e),
None => continue,
None => return,
};
crate::scene::annotative::set_entity_annotative(
&mut self.tabs[i].scene.document,
&mut app.tabs[i].scene.document,
handle,
!cur,
);
@@ -4834,10 +4813,10 @@ impl OpenCADStudio {
// object. Off is handled inside set_entity_*.
if !cur {
if let Some(sh) =
self.tabs[i].scene.creation_annotation_scale_handle()
app.tabs[i].scene.creation_annotation_scale_handle()
{
crate::scene::annotative::create_annotation_context(
&mut self.tabs[i].scene.document,
&mut app.tabs[i].scene.document,
handle,
sh,
);
@@ -4846,7 +4825,7 @@ impl OpenCADStudio {
}
"invisible" => {
if let Some(entity) =
self.tabs[i].scene.document.get_entity_mut(handle)
app.tabs[i].scene.document.get_entity_mut(handle)
{
crate::scene::view::dispatch::toggle_invisible(entity);
}
@@ -4855,22 +4834,22 @@ impl OpenCADStudio {
// (#427): checking collapses Y/Z onto X; unchecking
// only switches the panel to per-axis rows.
"ins_uniform" => {
let scales = match self.tabs[i].scene.document.get_entity(handle) {
let scales = match app.tabs[i].scene.document.get_entity(handle) {
Some(acadrust::EntityType::Insert(ins)) => {
Some((ins.x_scale(), ins.y_scale(), ins.z_scale()))
}
_ => None,
};
let Some((sx, sy, sz)) = scales else { continue };
let Some((sx, sy, sz)) = scales else { return };
let eq = (sx - sy).abs() < 1e-12 && (sx - sz).abs() < 1e-12;
let checked =
eq && !self.props_asym_scale.contains(&handle.value());
eq && !app.props_asym_scale.contains(&handle.value());
if checked {
self.props_asym_scale.insert(handle.value());
app.props_asym_scale.insert(handle.value());
} else {
self.props_asym_scale.remove(&handle.value());
app.props_asym_scale.remove(&handle.value());
if let Some(acadrust::EntityType::Insert(ins)) =
self.tabs[i].scene.document.get_entity_mut(handle)
app.tabs[i].scene.document.get_entity_mut(handle)
{
ins.set_y_scale(sx);
ins.set_z_scale(sx);
@@ -4879,11 +4858,11 @@ impl OpenCADStudio {
}
"tbl_title_suppressed" | "tbl_header_suppressed" => {
let next = {
let document = &self.tabs[i].scene.document;
let document = &app.tabs[i].scene.document;
let Some(acadrust::EntityType::Table(table)) =
document.get_entity(handle)
else {
continue;
return;
};
let table_style = table.table_style_handle.and_then(|style_handle| {
document.objects.get(&style_handle).and_then(|object| {
@@ -4909,7 +4888,7 @@ impl OpenCADStudio {
!current
};
if let Some(entity) =
self.tabs[i].scene.document.get_entity_mut(handle)
app.tabs[i].scene.document.get_entity_mut(handle)
{
crate::scene::view::dispatch::apply_geom_prop(
entity,
@@ -4920,7 +4899,7 @@ impl OpenCADStudio {
}
_ => {
if let Some(entity) =
self.tabs[i].scene.document.get_entity_mut(handle)
app.tabs[i].scene.document.get_entity_mut(handle)
{
crate::scene::view::dispatch::apply_geom_prop(
entity, field, "toggle",
@@ -4928,19 +4907,14 @@ impl OpenCADStudio {
}
}
}
}
if field.starts_with("tbl_") {
for &handle in &handles {
if field.starts_with("tbl_") {
if let Some(acadrust::EntityType::Table(table)) =
self.tabs[i].scene.document.get_entity_mut(handle)
app.tabs[i].scene.document.get_entity_mut(handle)
{
table.block_record_handle = None;
}
}
}
self.invalidate_property_targets(i, &handles);
self.tabs[i].dirty = true;
self.refresh_properties();
});
}
Task::none()
}
@@ -5070,9 +5044,8 @@ impl OpenCADStudio {
let i = self.active_tab;
let handles = self.property_target_handles(i);
if !handles.is_empty() {
self.push_undo_snapshot(i, "CHPROP");
for &handle in &handles {
match self.tabs[i].scene.document.get_entity_mut(handle) {
self.apply_property_op(i, "CHPROP", &handles, |app, handle| {
match app.tabs[i].scene.document.get_entity_mut(handle) {
Some(acadrust::EntityType::MText(m)) => {
m.background_color = color.clone();
// Picking a colour turns the background on in Fill
@@ -5085,11 +5058,8 @@ impl OpenCADStudio {
}
_ => {}
}
}
self.invalidate_property_targets(i, &handles);
});
self.tabs[i].properties.bg_color_picker_open = false;
self.tabs[i].dirty = true;
self.refresh_properties();
}
Task::none()
}
@@ -5145,11 +5115,10 @@ impl OpenCADStudio {
})
.collect();
if !targets.is_empty() {
self.push_undo_snapshot(i, "CHPROP");
for &handle in &targets {
self.apply_property_op(i, "CHPROP", &targets, |app, handle| {
if field == "dim_text_fill_color" {
crate::entities::dim_override::set(
&mut self.tabs[i].scene.document,
&mut app.tabs[i].scene.document,
handle,
crate::entities::dim_override::DIMTFILL,
Some(acadrust::xdata::XDataValue::Integer16(
@@ -5157,20 +5126,17 @@ impl OpenCADStudio {
)),
);
if fill_mode != Some(2) {
continue;
return;
}
}
crate::entities::dim_override::set(
&mut self.tabs[i].scene.document,
&mut app.tabs[i].scene.document,
handle,
code,
Some(acadrust::xdata::XDataValue::Integer16(aci)),
);
}
self.invalidate_property_targets(i, &targets);
});
self.tabs[i].properties.open_color_field = None;
self.tabs[i].dirty = true;
self.refresh_properties();
}
return Task::none();
}
@@ -5179,14 +5145,9 @@ impl OpenCADStudio {
"line_color" | "text_color" | "block_content_color"
| "background_fill_color"
) {
if !handles.is_empty() {
self.push_undo_snapshot(i, "CHPROP");
for &handle in &handles {
if self.tabs[i].scene.is_layer_locked(handle) {
continue;
}
self.apply_property_op(i, "CHPROP", &handles, |app, handle| {
if let Some(acadrust::EntityType::MultiLeader(leader)) =
self.tabs[i].scene.document.get_entity_mut(handle)
app.tabs[i].scene.document.get_entity_mut(handle)
{
match field.as_str() {
"line_color" => {
@@ -5223,12 +5184,8 @@ impl OpenCADStudio {
_ => {}
}
}
}
self.invalidate_property_targets(i, &handles);
self.tabs[i].properties.open_color_field = None;
self.tabs[i].dirty = true;
self.refresh_properties();
}
});
self.tabs[i].properties.open_color_field = None;
return Task::none();
}
if matches!(
@@ -5237,16 +5194,15 @@ impl OpenCADStudio {
) {
let cell_index = self.tabs[i].properties.prop_vertex;
if !handles.is_empty() {
self.push_undo_snapshot(i, "TABLE CELL COLOR");
for &handle in &handles {
self.apply_property_op(i, "TABLE CELL COLOR", &handles, |app, handle| {
let Some(acadrust::EntityType::Table(table)) =
self.tabs[i].scene.document.get_entity_mut(handle)
app.tabs[i].scene.document.get_entity_mut(handle)
else {
continue;
return;
};
let columns = table.column_count();
if columns == 0 {
continue;
return;
}
if let Some(cell) = table.cell_mut(
cell_index / columns,
@@ -5257,7 +5213,7 @@ impl OpenCADStudio {
CellStateFlags::FORMAT_LOCKED
| CellStateFlags::FORMAT_READ_ONLY,
) {
continue;
return;
}
let style = cell.style.get_or_insert_with(Default::default);
if field == "tbl_cell_content_color" {
@@ -5274,20 +5230,16 @@ impl OpenCADStudio {
}
}
table.block_record_handle = None;
}
self.invalidate_property_targets(i, &handles);
});
self.tabs[i].properties.open_color_field = None;
self.tabs[i].dirty = true;
self.refresh_properties();
}
return Task::none();
}
if !handles.is_empty() {
self.push_undo_snapshot(i, "CHPROP");
let idx = if field == "gradient_color_2" { 1 } else { 0 };
for &handle in &handles {
self.apply_property_op(i, "CHPROP", &handles, |app, handle| {
if let Some(acadrust::EntityType::Hatch(h)) =
self.tabs[i].scene.document.get_entity_mut(handle)
app.tabs[i].scene.document.get_entity_mut(handle)
{
while h.gradient_color.colors.len() <= idx {
let value = if h.gradient_color.colors.is_empty() {
@@ -5304,14 +5256,11 @@ impl OpenCADStudio {
}
h.gradient_color.colors[idx].color = color.clone();
}
}
});
// Rebuild hatch seeds so the gradient fill picks up the new
// colour (synced_hatch_models only patches the main colour).
self.tabs[i].scene.populate_hatches_from_document();
self.invalidate_property_targets(i, &handles);
self.tabs[i].properties.open_color_field = None;
self.tabs[i].dirty = true;
self.refresh_properties();
}
Task::none()
}
+22
View File
@@ -1248,6 +1248,28 @@ pub enum CmdResult {
CommitDimension {
entity: EntityType,
association: DimensionAssociationInput,
/// Retain the source dimension's layer and style instead of replacing
/// them with the current creation defaults (DIMCONTINUEMODE=1).
preserve_base_style: bool,
/// Keep collecting points after the dimension is committed.
continue_command: bool,
},
/// Commit several dimensions with independent association sources in one
/// undo step, then end the command.
CommitDimensionsAndExit(Vec<(EntityType, DimensionAssociationInput)>),
/// Persist the quick-dimension extension-origin priority while keeping the
/// active command at its current prompt (0 = endpoints, 1 = intersections).
SetQuickDimensionSnapPriority(u8),
/// Align multileader content points along a picked infinite line.
AlignMLeaders {
handles: Vec<Handle>,
from: DVec3,
to: DVec3,
},
/// Merge compatible block multileaders at a picked content point.
CollectMLeaders {
handles: Vec<Handle>,
point: DVec3,
},
/// Commit a Model-tab 3D solid: the acadrust entity (for selection /
/// persistence) plus its B-rep (cached for boolean ops + shaded
+20 -5
View File
@@ -403,8 +403,8 @@ pub fn set_property(
let size = current.abs().max(0.01);
let value = match trimmed.to_ascii_lowercase().as_str() {
"none" => 0.0,
"center marks" => size,
"centerlines" => -size,
"mark" | "center marks" => size,
"line" | "centerlines" => -size,
_ => return false,
};
set(doc, handle, DIMCEN, Some(XDataValue::Real(value)));
@@ -423,6 +423,7 @@ pub fn set_property(
return true;
}
let handle_field = match field {
"dim_radial_arrow" => Some(DIMLDRBLK),
"dim_arrowhead_1" => Some(DIMBLK1),
"dim_arrowhead_2" => Some(DIMBLK2),
"dim_linetype" => Some(DIMLTYPE),
@@ -433,7 +434,7 @@ pub fn set_property(
};
if let Some(code) = handle_field {
let resolved = match field {
"dim_arrowhead_1" | "dim_arrowhead_2" => {
"dim_radial_arrow" | "dim_arrowhead_1" | "dim_arrowhead_2" => {
if trimmed == "Closed filled" {
Some(Handle::NULL)
} else {
@@ -638,6 +639,10 @@ pub fn set_property(
"dim_line_lineweight" | "dim_ext_line_lineweight" => {
parse_lineweight_label(trimmed)
}
"dim_precision"
| "dim_alt_precision"
| "dim_tolerance_precision"
| "dim_alt_tolerance_precision" => parse_precision_label(trimmed),
"dim_ext_line_fixed"
| "dim_text_outside_align"
| "dim_text_inside_align"
@@ -729,8 +734,8 @@ pub fn set_property(
_ => trimmed.parse().ok(),
},
"dim_tolerance_alignment" => match trimmed.to_ascii_lowercase().as_str() {
"align decimal separators" => Some(0),
"align operational symbols" => Some(1),
"decimal separator" | "align decimal separators" => Some(0),
"operational symbols" | "align operational symbols" => Some(1),
_ => trimmed.parse().ok(),
},
_ => trimmed.parse().ok(),
@@ -744,6 +749,16 @@ pub fn set_property(
false
}
fn parse_precision_label(value: &str) -> Option<i16> {
if let Some(decimals) = value.strip_prefix("0.") {
return (!decimals.is_empty()
&& decimals.len() <= 8
&& decimals.bytes().all(|digit| digit == b'0'))
.then_some(decimals.len() as i16);
}
value.parse().ok()
}
fn parse_lineweight_label(value: &str) -> Option<i16> {
match value.trim() {
"ByLayer" => Some(-1),
+982 -396
View File
File diff suppressed because it is too large. Load diff
+22 -14
View File
@@ -3,6 +3,7 @@ use acadrust::entities::{
MultiLeader, MultiLeaderPathType, MultiLeaderPropertyOverrideFlags, TextAlignmentType,
TextAttachmentDirectionType, TextAttachmentType,
};
use cadkernel::geom2d::Transform;
use crate::entities::text_support::{
layout_mtext, resolve_text_style, MTextRenderOpts, MTextVAnchor, ResolvedTextStyle,
@@ -1120,7 +1121,9 @@ fn apply_geom_prop(ml: &mut MultiLeader, field: &str, value: &str) {
!ml.text_frame
} else {
value == "true"
}
};
ml.property_override_flags
.insert(MultiLeaderPropertyOverrideFlags::TEXT_FRAME);
}
"path_type" => {
ml.path_type = match value {
@@ -1200,6 +1203,8 @@ fn apply_geom_prop(ml: &mut MultiLeader, field: &str, value: &str) {
"dogleg_length" => {
if let Some(v) = f64(value) {
ml.dogleg_length = v;
ml.property_override_flags
.insert(MultiLeaderPropertyOverrideFlags::LANDING_DISTANCE);
}
}
"arrowhead_size" => {
@@ -1395,6 +1400,8 @@ fn apply_geom_prop(ml: &mut MultiLeader, field: &str, value: &str) {
ml.context.text_rotation.sin(),
0.0,
);
ml.property_override_flags
.insert(MultiLeaderPropertyOverrideFlags::TEXT_ANGLE);
}
}
"line_spacing" => {
@@ -1941,9 +1948,7 @@ impl MultiLeaderTess for MultiLeader {
}
}
// Vertically-attached leaders (text above/below) have no dogleg:
// the leader meets the text edge directly, so the short horizontal
// dash would be a stray mark AutoCAD never draws.
// Vertically attached leaders meet the text edge directly.
let vertical_attach = matches!(
ml.text_attachment_direction,
acadrust::entities::multileader::TextAttachmentDirectionType::Vertical
@@ -2074,20 +2079,28 @@ impl MultiLeaderTess for MultiLeader {
}
}
if bounds[0] <= bounds[2] && bounds[1] <= bounds[3] {
let to_block = Transform::rotation(-ml.block_rotation);
let from_left = ml
.context
.leader_roots
.first()
.map_or(true, |root| root.direction.x >= 0.0);
.map_or(true, |root| {
to_block.apply_vector([
root.direction.x,
root.direction.y,
])[0]
>= 0.0
});
let anchor_x = if from_left { bounds[0] } else { bounds[2] } as f64;
let anchor_y = ((bounds[1] + bounds[3]) * 0.5) as f64;
let local_x =
(anchor_x - block_record.base_point.x) * ml.block_scale.x;
let local_y =
(anchor_y - block_record.base_point.y) * ml.block_scale.y;
let (sin_r, cos_r) = ml.block_rotation.sin_cos();
insertion.x -= local_x * cos_r - local_y * sin_r;
insertion.y -= local_x * sin_r + local_y * cos_r;
let rotated = Transform::rotation(ml.block_rotation)
.apply_vector([local_x, local_y]);
insertion.x -= rotated[0];
insertion.y -= rotated[1];
}
}
let mut synth_ins = acadrust::entities::Insert::new(block_name, insertion);
@@ -2171,12 +2184,7 @@ impl MultiLeaderTess for MultiLeader {
let local_ins_x = (ins.x) as f32;
let z = (ins.z) as f32;
// Rotation: the annotation context is AutoCAD's BAKED display
// state — text_direction / text_rotation already reflect whatever
// the style's angle setting produced at edit time. Re-deriving
// from ml.text_angle_type here (e.g. forcing Horizontal to 0)
// un-rotated leaders whose stored context is angled; the angle
// type only guides recomputation during edits, not display.
// The context stores the resolved display rotation.
let td = ctx.text_direction;
let mut rot = if td.x.abs() > 1e-9 || td.y.abs() > 1e-9 {
(td.y as f32).atan2(td.x as f32)
+2
View File
@@ -135,6 +135,8 @@ impl CadCommand for AlignedDimensionCommand {
CmdResult::CommitDimension {
entity: self.plane.place_entity(EntityType::Dimension(Dimension::Aligned(dim))),
association: DimensionAssociationInput::Infer(self.source_handle),
preserve_base_style: false,
continue_command: false,
}
}
}
+2
View File
@@ -234,6 +234,8 @@ impl AngularDimensionCommand {
CmdResult::CommitDimension {
entity,
association: DimensionAssociationInput::Explicit(self.source_refs.clone()),
preserve_base_style: false,
continue_command: false,
}
}
+2
View File
@@ -265,6 +265,8 @@ impl ArcLengthDimensionCommand {
CmdResult::CommitDimension {
entity: plane.place_entity(EntityType::Dimension(Dimension::Arc(dimension))),
association,
preserve_base_style: false,
continue_command: false,
}
}
}
+2
View File
@@ -113,6 +113,8 @@ impl CadCommand for DiameterDimensionCommand {
entity: source_plane
.place_entity(EntityType::Dimension(Dimension::Diameter(dim))),
association: DimensionAssociationInput::Infer(self.source_handle),
preserve_base_style: false,
continue_command: false,
}
}
}
File diff suppressed because it is too large. Load diff
+791 -163
View File
@@ -1,20 +1,19 @@
// DIMCONTINUE command — chain linear/aligned dimensions end-to-end.
//
// Each new point becomes the second extension line origin of a new dimension,
// whose first extension line origin is the second extension line of the previous dim.
// The dimension line stays at the same perpendicular offset as the base dimension.
//
// Constructed from commands.rs after finding the last placed linear/aligned dimension.
use acadrust::entities::{Dimension, DimensionLinear};
use acadrust::entities::{
Dimension, DimensionAngular2Ln, DimensionAngular3Pt, DimensionBase, DimensionLinear,
DimensionOrdinate,
};
use acadrust::types::Vector3;
use acadrust::EntityType;
use glam::{DVec3, Vec3};
use acadrust::{EntityType, Handle};
use cadkernel::geom2d::{
arc_span, closest_point, intersect, nearest_of, Arc, Curve, Ray, Tolerance, Transform, Vec2,
XLine,
};
use cadkernel::space::Plane;
use glam::DVec3;
use crate::command::{CadCommand, CmdResult};
use crate::command::{CadCommand, CmdOption, CmdResult, DimensionAssociationInput};
use crate::modules::{IconKind, ModuleEvent, ToolDef};
use crate::scene::model::wire_model::WireModel;
use crate::t;
pub const ICON: IconKind = IconKind::Svg(include_bytes!("../../../assets/icons/dim_continue.svg"));
@@ -27,74 +26,292 @@ pub fn tool() -> ToolDef {
}
}
pub struct DimContinueCommand {
/// Fixed first-extension-line origin for the current step (moves each iteration).
chain_p1: DVec3,
/// Direction along the dimension axis (0.0 = horizontal, PI/2 = vertical).
rotation: f64,
/// Text reading rotation inherited from the base dim so a UCS-aligned chain
/// keeps its text consistent with the originating DIMLINEAR. 0 = natural.
#[derive(Clone)]
struct SourceStyle {
layer: String,
style_name: String,
normal: Vector3,
text_rotation: f64,
/// Absolute perpendicular coordinate (dot with `perp`) of the base
/// dimension line. Each continued dim line is projected onto this exact
/// coordinate so the whole chain stays collinear — even when the extension
/// origins sit at different perpendicular positions (extension lines of
/// different lengths). (#151)
dim_line_perp: f64,
/// Direction of "up" perpendicular to the dim axis (points toward the dim line).
perp: DVec3,
/// True once we have a base dimension loaded.
ready: bool,
horizontal_direction: f64,
}
impl DimContinueCommand {
/// No base dim found — will show an error prompt and cancel immediately.
pub fn new() -> Self {
impl SourceStyle {
fn from_base(base: &DimensionBase) -> Self {
Self {
chain_p1: DVec3::ZERO,
rotation: 0.0,
text_rotation: 0.0,
dim_line_perp: 0.0,
perp: DVec3::Y,
ready: false,
layer: base.common.layer.clone(),
style_name: base.style_name.clone(),
normal: base.normal,
text_rotation: base.text_rotation,
horizontal_direction: base.horizontal_direction,
}
}
/// Build from the last placed dimension.
///
/// `p2` — second extension line origin of the base dim (the chain starts
/// here). `p1` is unused — the dim line position comes from
/// `definition_point` alone — but kept for signature parity with the
/// shared `find_last_linear_dim` tuple (DIMBASELINE uses p1).
/// `definition_point` — where the dim line was placed; its perpendicular
/// coordinate is the line the whole chain stays collinear with.
/// `rotation` — 0.0 = horizontal dim, PI/2 = vertical dim.
pub fn from_base(
_p1: Vec3,
p2: Vec3,
definition_point: Vec3,
fn apply(&self, base: &mut DimensionBase) {
base.common.layer = self.layer.clone();
base.style_name = self.style_name.clone();
base.normal = self.normal;
base.text_rotation = self.text_rotation;
base.horizontal_direction = self.horizontal_direction;
}
}
#[derive(Clone)]
enum ContinueKind {
Linear {
current: Vec2,
rotation: f64,
text_rotation: f64,
) -> Self {
// Widen the base-dim points to f64 so the committed chain math runs in
// full precision. (The base points arrive as f32 from the entity, but
// every derived committed coordinate must stay f64 from here on.)
let p2 = p2.as_dvec3();
let definition_point = definition_point.as_dvec3();
// Axis unit vector along the measurement direction — the base dim's
// rotation angle (any angle, incl. a UCS-aligned one), not a world H/V.
let axis = DVec3::new(rotation.cos(), rotation.sin(), 0.0);
// Perpendicular unit vector toward the dim line.
let perp = DVec3::new(-axis.y, axis.x, 0.0);
let dim_line_perp = definition_point.dot(perp);
Self {
chain_p1: p2,
rotation,
text_rotation,
dim_line_perp,
perp,
ready: true,
perpendicular: Vec2,
line_coordinate: f64,
},
Angular2Ln {
vertex: Vec2,
current: Vec2,
radius: f64,
},
Angular3Pt {
vertex: Vec2,
current: Vec2,
radius: f64,
},
Ordinate {
definition: Vec2,
leader: Vec2,
is_x: bool,
},
}
#[derive(Clone)]
struct ContinueState {
kind: ContinueKind,
style: SourceStyle,
plane: Plane,
}
pub struct DimContinueCommand {
base: Option<ContinueState>,
injected: Option<EntityType>,
history: Vec<ContinueState>,
preserve_base_style: bool,
}
impl DimContinueCommand {
pub fn new(recent: Option<EntityType>, preserve_base_style: bool) -> Self {
let mut command = Self {
base: None,
injected: None,
history: Vec::new(),
preserve_base_style,
};
if let Some(entity) = recent {
let _ = command.install_base(&entity, None);
}
command
}
fn install_base(&mut self, entity: &EntityType, pick_world: Option<DVec3>) -> bool {
let EntityType::Dimension(dimension) = entity else {
return false;
};
let style = SourceStyle::from_base(dimension.base());
let plane = plane_from_normal(dimension.base().definition_point, style.normal);
let pick = pick_world.and_then(|point| plane.project(point.to_array()).map(Vec2::from));
let kind = match dimension {
Dimension::Linear(source) => {
let rotation = source.rotation;
let perpendicular = Vec2::new(-rotation.sin(), rotation.cos());
ContinueKind::Linear {
current: selected_linear_end(
project(&plane, source.first_point),
project(&plane, source.second_point),
pick,
),
rotation,
perpendicular,
line_coordinate: project(&plane, source.base.definition_point)
.dot(perpendicular),
}
}
Dimension::Aligned(source) => {
let first = project(&plane, source.first_point);
let second = project(&plane, source.second_point);
let Some(direction) = (second - first).normalize() else {
return false;
};
let rotation = direction.angle();
let perpendicular = direction.perpendicular();
ContinueKind::Linear {
current: selected_linear_end(first, second, pick),
rotation,
perpendicular,
line_coordinate: project(&plane, source.base.definition_point)
.dot(perpendicular),
}
}
Dimension::Angular2Ln(source) => {
let lines = [
[
project(&plane, source.first_point),
project(&plane, source.second_point),
],
[
project(&plane, source.angle_vertex),
project(&plane, source.definition_point),
],
];
let vertex = angular2_vertex(lines).unwrap_or(lines[1][0]);
ContinueKind::Angular2Ln {
vertex,
current: selected_angular_end(vertex, [lines[0][1], lines[1][1]], pick),
radius: project(&plane, source.dimension_arc)
.distance(vertex)
.max(1.0e-9),
}
}
Dimension::Angular3Pt(source) => {
let vertex = project(&plane, source.angle_vertex);
ContinueKind::Angular3Pt {
vertex,
current: selected_angular_end(
vertex,
[
project(&plane, source.first_point),
project(&plane, source.second_point),
],
pick,
),
radius: project(&plane, source.definition_point)
.distance(vertex)
.max(1.0e-9),
}
}
Dimension::Ordinate(source) => ContinueKind::Ordinate {
definition: project(&plane, source.definition_point),
leader: project(&plane, source.leader_endpoint),
is_x: source.is_ordinate_type_x,
},
_ => return false,
};
self.base = Some(ContinueState { kind, style, plane });
true
}
fn build_dimension(&self, point_world: DVec3) -> Option<Dimension> {
let state = self.base.as_ref()?;
let point = state.plane.project(point_world.to_array()).map(Vec2::from)?;
let mut dimension = match &state.kind {
ContinueKind::Linear {
current,
rotation,
perpendicular,
line_coordinate,
} => build_linear(
state.plane,
*current,
point,
*rotation,
*perpendicular,
*line_coordinate,
),
ContinueKind::Angular2Ln {
vertex,
current,
radius,
} => {
let previous = *current - *vertex;
let moving = point - *vertex;
let (definition, text) =
angular_definition_and_text(*vertex, previous, moving, *radius)?;
let mut result = DimensionAngular2Ln::default();
result.first_point = world(state.plane, *vertex);
result.second_point = world(state.plane, *current);
result.angle_vertex = world(state.plane, *vertex);
result.definition_point = world(state.plane, point);
result.dimension_arc = world(state.plane, definition);
result.base.definition_point = result.dimension_arc;
result.base.text_middle_point = world(state.plane, text);
result.base.insertion_point = result.base.text_middle_point;
Dimension::Angular2Ln(result)
}
ContinueKind::Angular3Pt {
vertex,
current,
radius,
} => {
let previous = *current - *vertex;
let moving = point - *vertex;
let (definition, text) =
angular_definition_and_text(*vertex, previous, moving, *radius)?;
let mut result = DimensionAngular3Pt::new(
world(state.plane, *vertex),
world(state.plane, *current),
world(state.plane, point),
);
result.definition_point = world(state.plane, definition);
result.base.definition_point = result.definition_point;
result.base.text_middle_point = world(state.plane, text);
result.base.insertion_point = result.base.text_middle_point;
Dimension::Angular3Pt(result)
}
ContinueKind::Ordinate {
definition,
leader,
is_x,
} => {
let to_dimension = Transform::rotation(-state.style.horizontal_direction);
let from_dimension = Transform::rotation(state.style.horizontal_direction);
let feature = Vec2::from(to_dimension.apply_point(point.into()));
let source_leader = Vec2::from(to_dimension.apply_point((*leader).into()));
let leader = if *is_x {
Vec2::new(feature.x, source_leader.y)
} else {
Vec2::new(source_leader.x, feature.y)
};
let leader = Vec2::from(from_dimension.apply_point(leader.into()));
let mut result = DimensionOrdinate::new(
world(state.plane, point),
world(state.plane, leader),
*is_x,
);
result.definition_point = world(state.plane, *definition);
result.base.definition_point = result.definition_point;
result.base.text_middle_point = result.leader_endpoint;
result.base.insertion_point = result.base.text_middle_point;
Dimension::Ordinate(result)
}
};
state.style.apply(dimension.base_mut());
refresh_measurement(&mut dimension, state.plane);
dimension.base_mut().common.handle = Handle::NULL;
dimension.base_mut().block_name.clear();
Some(dimension)
}
fn advance(&mut self, point_world: DVec3) {
let Some(point) = self
.base
.as_ref()
.and_then(|state| state.plane.project(point_world.to_array()))
.map(Vec2::from)
else {
return;
};
let Some(state) = self.base.as_mut() else {
return;
};
match &mut state.kind {
ContinueKind::Linear { current, .. }
| ContinueKind::Angular2Ln { current, .. }
| ContinueKind::Angular3Pt { current, .. } => *current = point,
ContinueKind::Ordinate { .. } => {}
}
}
fn undo_one(&mut self) -> CmdResult {
let Some(previous) = self.history.pop() else {
return CmdResult::NeedPoint;
};
self.base = Some(previous);
CmdResult::UndoDocument
}
}
@@ -104,110 +321,521 @@ impl CadCommand for DimContinueCommand {
}
fn prompt(&self) -> String {
if !self.ready {
t!("DIMCONTINUE No base dimension found. Place a dimension first.").into_owned()
if self.base.is_none() {
"DIMCONTINUE Select continued dimension:".to_string()
} else if self
.base
.as_ref()
.is_some_and(|base| matches!(&base.kind, ContinueKind::Ordinate { .. }))
{
"DIMCONTINUE Specify feature location [Undo/Select] <Select>:".to_string()
} else {
t!("DIMCONTINUE Specify a second extension line origin (Enter to exit):").into_owned()
"DIMCONTINUE Specify second extension line origin [Select/Undo] <Select>:"
.to_string()
}
}
fn on_point(&mut self, pt: DVec3) -> CmdResult {
if !self.ready {
return CmdResult::Cancel;
fn options(&self) -> Vec<CmdOption> {
self.base.as_ref().map_or_else(Vec::new, |base| {
if matches!(&base.kind, ContinueKind::Ordinate { .. }) {
vec![CmdOption::new("Undo", "U"), CmdOption::new("Select", "S")]
} else {
vec![CmdOption::new("Select", "S"), CmdOption::new("Undo", "U")]
}
})
}
fn needs_entity_pick(&self) -> bool {
self.base.is_none()
}
fn entity_pick_highlights_hover(&self) -> bool {
self.base.is_none()
}
fn inject_before_entity_pick(&self) -> bool {
true
}
fn inject_picked_entity(&mut self, entity: EntityType) {
self.injected = Some(entity);
}
fn on_entity_pick(&mut self, _handle: Handle, point: DVec3) -> CmdResult {
let Some(entity) = self.injected.take() else {
return CmdResult::NeedPoint;
};
let _ = self.install_base(&entity, Some(point));
CmdResult::NeedPoint
}
fn on_point(&mut self, point: DVec3) -> CmdResult {
let Some(dimension) = self.build_dimension(point) else {
return CmdResult::NeedPoint;
};
if let Some(base) = self.base.clone() {
self.history.push(base);
}
let p1 = self.chain_p1;
let p2 = pt;
// Build a new linear dimension.
let mut dim = DimensionLinear::new(v3(p1), v3(p2));
dim.rotation = self.rotation;
if self.text_rotation.abs() > 1e-9 {
dim.base.text_rotation = self.text_rotation;
self.advance(point);
CmdResult::CommitDimension {
entity: EntityType::Dimension(dimension),
association: DimensionAssociationInput::Infer(None),
preserve_base_style: self.preserve_base_style,
continue_command: true,
}
// Dim line stays collinear with the base: project both extension
// origins onto the base dim line's absolute perpendicular coordinate.
// (A fixed offset from p1 drifts off the base line whenever p1 and p2
// sit at different perpendicular positions.) (#151)
let d1 = p1 + self.perp * (self.dim_line_perp - p1.dot(self.perp));
let d2 = p2 + self.perp * (self.dim_line_perp - p2.dot(self.perp));
dim.definition_point = v3(d1);
dim.base.definition_point = v3(d1);
dim.base.text_middle_point = v3((d1 + d2) * 0.5);
dim.base.insertion_point = dim.base.text_middle_point;
dim.base.actual_measurement = dim.measurement();
// Advance chain: next dim's P1 = this dim's P2.
self.chain_p1 = p2;
CmdResult::CommitEntity(EntityType::Dimension(Dimension::Linear(dim)))
}
fn on_enter(&mut self) -> CmdResult {
CmdResult::Cancel
}
fn on_mouse_move(&mut self, pt: DVec3) -> Option<WireModel> {
if !self.ready {
return None;
if self.base.take().is_some() {
CmdResult::NeedPoint
} else {
CmdResult::Cancel
}
// Preview / rubber-band geometry feeds an f32 GPU buffer, so drop to f32
// at this screen-only boundary.
let pt = pt.as_vec3();
let p1 = self.chain_p1.as_vec3();
let perp = self.perp.as_vec3();
let dim_line_perp = self.dim_line_perp as f32;
let dim_line_pt = p1 + perp * (dim_line_perp - p1.dot(perp));
let dim_line_pt2 = pt + perp * (dim_line_perp - pt.dot(perp));
Some(WireModel {
point_marker: None,
taper_widths: Vec::new(),
pattern_stations: Vec::new(),
world_width: 0.0,
depth_override: None,
display_visible: true,
plot_visible: true,
fill_is_3d: false,
fill_is_2d_solid: false,
render_instance: None,
pick_tris: Vec::new(),
pick_tris_low: Vec::new(),
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
name: "dimcont_preview".into(),
points: vec![
[p1.x, p1.y, p1.z],
[dim_line_pt.x, dim_line_pt.y, dim_line_pt.z],
[f32::NAN, 0.0, 0.0],
[pt.x, pt.y, pt.z],
[dim_line_pt2.x, dim_line_pt2.y, dim_line_pt2.z],
[f32::NAN, 0.0, 0.0],
[dim_line_pt.x, dim_line_pt.y, dim_line_pt.z],
[dim_line_pt2.x, dim_line_pt2.y, dim_line_pt2.z],
],
points_low: Vec::new(),
color: WireModel::CYAN,
selected: false,
pattern_length: 0.0,
pattern: [0.0; 8],
line_weight_px: 1.0,
snap_pts: vec![],
tangent_geoms: vec![],
aci: 0,
key_vertices: vec![],
aabb: WireModel::UNBOUNDED_AABB,
plinegen: true,
fill_tris: vec![],
fill_tris_low: Vec::new(),
})
}
fn wants_text_input(&self) -> bool {
self.base.is_some()
}
fn point_step_accepts_keywords(&self) -> bool {
self.base.is_some()
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
match text.trim().to_ascii_uppercase().as_str() {
"S" | "SELECT" => {
self.base = None;
Some(CmdResult::NeedPoint)
}
"U" | "UNDO" => Some(self.undo_one()),
_ => None,
}
}
fn on_undo_step(&mut self) -> Option<CmdResult> {
if self.history.is_empty() {
None
} else {
Some(self.undo_one())
}
}
fn on_mouse_move(&mut self, point: DVec3) -> Option<WireModel> {
self.build_dimension(point)
.map(|dimension| preview_for_dimension(&dimension))
}
}
fn v3(p: DVec3) -> Vector3 {
Vector3::new(p.x, p.y, p.z)
fn build_linear(
plane: Plane,
first: Vec2,
second: Vec2,
rotation: f64,
perpendicular: Vec2,
line_coordinate: f64,
) -> Dimension {
let first_line = project_to_line(first, perpendicular, line_coordinate);
let second_line = project_to_line(second, perpendicular, line_coordinate);
let mut result = DimensionLinear::new(world(plane, first), world(plane, second));
result.rotation = rotation;
result.definition_point = world(plane, second_line);
result.base.definition_point = result.definition_point;
result.base.text_middle_point = world(plane, first_line.lerp(second_line, 0.5));
result.base.insertion_point = result.base.text_middle_point;
Dimension::Linear(result)
}
fn selected_linear_end(first: Vec2, second: Vec2, pick: Option<Vec2>) -> Vec2 {
pick.map_or(second, |point| {
if point.distance_squared(first) <= point.distance_squared(second) {
first
} else {
second
}
})
}
// ── Autocomplete registry ─────────────────────────────────
inventory::submit!(crate::command::CommandRegistration { names: &["DIMCONTINUE"] }); // DimContinueCommand
fn selected_angular_end(vertex: Vec2, ends: [Vec2; 2], pick: Option<Vec2>) -> Vec2 {
let Some(pick) = pick else {
return ends[0];
};
let rays = ends.map(|end| {
Curve::Ray(Ray {
origin: vertex.into(),
direction: (end - vertex).into(),
})
});
nearest_of(rays.iter(), pick.into())
.map(|(index, _)| ends[index])
.unwrap_or(ends[0])
}
fn angular2_vertex(lines: [[Vec2; 2]; 2]) -> Option<Vec2> {
let first = Curve::XLine(XLine {
base: lines[0][0].into(),
direction: (lines[0][1] - lines[0][0]).into(),
});
let second = Curve::XLine(XLine {
base: lines[1][0].into(),
direction: (lines[1][1] - lines[1][0]).into(),
});
let scale = (lines[0][1] - lines[0][0])
.length()
.max((lines[1][1] - lines[1][0]).length())
.max(1.0);
intersect(&first, &second, Tolerance::new(scale * 1.0e-9))
.first()
.map(|crossing| Vec2::from(crossing.point))
}
fn angular_definition_and_text(
vertex: Vec2,
previous: Vec2,
moving: Vec2,
radius: f64,
) -> Option<(Vec2, Vec2)> {
let arc = angular_arc(vertex, previous, moving, radius)?;
Some((
Vec2::from(arc.point_at(2.0 / 3.0)),
Vec2::from(arc.point_at(0.5)),
))
}
fn angular_arc(vertex: Vec2, first: Vec2, second: Vec2, radius: f64) -> Option<Curve> {
let first = first.normalize()?;
let second = second.normalize()?;
if first.distance_squared(second) <= 1.0e-18 {
return None;
}
let first_angle = first.angle();
let second_angle = second.angle();
let (start_angle, end_angle) =
if arc_span(first_angle, second_angle) <= std::f64::consts::PI {
(first_angle, second_angle)
} else {
(second_angle, first_angle)
};
Some(Curve::Arc(Arc {
centre: vertex.into(),
radius,
start_angle,
end_angle,
}))
}
fn refresh_measurement(dimension: &mut Dimension, plane: Plane) {
match dimension {
Dimension::Linear(value) => {
let first = project(&plane, value.first_point);
let second = project(&plane, value.second_point);
let axis = Vec2::new(value.rotation.cos(), value.rotation.sin());
value.base.actual_measurement = (second - first).dot(axis).abs();
}
Dimension::Angular2Ln(value) => {
value.base.actual_measurement = value.measurement_degrees()
}
Dimension::Angular3Pt(value) => {
value.base.actual_measurement = value.measurement_degrees()
}
Dimension::Ordinate(value) => value.refresh_measurement(),
_ => {}
}
}
fn preview_for_dimension(dimension: &Dimension) -> WireModel {
let mut points = Vec::<[f64; 3]>::new();
match dimension {
Dimension::Linear(value) => {
let plane = plane_from_normal(value.first_point, value.base.normal);
linear_preview(
&mut points,
plane,
project(&plane, value.first_point),
project(&plane, value.second_point),
value.rotation,
project(&plane, value.definition_point),
project(&plane, value.base.text_middle_point),
value.base.actual_measurement,
);
}
Dimension::Angular2Ln(value) => {
let plane = plane_from_normal(value.first_point, value.base.normal);
let lines = [
[
project(&plane, value.first_point),
project(&plane, value.second_point),
],
[
project(&plane, value.angle_vertex),
project(&plane, value.definition_point),
],
];
let vertex = angular2_vertex(lines).unwrap_or(lines[1][0]);
angular_preview(
&mut points,
plane,
vertex,
lines[0][1] - vertex,
lines[1][1] - vertex,
project(&plane, value.dimension_arc),
project(&plane, value.base.text_middle_point),
value.base.actual_measurement,
);
}
Dimension::Angular3Pt(value) => {
let plane = plane_from_normal(value.angle_vertex, value.base.normal);
let vertex = project(&plane, value.angle_vertex);
angular_preview(
&mut points,
plane,
vertex,
project(&plane, value.first_point) - vertex,
project(&plane, value.second_point) - vertex,
project(&plane, value.definition_point),
project(&plane, value.base.text_middle_point),
value.base.actual_measurement,
);
}
Dimension::Ordinate(value) => {
let plane = plane_from_normal(value.definition_point, value.base.normal);
ordinate_preview(
&mut points,
plane,
project(&plane, value.feature_location),
project(&plane, value.leader_endpoint),
value.base.actual_measurement,
);
}
_ => {}
}
WireModel::solid_f64(
"dimcontinue_preview".to_string(),
points,
WireModel::CYAN,
false,
)
}
fn linear_preview(
points: &mut Vec<[f64; 3]>,
plane: Plane,
first: Vec2,
second: Vec2,
rotation: f64,
definition: Vec2,
text: Vec2,
measurement: f64,
) {
let axis = Vec2::new(rotation.cos(), rotation.sin());
let perpendicular = axis.perpendicular();
let coordinate = definition.dot(perpendicular);
let first_line = project_to_line(first, perpendicular, coordinate);
let second_line = project_to_line(second, perpendicular, coordinate);
segment(points, plane, first, first_line);
segment(points, plane, second, second_line);
segment(points, plane, first_line, second_line);
arrow(
points,
plane,
first_line,
axis,
perpendicular,
first_line.distance(second_line),
);
arrow(
points,
plane,
second_line,
-axis,
perpendicular,
first_line.distance(second_line),
);
text_box(points, plane, text, axis, perpendicular, measurement);
}
fn angular_preview(
points: &mut Vec<[f64; 3]>,
plane: Plane,
vertex: Vec2,
first: Vec2,
second: Vec2,
arc_point: Vec2,
text: Vec2,
measurement: f64,
) {
let radius = arc_point.distance(vertex).max(1.0e-9);
let Some(first_direction) = first.normalize() else {
return;
};
let Some(second_direction) = second.normalize() else {
return;
};
let Some(arc) = angular_arc(vertex, first, second, radius) else {
return;
};
segment(points, plane, vertex, vertex + first_direction * radius);
segment(points, plane, vertex, vertex + second_direction * radius);
for index in 0..=24 {
push_local(points, plane, Vec2::from(arc.point_at(index as f64 / 24.0)));
}
points.push([f64::NAN; 3]);
let start = Vec2::from(arc.point_at(0.0));
let start_next = Vec2::from(arc.point_at(1.0 / 1024.0));
let end = Vec2::from(arc.point_at(1.0));
let end_previous = Vec2::from(arc.point_at(1.0 - 1.0 / 1024.0));
arrow(
points,
plane,
start,
(start_next - start).normalize().unwrap_or(first_direction),
first_direction,
radius,
);
arrow(
points,
plane,
end,
(end_previous - end).normalize().unwrap_or(-second_direction),
second_direction,
radius,
);
let before_middle = Vec2::from(arc.point_at(0.5 - 1.0 / 1024.0));
let after_middle = Vec2::from(arc.point_at(0.5 + 1.0 / 1024.0));
let text_axis = (after_middle - before_middle)
.normalize()
.unwrap_or(Vec2::new(1.0, 0.0));
text_box(
points,
plane,
text,
text_axis,
text_axis.perpendicular(),
measurement,
);
}
fn ordinate_preview(
points: &mut Vec<[f64; 3]>,
plane: Plane,
feature: Vec2,
leader: Vec2,
measurement: f64,
) {
segment(points, plane, feature, leader);
let axis = (leader - feature)
.normalize()
.unwrap_or(Vec2::new(0.0, 1.0));
let perpendicular = axis.perpendicular();
arrow(
points,
plane,
feature,
axis,
perpendicular,
feature.distance(leader),
);
text_box(
points,
plane,
leader,
perpendicular,
axis,
measurement,
);
}
fn arrow(
points: &mut Vec<[f64; 3]>,
plane: Plane,
tip: Vec2,
inward: Vec2,
perpendicular: Vec2,
span: f64,
) {
let inward = inward.normalize().unwrap_or(Vec2::new(1.0, 0.0));
let perpendicular = perpendicular
.normalize()
.unwrap_or(Vec2::new(0.0, 1.0));
let size = span.clamp(1.0, 100.0) * 0.035;
let back = tip + inward * size;
segment(
points,
plane,
tip,
back + perpendicular * size * 0.4,
);
segment(
points,
plane,
tip,
back - perpendicular * size * 0.4,
);
}
fn text_box(
points: &mut Vec<[f64; 3]>,
plane: Plane,
center: Vec2,
axis: Vec2,
perpendicular: Vec2,
measurement: f64,
) {
let digits = if measurement.abs() < 1.0 {
1.0
} else {
measurement.abs().log10().floor() + 1.0
};
let half_width = (digits + 2.0) * 0.12;
let half_height = 0.16;
let a = center - axis * half_width - perpendicular * half_height;
let b = center + axis * half_width - perpendicular * half_height;
let c = center + axis * half_width + perpendicular * half_height;
let d = center - axis * half_width + perpendicular * half_height;
for point in [a, b, c, d, a] {
push_local(points, plane, point);
}
points.push([f64::NAN; 3]);
}
fn project_to_line(point: Vec2, perpendicular: Vec2, coordinate: f64) -> Vec2 {
let line = Curve::XLine(XLine {
base: (perpendicular * coordinate).into(),
direction: perpendicular.perpendicular().into(),
});
Vec2::from(closest_point(&line, point.into()).point)
}
fn plane_from_normal(origin: Vector3, normal: Vector3) -> Plane {
let (x_axis, y_axis) =
crate::scene::view::transform::ocs_axes((normal.x, normal.y, normal.z));
Plane::from_axes(
point(origin),
[x_axis.0, x_axis.1, x_axis.2],
[y_axis.0, y_axis.1, y_axis.2],
)
}
fn project(plane: &Plane, value: Vector3) -> Vec2 {
Vec2::from(plane.project(point(value)).unwrap_or([0.0; 2]))
}
fn world(plane: Plane, value: Vec2) -> Vector3 {
let value = plane.point_at(value.into());
Vector3::new(value[0], value[1], value[2])
}
fn point(value: Vector3) -> [f64; 3] {
[value.x, value.y, value.z]
}
fn segment(points: &mut Vec<[f64; 3]>, plane: Plane, first: Vec2, second: Vec2) {
push_local(points, plane, first);
push_local(points, plane, second);
points.push([f64::NAN; 3]);
}
fn push_local(points: &mut Vec<[f64; 3]>, plane: Plane, point: Vec2) {
points.push(plane.point_at(point.into()));
}
inventory::submit!(crate::command::CommandRegistration { names: &["DIMCONTINUE"] });
@@ -139,6 +139,8 @@ impl JoggedRadiusDimensionCommand {
dimension,
))),
association,
preserve_base_style: false,
continue_command: false,
}
}
}
+2
View File
@@ -187,6 +187,8 @@ impl CadCommand for LinearDimensionCommand {
CmdResult::CommitDimension {
entity,
association: DimensionAssociationInput::Infer(self.source_handle),
preserve_base_style: false,
continue_command: false,
}
}
}
+16 -4
View File
@@ -236,19 +236,31 @@ impl MLeaderCommand {
acadrust::entities::multileader::FlowDirectionType::ByStyle
}
};
ml.context.text_attachment_point = match source.attachment_point {
let (attachment, alignment) = match source.attachment_point {
acadrust::entities::mtext::AttachmentPoint::TopCenter
| acadrust::entities::mtext::AttachmentPoint::MiddleCenter
| acadrust::entities::mtext::AttachmentPoint::BottomCenter => {
acadrust::entities::multileader::TextAttachmentPointType::Center
(
acadrust::entities::multileader::TextAttachmentPointType::Center,
acadrust::entities::TextAlignmentType::Center,
)
}
acadrust::entities::mtext::AttachmentPoint::TopRight
| acadrust::entities::mtext::AttachmentPoint::MiddleRight
| acadrust::entities::mtext::AttachmentPoint::BottomRight => {
acadrust::entities::multileader::TextAttachmentPointType::Right
(
acadrust::entities::multileader::TextAttachmentPointType::Right,
acadrust::entities::TextAlignmentType::Right,
)
}
_ => acadrust::entities::multileader::TextAttachmentPointType::Left,
_ => (
acadrust::entities::multileader::TextAttachmentPointType::Left,
acadrust::entities::TextAlignmentType::Left,
),
};
ml.context.text_attachment_point = attachment;
ml.text_alignment = alignment;
ml.context.text_alignment = alignment;
let style_handle = self
.text_styles
.iter()
+56 -65
View File
@@ -8,6 +8,7 @@
use acadrust::entities::{LeaderLine, MultiLeader};
use acadrust::types::Vector3;
use acadrust::{EntityType, Handle};
use cadkernel::geom2d::{closest_point, Curve, Line};
use glam::{DVec3, Vec3};
use crate::command::{CadCommand, CmdResult};
@@ -92,6 +93,9 @@ impl CadCommand for MLeaderAddCommand {
match &mut self.step {
AddStep::PickArrowhead { handle, entity } => {
if let Some(ent) = entity.take() {
if !matches!(ent, EntityType::MultiLeader(_)) {
return CmdResult::Cancel;
}
let h = *handle;
self.step = AddStep::CollectPoints {
handle: h,
@@ -121,12 +125,16 @@ impl CadCommand for MLeaderAddCommand {
return CmdResult::Cancel;
}
let h = *handle;
if let EntityType::MultiLeader(ref mut ml) = entity {
if let EntityType::MultiLeader(ref mut ml) = entity {
let points: Vec<Vector3> = pts
.iter()
.map(|p| Vector3::new(p.x, p.y, p.z))
.collect();
let template_root = ml.context.leader_roots.first().cloned();
let template_line = template_root
.as_ref()
.and_then(|root| root.lines.first())
.cloned();
let path_type = ml.path_type;
let line_color = ml.line_color;
let line_type_handle = ml.line_type_handle;
@@ -141,12 +149,22 @@ impl CadCommand for MLeaderAddCommand {
root.text_attachment_direction = template.text_attachment_direction;
}
let line = root.create_line(points);
line.path_type = path_type;
line.line_color = line_color;
line.line_type_handle = line_type_handle;
line.line_weight = line_weight;
line.arrowhead_handle = arrowhead_handle;
line.arrowhead_size = arrowhead_size;
if let Some(template) = template_line {
line.path_type = template.path_type;
line.line_color = template.line_color;
line.line_type_handle = template.line_type_handle;
line.line_weight = template.line_weight;
line.arrowhead_handle = template.arrowhead_handle;
line.arrowhead_size = template.arrowhead_size;
line.override_flags = template.override_flags;
} else {
line.path_type = path_type;
line.line_color = line_color;
line.line_type_handle = line_type_handle;
line.line_weight = line_weight;
line.arrowhead_handle = arrowhead_handle;
line.arrowhead_size = arrowhead_size;
}
}
let updated = std::mem::replace(entity, EntityType::XLine(Default::default()));
return CmdResult::ReplaceEntity(h, vec![updated]);
@@ -252,10 +270,10 @@ impl CadCommand for MLeaderRemoveCommand {
let mut distance = line
.points
.windows(2)
.map(|segment| point_segment_distance_sq_xy(pick, segment[0], segment[1]))
.map(|segment| point_segment_distance_xy(pick, segment[0], segment[1]))
.fold(f64::INFINITY, f64::min);
if let Some(last) = line.points.last().copied() {
distance = distance.min(point_segment_distance_sq_xy(
distance = distance.min(point_segment_distance_xy(
pick,
last,
root.connection_point,
@@ -265,16 +283,19 @@ impl CadCommand for MLeaderRemoveCommand {
})
})
.min_by(|a, b| a.2.partial_cmp(&b.2).unwrap_or(std::cmp::Ordering::Equal));
if let Some((root_index, line_index, _)) = best {
let total_lines = ml.total_leader_line_count();
if total_lines > 1 {
let root = &mut ml.context.leader_roots[root_index];
root.lines.remove(line_index);
if root.lines.is_empty() {
ml.context.leader_roots.remove(root_index);
}
}
let Some((root_index, line_index, _)) = best else {
return CmdResult::Cancel;
};
if ml.total_leader_line_count() <= 1 {
return CmdResult::Cancel;
}
let root = &mut ml.context.leader_roots[root_index];
root.lines.remove(line_index);
if root.lines.is_empty() {
ml.context.leader_roots.remove(root_index);
}
} else {
return CmdResult::Cancel;
}
return CmdResult::ReplaceEntity(h, vec![ent]);
}
@@ -370,25 +391,11 @@ impl CadCommand for MLeaderAlignCommand {
CmdResult::NeedPoint
}
AlignStep::PickEndDir { handles, from } => {
let h = handles.clone();
let f = *from;
// Emit sentinel for commands.rs
use acadrust::entities::XLine;
let mut xl = XLine::default();
let hstr: Vec<String> = h.iter().map(|hh| hh.value().to_string()).collect();
xl.common.layer = format!(
"__MLEADERALIGN__{};{:.4},{:.4};{:.4},{:.4}",
hstr.join(","),
f.x,
f.y,
pt.x,
pt.y
);
// Use first handle as the "replaced" entity (sentinel)
if let Some(&first) = h.first() {
return CmdResult::ReplaceEntity(first, vec![EntityType::XLine(xl)]);
CmdResult::AlignMLeaders {
handles: handles.clone(),
from: *from,
to: pt,
}
CmdResult::Cancel
}
_ => CmdResult::NeedPoint,
}
@@ -463,21 +470,10 @@ impl CadCommand for MLeaderCollectCommand {
fn on_point(&mut self, pt: DVec3) -> CmdResult {
if let CollectStep::PickLocation { handles } = &self.step {
let h = handles.clone();
// Build a new combined multileader at the collection point, delete originals.
// The first handle is the "base" — the rest are erased and their leaders merged.
use acadrust::entities::XLine;
let mut xl = XLine::default();
let hstr: Vec<String> = h.iter().map(|hh| hh.value().to_string()).collect();
xl.common.layer = format!(
"__MLEADERCOLLECT__{};{:.4},{:.4}",
hstr.join(","),
pt.x,
pt.y
);
if let Some(&first) = h.first() {
return CmdResult::ReplaceEntity(first, vec![EntityType::XLine(xl)]);
}
return CmdResult::CollectMLeaders {
handles: handles.clone(),
point: pt,
};
}
CmdResult::NeedPoint
}
@@ -528,20 +524,15 @@ fn preview_wire(pts: &[Vec3]) -> WireModel {
}
}
fn point_segment_distance_sq_xy(point: Vector3, start: Vector3, end: Vector3) -> f64 {
let dx = end.x - start.x;
let dy = end.y - start.y;
let len_sq = dx * dx + dy * dy;
if len_sq <= 1.0e-18 {
let px = point.x - start.x;
let py = point.y - start.y;
return px * px + py * py;
}
let t = (((point.x - start.x) * dx + (point.y - start.y) * dy) / len_sq)
.clamp(0.0, 1.0);
let px = point.x - (start.x + t * dx);
let py = point.y - (start.y + t * dy);
px * px + py * py
fn point_segment_distance_xy(point: Vector3, start: Vector3, end: Vector3) -> f64 {
closest_point(
&Curve::Line(Line {
start: [start.x, start.y],
end: [end.x, end.y],
}),
[point.x, point.y],
)
.distance
}
// Silence unused-import warning for MultiLeader and LeaderLine if not used in all paths
+2
View File
@@ -116,6 +116,8 @@ impl CadCommand for OrdinateDimCommand {
Dimension::Ordinate(dim),
)),
association: DimensionAssociationInput::Infer(None),
preserve_base_style: false,
continue_command: false,
}
}
}
+362 -105
View File
@@ -1,17 +1,15 @@
// QDIM command — quick dimension: select objects then pick placement line.
//
// Workflow:
// 1. Select objects (window/click, Enter to finish selection)
// 2. Pick a point that defines the dimension-line position
//
// Creates one linear dimension per detected endpoint pair on the selected objects.
//! Quick dimension creation from a selected set of drawing entities.
use acadrust::entities::{Dimension, DimensionLinear};
use acadrust::entities::{Dimension, DimensionDiameter, DimensionLinear, DimensionOrdinate, DimensionRadius};
use acadrust::types::Vector3;
use acadrust::{EntityType, Handle};
use cadkernel::geom2d::{characteristic_points, intersect, Curve, SnapKind, Tolerance};
use glam::DVec3;
use crate::command::{CadCommand, CmdResult};
use crate::command::{
CadCommand, CmdOption, CmdResult, DimensionAssociationInput, DimensionAssociationSource,
EntityTransform, SelectionEntity, WorkingPlane,
};
use crate::modules::{IconKind, ModuleEvent, ToolDef};
use crate::scene::model::wire_model::WireModel;
use crate::t;
@@ -27,129 +25,388 @@ pub fn tool() -> ToolDef {
}
}
enum Step {
Gathering,
PlaceLine { handles: Vec<Handle> },
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Step { Gathering, Place, Datum, Edit, Settings }
#[derive(Clone, Copy, PartialEq, Eq)]
enum Mode { Continuous, Staggered, Baseline, Ordinate, Radius, Diameter }
#[derive(Clone, Copy, PartialEq, Eq)]
enum SnapPriority { Endpoints, Intersections }
#[derive(Clone)]
struct SourceCurve { handle: Handle, curve: Curve }
#[derive(Clone)]
struct Candidate { point: DVec3, sources: Vec<DimensionAssociationSource> }
pub struct QdimCommand {
step: Step,
mode: Mode,
snap_priority: SnapPriority,
plane: WorkingPlane,
selection: Vec<SelectionEntity>,
datum: Option<DVec3>,
excluded: Vec<DVec3>,
dim_spacing: f64,
}
impl QdimCommand {
pub fn new() -> Self {
pub fn new(selection: Vec<SelectionEntity>, dim_spacing: f64, snap_priority: u8) -> Self {
let step = if selection.is_empty() { Step::Gathering } else { Step::Place };
Self {
step: Step::Gathering,
step,
mode: Mode::Continuous,
snap_priority: if snap_priority == 1 { SnapPriority::Intersections } else { SnapPriority::Endpoints },
plane: WorkingPlane::default(),
selection,
datum: None,
excluded: Vec::new(),
dim_spacing: dim_spacing.abs().max(1e-9),
}
}
fn local_sources(&self) -> Vec<SourceCurve> {
let transform = EntityTransform::Affine(self.plane.to_local_transform());
self.selection.iter().filter_map(|selected| {
let mut entity = selected.entity.clone();
crate::scene::view::dispatch::apply_transform(&mut entity, &transform);
crate::entities::curve::entity_curve_xy(&entity)
.map(|curve| SourceCurve { handle: selected.handle, curve })
}).collect()
}
fn candidates(&self) -> Vec<Candidate> {
let curves = self.local_sources();
let mut candidates = if self.snap_priority == SnapPriority::Intersections {
let mut hits = Vec::new();
for first in 0..curves.len() {
for second in first + 1..curves.len() {
for crossing in intersect(&curves[first].curve, &curves[second].curve, Tolerance::default()) {
hits.push(Candidate {
point: DVec3::new(crossing.point[0], crossing.point[1], 0.0),
// Intersections depend on two curves, but each
// extension origin stores one source.
sources: Vec::new(),
});
}
}
}
hits
} else { Vec::new() };
if candidates.is_empty() {
for source in &curves {
for point in characteristic_points(&source.curve).into_iter().filter(|point| point.kind == SnapKind::Endpoint) {
candidates.push(Candidate {
point: DVec3::new(point.point[0], point.point[1], 0.0),
sources: vec![DimensionAssociationSource::inferred(source.handle)],
});
}
}
}
for selected in &self.selection {
if let EntityType::Dimension(dimension) = &selected.entity {
for point in dimension_reference_points(dimension) {
candidates.push(Candidate {
point: self.plane.to_local(point),
sources: vec![DimensionAssociationSource::inferred(selected.handle)],
});
}
}
}
let tolerance = candidate_tolerance(candidates.iter().map(|candidate| candidate.point));
let mut unique: Vec<Candidate> = Vec::new();
for candidate in candidates {
if let Some(existing) = unique.iter_mut().find(|existing| existing.point.distance(candidate.point) <= tolerance) {
for source in candidate.sources {
if !existing.sources.iter().any(|current| current.handle == source.handle) {
existing.sources.push(source);
}
}
} else {
unique.push(candidate);
}
}
unique.retain(|candidate| !self.excluded.iter().any(|point| point.distance(candidate.point) <= tolerance));
unique
}
fn radial_sources(&self) -> Vec<(Handle, Curve)> {
self.local_sources().into_iter()
.filter(|source| matches!(source.curve, Curve::Circle(_) | Curve::Arc(_)))
.map(|source| (source.handle, source.curve)).collect()
}
fn orientation(&self, points: &[Candidate], place: DVec3) -> bool {
let min_x = points.iter().map(|point| point.point.x).fold(f64::INFINITY, f64::min);
let max_x = points.iter().map(|point| point.point.x).fold(f64::NEG_INFINITY, f64::max);
let min_y = points.iter().map(|point| point.point.y).fold(f64::INFINITY, f64::min);
let max_y = points.iter().map(|point| point.point.y).fold(f64::NEG_INFINITY, f64::max);
let outside_y = (place.y - max_y).max(min_y - place.y).max(0.0);
let outside_x = (place.x - max_x).max(min_x - place.x).max(0.0);
if (outside_x - outside_y).abs() > 1e-9 { outside_y >= outside_x } else { max_x - min_x >= max_y - min_y }
}
fn build_dimensions(&self, place_world: DVec3) -> Vec<(EntityType, DimensionAssociationInput)> {
let place = self.plane.to_local(place_world);
if matches!(self.mode, Mode::Radius | Mode::Diameter) {
return self.build_radial_dimensions(place);
}
let mut points = self.candidates();
if points.is_empty() { return Vec::new(); }
let horizontal = self.orientation(&points, place);
points.sort_by(|first, second| if horizontal { first.point.x.total_cmp(&second.point.x) } else { first.point.y.total_cmp(&second.point.y) });
let tolerance = candidate_tolerance(points.iter().map(|point| point.point));
points.dedup_by(|first, second| {
let delta = if horizontal { first.point.x - second.point.x } else { first.point.y - second.point.y };
delta.abs() <= tolerance
});
match self.mode {
Mode::Continuous | Mode::Staggered => points.windows(2).enumerate().map(|(index, pair)| {
let offset = if self.mode == Mode::Staggered { index as f64 * self.dim_spacing } else { 0.0 };
let direction = if horizontal {
if place.y >= (pair[0].point.y + pair[1].point.y) * 0.5 { 1.0 } else { -1.0 }
} else if place.x >= (pair[0].point.x + pair[1].point.x) * 0.5 { 1.0 } else { -1.0 };
let shifted = if horizontal { DVec3::new(place.x, place.y + direction * offset, 0.0) } else { DVec3::new(place.x + direction * offset, place.y, 0.0) };
self.linear_dimension(&pair[0], &pair[1], shifted, horizontal)
}).collect(),
Mode::Baseline => {
let datum = self.datum.unwrap_or(points[0].point);
let datum_candidate = Candidate {
point: datum,
sources: points
.iter()
.find(|point| point.point.distance(datum) <= tolerance)
.map(|point| point.sources.clone())
.unwrap_or_default(),
};
points.iter().filter(|point| point.point.distance(datum) > tolerance)
.map(|point| self.linear_dimension(&datum_candidate, point, place, horizontal)).collect()
}
Mode::Ordinate => points.iter().map(|point| self.ordinate_dimension(point, place, horizontal)).collect(),
Mode::Radius | Mode::Diameter => Vec::new(),
}
}
fn linear_dimension(&self, first: &Candidate, second: &Candidate, place: DVec3, horizontal: bool) -> (EntityType, DimensionAssociationInput) {
let mut dimension = DimensionLinear::new(v3(first.point), v3(second.point));
dimension.rotation = if horizontal { 0.0 } else { std::f64::consts::FRAC_PI_2 };
let definition = if horizontal { DVec3::new(second.point.x, place.y, 0.0) } else { DVec3::new(place.x, second.point.y, 0.0) };
let text = if horizontal { DVec3::new((first.point.x + second.point.x) * 0.5, place.y, 0.0) } else { DVec3::new(place.x, (first.point.y + second.point.y) * 0.5, 0.0) };
dimension.definition_point = v3(definition);
dimension.base.definition_point = v3(definition);
dimension.base.text_middle_point = v3(text);
dimension.base.insertion_point = v3(text);
dimension.base.actual_measurement = dimension.measurement();
let association = DimensionAssociationInput::Explicit(vec![first.sources.first().copied(), second.sources.first().copied()]);
(self.plane.place_entity(EntityType::Dimension(Dimension::Linear(dimension))), association)
}
fn ordinate_dimension(&self, point: &Candidate, place: DVec3, horizontal: bool) -> (EntityType, DimensionAssociationInput) {
let datum = self.datum.unwrap_or(DVec3::ZERO);
let leader = if horizontal { DVec3::new(point.point.x, place.y, 0.0) } else { DVec3::new(place.x, point.point.y, 0.0) };
let mut dimension = DimensionOrdinate::new(v3(point.point), v3(leader), horizontal);
dimension.definition_point = v3(datum);
dimension.base.definition_point = v3(datum);
dimension.base.text_middle_point = v3(leader);
dimension.base.insertion_point = v3(leader);
dimension.refresh_measurement();
(self.plane.place_entity(EntityType::Dimension(Dimension::Ordinate(dimension))), DimensionAssociationInput::Explicit(vec![point.sources.first().copied()]))
}
fn build_radial_dimensions(&self, place: DVec3) -> Vec<(EntityType, DimensionAssociationInput)> {
self.radial_sources().into_iter().enumerate().map(|(index, (handle, curve))| {
let (center, radius) = match curve { Curve::Circle(circle) => (circle.centre, circle.radius), Curve::Arc(arc) => (arc.centre, arc.radius), _ => unreachable!() };
let center = DVec3::new(center[0], center[1], 0.0);
let mut direction = place - center;
direction.z = 0.0;
direction = if direction.length_squared() <= f64::EPSILON { DVec3::X } else { direction.normalize() };
let chord = center + direction * radius;
let text = place + DVec3::new(0.0, index as f64 * self.dim_spacing, 0.0);
let association = DimensionAssociationInput::Explicit(vec![Some(DimensionAssociationSource::inferred(handle))]);
let entity = if self.mode == Mode::Radius {
let mut dimension = DimensionRadius::new(v3(center), v3(chord));
dimension.base.definition_point = v3(chord);
dimension.base.text_middle_point = v3(text);
dimension.base.insertion_point = v3(text);
dimension.base.text_user_positioned = true;
dimension.leader_length = chord.distance(text);
dimension.base.actual_measurement = dimension.measurement();
EntityType::Dimension(Dimension::Radius(dimension))
} else {
let opposite = center - direction * radius;
let mut dimension = DimensionDiameter::new(v3(chord), v3(opposite));
dimension.base.definition_point = v3(opposite);
dimension.base.text_middle_point = v3(text);
dimension.base.insertion_point = v3(text);
dimension.base.text_user_positioned = true;
dimension.leader_length = chord.distance(text);
dimension.base.actual_measurement = dimension.measurement();
EntityType::Dimension(Dimension::Diameter(dimension))
};
(self.plane.place_entity(entity), association)
}).collect()
}
fn remove_candidate(&mut self, point_world: DVec3) {
let point = self.plane.to_local(point_world);
let all = self.candidates();
let Some(nearest) = all.iter().min_by(|first, second| first.point.distance(point).total_cmp(&second.point.distance(point))) else { return; };
self.excluded.push(nearest.point);
}
fn preview_dimensions(&self, point: DVec3) -> Vec<WireModel> {
self.build_dimensions(point).into_iter().enumerate().flat_map(|(index, (entity, _))| preview_entity(index, &entity)).collect()
}
}
impl CadCommand for QdimCommand {
fn name(&self) -> &'static str {
"QDIM"
}
fn set_working_plane(&mut self, plane: WorkingPlane) { self.plane = plane; }
fn name(&self) -> &'static str { "QDIM" }
fn prompt(&self) -> String {
match &self.step {
Step::Gathering => {
t!("QDIM Select geometry to dimension (Enter when done):").into_owned()
match self.step {
Step::Gathering => t!("QDIM Select geometry to dimension (Enter when done):").into_owned(),
Step::Place => t!("QDIM Specify dimension line position or choose an option:").into_owned(),
Step::Datum => t!("QDIM Specify datum point:").into_owned(),
Step::Edit => t!("QDIM Select a point to remove (Enter when done):").into_owned(),
Step::Settings => t!("QDIM Choose extension origin priority [Endpoints/Intersections]:").into_owned(),
}
}
fn options(&self) -> Vec<CmdOption> {
match self.step {
Step::Place => vec![
CmdOption::new("Continuous", "CONTINUOUS"), CmdOption::new("Staggered", "STAGGERED"),
CmdOption::new("Baseline", "BASELINE"), CmdOption::new("Ordinate", "ORDINATE"),
CmdOption::new("Radius", "RADIUS"), CmdOption::new("Diameter", "DIAMETER"),
CmdOption::new("Datum point", "DATUM"), CmdOption::new("Edit", "EDIT"),
CmdOption::new("Settings", "SETTINGS"),
],
Step::Settings => vec![CmdOption::new("Endpoints", "ENDPOINTS"), CmdOption::new("Intersections", "INTERSECTIONS")],
_ => Vec::new(),
}
}
fn is_selection_gathering(&self) -> bool { self.step == Step::Gathering }
fn selection_forces_add(&self) -> bool { self.step == Step::Gathering }
fn on_selection_complete(&mut self, _handles: Vec<Handle>) -> CmdResult { CmdResult::NeedPoint }
fn inject_selection_entities(&mut self, entities: Vec<SelectionEntity>) { self.selection = entities; }
fn wants_text_input(&self) -> bool { matches!(self.step, Step::Place | Step::Settings) }
fn point_step_accepts_keywords(&self) -> bool { self.step == Step::Place }
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
let keyword = text.trim().to_ascii_uppercase();
if self.step == Step::Settings {
match keyword.as_str() {
"E" | "ENDPOINT" | "ENDPOINTS" => { self.snap_priority = SnapPriority::Endpoints; self.step = Step::Place; return Some(CmdResult::SetQuickDimensionSnapPriority(0)); }
"I" | "INTERSECTION" | "INTERSECTIONS" => { self.snap_priority = SnapPriority::Intersections; self.step = Step::Place; return Some(CmdResult::SetQuickDimensionSnapPriority(1)); }
_ => return Some(CmdResult::NeedPoint),
}
Step::PlaceLine { .. } => t!("QDIM Specify dimension line position:").into_owned(),
}
}
fn is_selection_gathering(&self) -> bool {
matches!(self.step, Step::Gathering)
}
fn on_selection_complete(&mut self, handles: Vec<Handle>) -> CmdResult {
if handles.is_empty() {
return CmdResult::Cancel;
match keyword.as_str() {
"C" | "CONTINUOUS" => self.mode = Mode::Continuous,
"S" | "STAGGERED" => self.mode = Mode::Staggered,
"B" | "BASELINE" => self.mode = Mode::Baseline,
"O" | "ORDINATE" => self.mode = Mode::Ordinate,
"R" | "RADIUS" => self.mode = Mode::Radius,
"DI" | "DIAMETER" => self.mode = Mode::Diameter,
"DA" | "DATUM" | "DATUMPOINT" => { self.step = Step::Datum; return Some(CmdResult::NeedPoint); }
"E" | "EDIT" => { self.step = Step::Edit; return Some(CmdResult::NeedPoint); }
"SE" | "SETTINGS" => { self.step = Step::Settings; return Some(CmdResult::NeedPoint); }
_ => return Some(CmdResult::NeedPoint),
}
self.step = Step::PlaceLine { handles };
CmdResult::NeedPoint
Some(CmdResult::NeedPoint)
}
fn on_point(&mut self, pt: DVec3) -> CmdResult {
if let Step::PlaceLine { handles } = &self.step {
CmdResult::Relaunch(
// Keep the picked placement point in full f64 precision so the
// committed dimension coordinates are not quantized to f32.
format!("QDIM_PLACE {:.6} {:.6} {:.6}", pt.x, pt.y, pt.z),
handles.clone(),
)
} else {
CmdResult::NeedPoint
fn on_point(&mut self, point: DVec3) -> CmdResult {
match self.step {
Step::Gathering | Step::Settings => CmdResult::NeedPoint,
Step::Datum => { self.datum = Some(self.plane.to_local(point)); self.step = Step::Place; CmdResult::NeedPoint }
Step::Edit => { self.remove_candidate(point); CmdResult::NeedPoint }
Step::Place => {
let dimensions = self.build_dimensions(point);
if dimensions.is_empty() { CmdResult::NeedPoint } else { CmdResult::CommitDimensionsAndExit(dimensions) }
}
}
}
fn on_enter(&mut self) -> CmdResult {
CmdResult::Cancel
}
fn on_mouse_move(&mut self, pt: DVec3) -> Option<WireModel> { let pt = pt.as_vec3();
if !matches!(self.step, Step::PlaceLine { .. }) {
return None;
match self.step {
Step::Gathering if !self.selection.is_empty() => { self.step = Step::Place; CmdResult::NeedPoint }
Step::Edit | Step::Datum | Step::Settings => { self.step = Step::Place; CmdResult::NeedPoint }
_ => CmdResult::Cancel,
}
let d = 0.5_f32;
Some(WireModel {
point_marker: None,
taper_widths: Vec::new(),
pattern_stations: Vec::new(),
world_width: 0.0,
depth_override: None,
display_visible: true,
plot_visible: true,
fill_is_3d: false,
fill_is_2d_solid: false,
render_instance: None,
pick_tris: Vec::new(),
pick_tris_low: Vec::new(),
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
name: "qdim_preview".into(),
points: vec![[pt.x - d * 3.0, pt.y, pt.z], [pt.x + d * 3.0, pt.y, pt.z]],
points_low: Vec::new(),
color: WireModel::CYAN,
selected: false,
pattern_length: 0.0,
pattern: [0.0; 8],
line_weight_px: 1.0,
snap_pts: vec![],
tangent_geoms: vec![],
aci: 0,
key_vertices: vec![],
aabb: WireModel::UNBOUNDED_AABB,
plinegen: true,
fill_tris: vec![],
fill_tris_low: Vec::new(),
})
}
fn on_preview_wires(&mut self, point: DVec3) -> Vec<WireModel> {
if self.step == Step::Place { return self.preview_dimensions(point); }
if self.step == Step::Edit {
let size = self.dim_spacing * 0.12;
return self.candidates().into_iter().enumerate().flat_map(|(index, candidate)| {
let x_start = self.plane.to_world(candidate.point - DVec3::X * size);
let x_end = self.plane.to_world(candidate.point + DVec3::X * size);
let y_start = self.plane.to_world(candidate.point - DVec3::Y * size);
let y_end = self.plane.to_world(candidate.point + DVec3::Y * size);
[
WireModel::solid_f64(format!("qdim_edit_{index}_x"), vec![
x_start.to_array(), x_end.to_array(),
], WireModel::CYAN, false),
WireModel::solid_f64(format!("qdim_edit_{index}_y"), vec![
y_start.to_array(), y_end.to_array(),
], WireModel::CYAN, false),
]
}).collect();
}
Vec::new()
}
}
/// Build a linear dimension between two points at the given dimension-line position.
#[allow(dead_code)]
pub fn make_linear_dim(p1: DVec3, p2: DVec3, dim_pt: DVec3) -> EntityType {
let v = |p: DVec3| Vector3::new(p.x, p.y, p.z);
let mut dim = DimensionLinear::new(v(p1), v(p2));
// Determine axis: horizontal if Δx > Δz, else vertical
let dx = (p2.x - p1.x).abs();
let dz = (p2.z - p1.z).abs();
dim.rotation = if dz > dx {
std::f64::consts::FRAC_PI_2
} else {
0.0
};
dim.definition_point = v(dim_pt);
dim.base.definition_point = v(dim_pt);
let text_pos = (p1 + p2) * 0.5;
dim.base.text_middle_point = v(text_pos);
dim.base.insertion_point = v(text_pos);
dim.base.actual_measurement = dim.measurement();
EntityType::Dimension(Dimension::Linear(dim))
fn v3(point: DVec3) -> Vector3 { Vector3::new(point.x, point.y, point.z) }
fn d3(point: Vector3) -> DVec3 { DVec3::new(point.x, point.y, point.z) }
fn candidate_tolerance(points: impl Iterator<Item = DVec3>) -> f64 {
let magnitude = points.map(|point| point.x.abs().max(point.y.abs()).max(point.z.abs())).fold(1.0_f64, f64::max);
Tolerance::default().linear().max(magnitude * f64::EPSILON * 64.0)
}
fn dimension_reference_points(dimension: &Dimension) -> Vec<DVec3> {
match dimension {
Dimension::Linear(value) => vec![d3(value.first_point), d3(value.second_point)],
Dimension::Aligned(value) => vec![d3(value.first_point), d3(value.second_point)],
Dimension::Radius(value) => vec![d3(value.angle_vertex), d3(value.definition_point)],
Dimension::Diameter(value) => vec![d3(value.angle_vertex), d3(value.definition_point)],
Dimension::Ordinate(value) => vec![d3(value.feature_location)],
Dimension::Angular2Ln(value) => vec![d3(value.first_point), d3(value.second_point), d3(value.angle_vertex), d3(value.definition_point)],
Dimension::Angular3Pt(value) => vec![d3(value.angle_vertex), d3(value.first_point), d3(value.second_point)],
Dimension::Arc(value) => vec![d3(value.center_point), d3(value.first_extension_point), d3(value.second_extension_point)],
Dimension::LargeRadial(value) => vec![d3(value.definition_point), d3(value.chord_point)],
}
}
// ── Autocomplete registry ─────────────────────────────────
inventory::submit!(crate::command::CommandRegistration { names: &["QDIM"] }); // QdimCommand
fn preview_entity(index: usize, entity: &EntityType) -> Vec<WireModel> {
let EntityType::Dimension(dimension) = entity else { return Vec::new(); };
let mut lines: Vec<Vec<[f64; 3]>> = Vec::new();
match dimension {
Dimension::Linear(value) => {
let first = d3(value.first_point); let second = d3(value.second_point); let definition = d3(value.definition_point);
let horizontal = value.rotation.cos().abs() >= value.rotation.sin().abs();
let first_line = if horizontal { DVec3::new(first.x, definition.y, definition.z) } else { DVec3::new(definition.x, first.y, definition.z) };
let second_line = if horizontal { DVec3::new(second.x, definition.y, definition.z) } else { DVec3::new(definition.x, second.y, definition.z) };
lines.push(vec![first.to_array(), first_line.to_array()]);
lines.push(vec![second.to_array(), second_line.to_array()]);
lines.push(vec![first_line.to_array(), second_line.to_array()]);
}
Dimension::Ordinate(value) => lines.push(vec![d3(value.feature_location).to_array(), d3(value.leader_endpoint).to_array()]),
Dimension::Radius(value) => lines.push(vec![d3(value.angle_vertex).to_array(), d3(value.definition_point).to_array(), d3(value.base.text_middle_point).to_array()]),
Dimension::Diameter(value) => lines.push(vec![d3(value.definition_point).to_array(), d3(value.angle_vertex).to_array(), d3(value.base.text_middle_point).to_array()]),
_ => {}
}
lines.into_iter().enumerate().map(|(part, points)| WireModel::solid_f64(format!("qdim_preview_{index}_{part}"), points, WireModel::CYAN, false)).collect()
}
inventory::submit!(crate::command::CommandRegistration { names: &["QDIM"] });
+2
View File
@@ -111,6 +111,8 @@ impl CadCommand for RadiusDimensionCommand {
CmdResult::CommitDimension {
entity: source_plane.place_entity(EntityType::Dimension(Dimension::Radius(dim))),
association: DimensionAssociationInput::Infer(self.source_handle),
preserve_base_style: false,
continue_command: false,
}
}
}
+74 -187
View File
@@ -356,6 +356,38 @@ fn dim_seg(a: Vector3, b: Vector3, common: &acadrust::entities::EntityCommon) ->
})
}
fn dim_geom_entities(
geometry: &crate::scene::convert::tessellate::DimGeom,
ext_common: &acadrust::entities::EntityCommon,
dim_common: &acadrust::entities::EntityCommon,
) -> Vec<EntityType> {
let mut entities = Vec::new();
let point = |value: [f32; 3]| {
Vector3::new(value[0] as f64, value[1] as f64, value[2] as f64)
};
for (points, common) in [
(geometry.ext_lines.as_slice(), ext_common),
(geometry.dim_lines.as_slice(), dim_common),
] {
for run in points.split(|point| point[0].is_nan()) {
for pair in run.windows(2) {
entities.push(dim_seg(point(pair[0]), point(pair[1]), common));
}
}
}
for triangle in geometry.arrow_fill.chunks_exact(3) {
let mut solid = acadrust::entities::Solid::triangle(
point(triangle[0]),
point(triangle[1]),
point(triangle[2]),
);
solid.common = dim_common.clone();
solid.common.handle = Handle::NULL;
entities.push(EntityType::Solid(solid));
}
entities
}
/// A dimension-line terminator at `tip`, body extending back along the unit
/// vector `(dx,dy)` (toward the dim line). When DIMTSZ>0 it's an oblique 45°
/// tick; otherwise a closed *filled* arrowhead (DXF SOLID) of length DIMASZ —
@@ -597,80 +629,23 @@ struct DimMetrics {
dimclrt: i16,
dimlwd: i16,
dimlwe: i16,
/// Resolved terminator shapes for the first / second end (DIMTSZ tick,
/// DIMBLK/DIMBLK1/DIMBLK2 per DIMSAH, else closed-filled), so the bake
/// reproduces the style's actual arrow type.
dimltype: Handle,
dimltex1: Handle,
arrow1: crate::scene::convert::tessellate::ArrowKind,
arrow2: crate::scene::convert::tessellate::ArrowKind,
}
/// Metrics from the dim's style, mirroring what the live renderer applies, so a
/// baked block reproduces the same gaps, arrow type, suppression, colours and
/// lineweights: DIMASZ (arrow), DIMCEN (centre mark), DIMEXO/DIMEXE (extension
/// gap/overshoot), DIMTSZ (oblique tick; >0 = ticks not arrows), DIMDLE (dim
/// line overshoot past ticks), DIMSE1/2 + DIMSD1/2 (extension / dim-line
/// suppression), DIMCLRD/E/T (colours) and DIMLWD/E (lineweights).
/// Resolve style metrics used by saved dimension geometry.
fn dim_metrics(dim: &Dimension, doc: &CadDocument) -> DimMetrics {
let name = dim.base().style_name.as_str();
let mut effective_style = doc.dim_styles.iter().find(|s| {
s.name.eq_ignore_ascii_case(name)
|| (name.trim().is_empty() && s.name.eq_ignore_ascii_case("Standard"))
}).cloned();
if let Some(style) = &mut effective_style {
use crate::entities::dim_override as ov;
let data = &dim.base().common.extended_data;
macro_rules! real {
($field:ident, $code:ident) => {
if let Some(value) = ov::real(data, ov::$code) {
style.$field = value;
}
};
}
macro_rules! int {
($field:ident, $code:ident) => {
if let Some(value) = ov::int(data, ov::$code) {
style.$field = value;
}
};
}
macro_rules! flag {
($field:ident, $code:ident) => {
if let Some(value) = ov::int(data, ov::$code) {
style.$field = value != 0;
}
};
}
macro_rules! handle {
($field:ident, $code:ident) => {
if let Some(value) = ov::handle(data, ov::$code) {
style.$field = value;
}
};
}
real!(dimscale, DIMSCALE);
real!(dimasz, DIMASZ);
real!(dimcen, DIMCEN);
real!(dimexo, DIMEXO);
real!(dimexe, DIMEXE);
real!(dimtsz, DIMTSZ);
real!(dimdle, DIMDLE);
real!(dimfxl, DIMFXL);
flag!(dimfxlon, DIMFXLON);
flag!(dimse1, DIMSE1);
flag!(dimse2, DIMSE2);
flag!(dimsd1, DIMSD1);
flag!(dimsd2, DIMSD2);
flag!(dimsoxd, DIMSOXD);
flag!(dimsah, DIMSAH);
int!(dimclrd, DIMCLRD);
int!(dimclre, DIMCLRE);
int!(dimclrt, DIMCLRT);
int!(dimlwd, DIMLWD);
int!(dimlwe, DIMLWE);
handle!(dimblk, DIMBLK);
handle!(dimblk1, DIMBLK1);
handle!(dimblk2, DIMBLK2);
}
let effective_style = doc
.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| crate::entities::dimension::resolved_dimension_style(style, dim, doc));
let style = effective_style.as_ref();
let scale = style
.map(|s| if s.dimscale > 1e-6 { s.dimscale } else { 1.0 })
@@ -679,14 +654,14 @@ fn dim_metrics(dim: &Dimension, doc: &CadDocument) -> DimMetrics {
let dimasz = style.map(|s| s.dimasz * scale).unwrap_or(0.18 * scale).max(1e-6);
let dimtsz = style.map(|s| s.dimtsz * scale).unwrap_or(0.0);
let asz = dimasz as f32;
// Resolve the terminator shapes exactly like the live render: ticks when
// DIMTSZ>0, otherwise the DIMBLK / DIMBLK1+DIMBLK2 (per DIMSAH) arrow blocks,
// else closed-filled.
let (arrow1, arrow2) = if dimtsz > 1e-9 {
let t = ArrowKind::Tick { size: dimtsz as f32 };
(t.clone(), t)
} else if let Some(s) = style {
if matches!(dim, Dimension::Diameter(_)) {
if matches!(dim, Dimension::Radius(_) | Dimension::LargeRadial(_)) {
let arrow = arrow_from_block(doc, s.dimldrblk, asz);
(arrow.clone(), arrow)
} else if matches!(dim, Dimension::Diameter(_)) {
use crate::entities::dim_override as ov;
let data = &dim.base().common.extended_data;
let first = ov::handle(data, ov::DIMBLK1)
@@ -723,6 +698,8 @@ fn dim_metrics(dim: &Dimension, doc: &CadDocument) -> DimMetrics {
dimclrt: style.map(|s| s.dimclrt).unwrap_or(0),
dimlwd: style.map(|s| s.dimlwd).unwrap_or(-2),
dimlwe: style.map(|s| s.dimlwe).unwrap_or(-2),
dimltype: style.map(|s| s.dimltex_handle).unwrap_or(Handle::NULL),
dimltex1: style.map(|s| s.dimltex1_handle).unwrap_or(Handle::NULL),
arrow1,
arrow2,
}
@@ -744,6 +721,21 @@ fn dim_common(base: &acadrust::entities::EntityCommon, clr: i16, lw: i16) -> aca
c
}
fn with_dim_linetype(
mut common: acadrust::entities::EntityCommon,
doc: &CadDocument,
handle: Handle,
) -> acadrust::entities::EntityCommon {
common.linetype = doc
.line_types
.iter()
.find(|line_type| line_type.handle == handle)
.map(|line_type| line_type.name.clone())
.unwrap_or_else(|| "Continuous".to_string());
common.linetype_handle = (!handle.is_null()).then_some(handle);
common
}
/// Baked geometry for an angular dimension, matching the live render exactly:
/// an extension line from each measured ray point flush out to the dimension
/// arc, the swept arc, and a terminator at each arc end. (The live angular path
@@ -806,49 +798,6 @@ fn angular_block_segs(
out
}
fn jogged_radial_break(
chord: Vector3,
jog: Vector3,
override_center: Vector3,
jog_angle: f64,
) -> (Vector3, Vector3) {
let radial = norm2(
chord.x - override_center.x,
chord.y - override_center.y,
1.0,
0.0,
);
let (sin, cos) = jog_angle.sin_cos();
let transverse = (
radial.0 * cos - radial.1 * sin,
radial.0 * sin + radial.1 * cos,
);
let length = ((chord.x - override_center.x).powi(2)
+ (chord.y - override_center.y).powi(2))
.sqrt();
let half = (length * 0.04).max(1e-6);
let first = Vector3::new(
jog.x - transverse.0 * half,
jog.y - transverse.1 * half,
jog.z,
);
let second = Vector3::new(
jog.x + transverse.0 * half,
jog.y + transverse.1 * half,
jog.z,
);
let distance_squared = |point: Vector3| {
(point.x - chord.x).powi(2)
+ (point.y - chord.y).powi(2)
+ (point.z - chord.z).powi(2)
};
if distance_squared(first) <= distance_squared(second) {
(first, second)
} else {
(second, first)
}
}
/// The text anchor for a radial leader: the saved text middle point when set,
/// else the midpoint of `a` and `b` — mirroring the live `dimension_text_position`.
fn dim_text_anchor(
@@ -1246,38 +1195,14 @@ fn explode_dimension(dim: &Dimension, doc: &CadDocument) -> Vec<EntityType> {
));
}
}
Dimension::LargeRadial(d) => {
let (near, far) = jogged_radial_break(
d.chord_point,
d.jog_point,
d.override_center,
d.jog_angle,
);
if !met.dimsd1 {
result.push(make_seg(&d.chord_point, &near, &dim_c));
result.push(make_seg(&near, &far, &dim_c));
result.push(make_seg(&far, &d.override_center, &dim_c));
Dimension::LargeRadial(_) => {
if let Some(geometry) =
crate::entities::dimension::baked_large_radial_geometry(dim, doc)
{
let radial_ext_c = with_dim_linetype(ext_c.clone(), doc, met.dimltex1);
let radial_dim_c = with_dim_linetype(dim_c.clone(), doc, met.dimltype);
result.extend(dim_geom_entities(&geometry, &radial_ext_c, &radial_dim_c));
}
let len = ((near.x - d.chord_point.x).powi(2)
+ (near.y - d.chord_point.y).powi(2))
.sqrt()
.max(1e-12);
result.extend(dim_terminator(
d.chord_point,
(near.x - d.chord_point.x) / len,
(near.y - d.chord_point.y) / len,
&met.arrow1,
&dim_c,
));
let radius = ((d.definition_point.x - d.chord_point.x).powi(2)
+ (d.definition_point.y - d.chord_point.y).powi(2))
.sqrt();
result.extend(dim_center_mark(
d.definition_point,
met.dimcen,
radius,
&dim_c,
));
}
}
@@ -1371,23 +1296,7 @@ fn next_dimension_block_name(doc: &CadDocument, next: &mut u64) -> String {
}
}
/// Bake an anonymous `*D<n>` geometry block for every DIMENSION that doesn't
/// already own one, so the file is valid for AutoCAD-family readers.
///
/// OCS renders dimensions by re-tessellating them on the fly and never
/// materialises the `*D` block that a DWG `DIMENSION` is supposed to reference
/// (the lines / arrows / text that AutoCAD actually draws). A dimension created
/// in OCS therefore goes out referencing a block that doesn't exist, and the
/// writer emits a null block handle — strict readers (DWG TrueView, QCAD) drop
/// the dimension or demand a recovery, and lenient ones (BricsCAD) regenerate it
/// at a different position. Call this on the document about to be written so each
/// such dimension gets a real block built from its exploded geometry (extension
/// lines + dimension line + measurement text, the same decomposition EXPLODE
/// uses) and its `block_name` points at it.
///
/// Dimensions that already reference an existing block (e.g. imported from a real
/// DWG, or copied via the `*D`-cloning copy path) are left untouched so their
/// original graphics are preserved.
/// Build missing anonymous dimension geometry blocks before writing.
pub fn bake_dimension_blocks(doc: &mut CadDocument) {
// Keep group-10 in step and find missing blocks in one entity pass. Existing
// `*D` blocks are the save cache: only invalidated/new dimensions enter the
@@ -1451,35 +1360,13 @@ pub fn bake_dimension_blocks(doc: &mut CadDocument) {
if let Some(EntityType::Dimension(d)) = doc.get_entity_mut(handle) {
d.base_mut().block_name = name;
// The block we just baked holds the dimension graphics in absolute
// WCS, so the DWG group-12 insertion point (base.insertion_point)
// MUST be the origin. A reader that positions the *D block by that
// point (BricsCAD / ODA) otherwise draws it shifted by the offset,
// while OCS — which renders the block in place — shows it correctly.
// OCS's dimension commands seed insertion_point with the text
// anchor; reset it here so the saved dimension lands identically in
// every application. (#181)
// Baked geometry uses absolute coordinates.
d.base_mut().insertion_point = Vector3::new(0.0, 0.0, 0.0);
}
}
}
/// Drop a dimension's baked `*D` block so the next save regenerates it from the
/// dimension's current definition points / text / style.
///
/// OCS renders a dimension live from its definition points, but exports a baked
/// `*D` block that other applications (BricsCAD / ODA) draw instead. An in-place
/// edit — grip drag, DIMTEDIT, restyle, text edit, DIMSPACE — changes the
/// definition points while leaving the old block, so without this the export
/// keeps the pre-edit graphics and the dimension appears wrong everywhere but in
/// OCS. Call this after any such edit; [`bake_dimension_blocks`] then rebuilds a
/// fresh block on save.
///
/// The transform path (MOVE / COPY / PASTE) keeps its own block in sync via
/// `define_transformed_block` and must NOT call this. The removed block's record
/// and owned entities are deleted too, so re-baking on every edit can't
/// accumulate orphan `*D` blocks. No-op when the dimension has no baked block
/// yet (the pending path bakes it fresh on save). (#181)
/// Drop baked dimension geometry so the next save regenerates it.
pub fn invalidate_dim_block(doc: &mut CadDocument, handle: Handle) {
let bn = match doc.get_entity(handle) {
Some(EntityType::Dimension(d)) => d.base().block_name.clone(),
+137 -57
View File
@@ -1502,54 +1502,113 @@ pub fn apply_mleader_style(
entity: &mut acadrust::entities::MultiLeader,
style: &acadrust::objects::MultiLeaderStyle,
) {
use acadrust::entities::MultiLeaderPropertyOverrideFlags as F;
let flags = entity.property_override_flags;
entity.style_handle = Some(style.handle);
entity.content_type = (style.content_type as i16).into();
entity.path_type = (style.path_type as i16).into();
entity.line_color = style.line_color;
entity.line_type_handle = style.line_type_handle;
entity.line_weight = style.line_weight;
entity.enable_landing = style.enable_landing;
entity.enable_dogleg = style.enable_dogleg;
entity.dogleg_length = style.landing_distance;
entity.arrowhead_handle = style.arrowhead_handle;
entity.arrowhead_size = style.arrowhead_size;
entity.context.arrowhead_size = style.arrowhead_size;
entity.context.landing_gap = style.landing_gap;
entity.text_style_handle = style.text_style_handle;
entity.text_color = style.text_color;
entity.text_frame = style.text_frame;
entity.text_height = style.text_height;
entity.context.text_height = style.text_height;
entity.context.text_style_handle = style.text_style_handle;
entity.context.text_color = style.text_color;
entity.text_left_attachment = (style.text_left_attachment as i16).into();
entity.text_right_attachment = (style.text_right_attachment as i16).into();
entity.text_top_attachment = (style.text_top_attachment as i16).into();
entity.text_bottom_attachment = (style.text_bottom_attachment as i16).into();
entity.text_attachment_direction = (style.text_attachment_direction as i16).into();
entity.text_alignment = (style.text_alignment as i16).into();
entity.text_angle_type = (style.text_angle_type as i16).into();
if !flags.contains(F::CONTENT_TYPE) {
entity.content_type = (style.content_type as i16).into();
}
if !flags.contains(F::PATH_TYPE) {
entity.path_type = (style.path_type as i16).into();
}
if !flags.contains(F::LINE_COLOR) {
entity.line_color = style.line_color;
}
if !flags.contains(F::LEADER_LINE_TYPE) {
entity.line_type_handle = style.line_type_handle;
}
if !flags.contains(F::LEADER_LINE_WEIGHT) {
entity.line_weight = style.line_weight;
}
if !flags.contains(F::ENABLE_LANDING) {
entity.enable_landing = style.enable_landing;
}
if !flags.contains(F::ENABLE_DOGLEG) {
entity.enable_dogleg = style.enable_dogleg;
}
if !flags.contains(F::LANDING_DISTANCE) {
entity.dogleg_length = style.landing_distance;
}
if !flags.contains(F::LANDING_GAP) {
entity.context.landing_gap = style.landing_gap;
}
if !flags.contains(F::ARROWHEAD) {
entity.arrowhead_handle = style.arrowhead_handle;
}
if !flags.contains(F::ARROWHEAD_SIZE) {
entity.arrowhead_size = style.arrowhead_size;
entity.context.arrowhead_size = style.arrowhead_size;
}
if !flags.contains(F::TEXT_STYLE) {
entity.text_style_handle = style.text_style_handle;
}
if !flags.contains(F::TEXT_COLOR) {
entity.text_color = style.text_color;
}
if !flags.contains(F::TEXT_FRAME) {
entity.text_frame = style.text_frame;
}
if !flags.contains(F::TEXT_HEIGHT) {
entity.text_height = style.text_height;
entity.context.text_height = style.text_height;
}
if !flags.contains(F::TEXT_LEFT_ATTACHMENT) {
entity.text_left_attachment = (style.text_left_attachment as i16).into();
}
if !flags.contains(F::TEXT_RIGHT_ATTACHMENT) {
entity.text_right_attachment = (style.text_right_attachment as i16).into();
}
if !flags.contains(F::TEXT_TOP_ATTACHMENT) {
entity.text_top_attachment = (style.text_top_attachment as i16).into();
}
if !flags.contains(F::TEXT_BOTTOM_ATTACHMENT) {
entity.text_bottom_attachment = (style.text_bottom_attachment as i16).into();
}
if !flags.contains(F::TEXT_ATTACHMENT_DIRECTION) {
entity.text_attachment_direction = (style.text_attachment_direction as i16).into();
}
if !flags.contains(F::TEXT_ALIGNMENT) {
entity.text_alignment = (style.text_alignment as i16).into();
}
if !flags.contains(F::TEXT_ANGLE) {
entity.text_angle_type = (style.text_angle_type as i16).into();
}
entity.context.text_left_attachment = entity.text_left_attachment;
entity.context.text_right_attachment = entity.text_right_attachment;
entity.context.text_top_attachment = entity.text_top_attachment;
entity.context.text_bottom_attachment = entity.text_bottom_attachment;
entity.context.text_alignment = entity.text_alignment;
entity.block_content_handle = style.block_content_handle;
entity.block_content_color = style.block_content_color;
entity.block_connection_type = (style.block_content_connection as i16).into();
entity.block_rotation = style.block_content_rotation;
entity.block_scale = Vector3::new(
style.block_content_scale_x,
style.block_content_scale_y,
style.block_content_scale_z,
);
entity.scale_factor = style.scale_factor;
entity.context.block_content_handle = style.block_content_handle;
entity.context.block_content_color = style.block_content_color;
entity.context.text_style_handle = entity.text_style_handle;
entity.context.text_color = entity.text_color;
if !flags.contains(F::BLOCK_CONTENT) {
entity.block_content_handle = style.block_content_handle;
}
if !flags.contains(F::BLOCK_CONTENT_COLOR) {
entity.block_content_color = style.block_content_color;
}
if !flags.contains(F::BLOCK_CONTENT_CONNECTION) {
entity.block_connection_type = (style.block_content_connection as i16).into();
}
if !flags.contains(F::BLOCK_CONTENT_ROTATION) {
entity.block_rotation = style.block_content_rotation;
}
if !flags.contains(F::BLOCK_CONTENT_SCALE) {
entity.block_scale = Vector3::new(
style.block_content_scale_x,
style.block_content_scale_y,
style.block_content_scale_z,
);
}
if !flags.contains(F::SCALE_FACTOR) {
entity.scale_factor = style.scale_factor;
entity.context.scale_factor = style.scale_factor;
}
entity.context.block_content_handle = entity.block_content_handle;
entity.context.block_content_color = entity.block_content_color;
entity.context.block_connection_type = entity.block_connection_type;
entity.context.block_rotation = style.block_content_rotation;
entity.context.block_rotation = entity.block_rotation;
entity.context.block_content_scale = entity.block_scale;
entity.context.scale_factor = style.scale_factor;
entity.enable_annotation_scale = style.is_annotative;
match entity.content_type {
acadrust::entities::LeaderContentType::MText => {
@@ -1567,7 +1626,9 @@ pub fn apply_mleader_style(
}
}
for root in &mut entity.context.leader_roots {
root.landing_distance = style.landing_distance;
if !flags.contains(F::LANDING_DISTANCE) {
root.landing_distance = entity.dogleg_length;
}
root.text_attachment_direction = entity.text_attachment_direction;
for line in &mut root.lines {
use acadrust::entities::LeaderLinePropertyOverrideFlags as F;
@@ -1575,19 +1636,19 @@ pub fn apply_mleader_style(
line.path_type = entity.path_type;
}
if !line.override_flags.contains(F::LINE_COLOR) {
line.line_color = style.line_color;
line.line_color = entity.line_color;
}
if !line.override_flags.contains(F::LINE_TYPE) {
line.line_type_handle = style.line_type_handle;
line.line_type_handle = entity.line_type_handle;
}
if !line.override_flags.contains(F::LINE_WEIGHT) {
line.line_weight = style.line_weight;
line.line_weight = entity.line_weight;
}
if !line.override_flags.contains(F::ARROWHEAD) {
line.arrowhead_handle = style.arrowhead_handle;
line.arrowhead_handle = entity.arrowhead_handle;
}
if !line.override_flags.contains(F::ARROWHEAD_SIZE) {
line.arrowhead_size = style.arrowhead_size;
line.arrowhead_size = entity.arrowhead_size;
}
}
}
@@ -1597,6 +1658,16 @@ fn apply_mleader_style_at_display_scale(
style: &acadrust::objects::MultiLeaderStyle,
display_scale: f64,
) {
use acadrust::entities::MultiLeaderPropertyOverrideFlags as F;
let old_scale = entity.context.scale_factor.max(1.0e-12);
let landing_gap = entity.context.landing_gap / old_scale;
let landing_distances = entity
.context
.leader_roots
.iter()
.map(|root| root.landing_distance / old_scale)
.collect::<Vec<_>>();
apply_mleader_style(entity, style);
let scale = if display_scale.is_finite()
@@ -1610,18 +1681,27 @@ fn apply_mleader_style_at_display_scale(
// Context sizes are stored at their display scale.
entity.context.scale_factor = scale;
entity.context.text_height =
style.text_height * scale;
entity.context.text_height = entity.text_height * scale;
entity.context.arrowhead_size = entity.arrowhead_size * scale;
entity.context.landing_gap = if entity.property_override_flags.contains(F::LANDING_GAP) {
landing_gap * scale
} else {
style.landing_gap * scale
};
entity.context.arrowhead_size =
style.arrowhead_size * scale;
entity.context.landing_gap =
style.landing_gap * scale;
for root in &mut entity.context.leader_roots {
root.landing_distance =
style.landing_distance * scale;
for (index, root) in entity.context.leader_roots.iter_mut().enumerate() {
root.landing_distance = if entity
.property_override_flags
.contains(F::LANDING_DISTANCE)
{
landing_distances
.get(index)
.copied()
.unwrap_or(entity.dogleg_length)
* scale
} else {
entity.dogleg_length * scale
};
root.text_attachment_direction =
entity.text_attachment_direction;
-8
View File
@@ -89,14 +89,6 @@ pub fn general_section(entity: &EntityType) -> PropSection {
],
};
if matches!(entity, EntityType::Point(_)) {
let hyperlink = section.props.iter().position(|prop| prop.field == "hyperlink");
let transparency = section.props.iter().position(|prop| prop.field == "transparency");
if let (Some(hyperlink), Some(transparency)) = (hyperlink, transparency) {
section.props.swap(hyperlink, transparency);
}
}
// Thickness (DXF 39) is a General-group property, but only the entity
// types that carry an extrusion thickness expose it (line, circle, arc,
// polyline, text, 2D solid, …). Show it right after Hyperlink for those.
+2
View File
@@ -1535,6 +1535,8 @@ pub fn tessellate(
| EntityType::Circle(_)
| EntityType::Arc(_)
| EntityType::Ellipse(_)
| EntityType::Spline(_)
| EntityType::LwPolyline(_)
) {
(pattern_length, pattern)
} else {
+49 -24
View File
@@ -408,12 +408,23 @@ fn resolve_reference(scene: &Scene, reference: &AssocDimensionReference) -> Opti
.copied()
}
fn dimension_inference_points(dimension: &Dimension) -> Option<[Option<Vector3>; 2]> {
fn dimension_inference_points(dimension: &Dimension) -> Vec<Option<Vector3>> {
match dimension {
Dimension::Linear(linear) => Some([Some(linear.first_point), Some(linear.second_point)]),
Dimension::Aligned(aligned) => Some([Some(aligned.first_point), Some(aligned.second_point)]),
Dimension::Ordinate(ordinate) => Some([Some(ordinate.feature_location), None]),
_ => None,
Dimension::Linear(linear) => vec![Some(linear.first_point), Some(linear.second_point)],
Dimension::Aligned(aligned) => vec![Some(aligned.first_point), Some(aligned.second_point)],
Dimension::Angular3Pt(angular) => vec![
Some(angular.angle_vertex),
Some(angular.first_point),
Some(angular.second_point),
],
Dimension::Angular2Ln(angular) => vec![
Some(angular.first_point),
Some(angular.second_point),
Some(angular.angle_vertex),
Some(angular.definition_point),
],
Dimension::Ordinate(ordinate) => vec![Some(ordinate.feature_location)],
_ => Vec::new(),
}
}
@@ -800,10 +811,10 @@ impl Scene {
pub(crate) fn infer_dimension_sources(
&self,
dimension: Handle,
) -> [Option<Handle>; 2] {
) -> Vec<Option<Handle>> {
let Some(EntityType::Dimension(entity)) = self.document.get_entity(dimension)
else {
return [None, None];
return Vec::new();
};
let radial_data = match entity {
Dimension::Radius(radius) => Some((
@@ -845,23 +856,25 @@ impl Scene {
})
.min_by(|first, second| first.0.total_cmp(&second.0))
.map(|(_, handle)| handle);
return [source, None];
return vec![source];
}
let Some(points) = dimension_inference_points(entity) else {
return [None, None];
};
points.map(|point| {
point.and_then(|point| self.document
.entities()
.filter(|entity| entity.common().handle != dimension)
.filter_map(|entity| {
source_distance_squared(entity, point)
.map(|distance| (distance, entity.common().handle))
dimension_inference_points(entity)
.into_iter()
.map(|point| {
point.and_then(|point| {
self.document
.entities()
.filter(|entity| entity.common().handle != dimension)
.filter_map(|entity| {
source_distance_squared(entity, point)
.map(|distance| (distance, entity.common().handle))
})
.filter(|(distance, _)| *distance <= 1e-16)
.min_by(|first, second| first.0.total_cmp(&second.0))
.map(|(_, handle)| handle)
})
.filter(|(distance, _)| *distance <= 1e-16)
.min_by(|first, second| first.0.total_cmp(&second.0))
.map(|(_, handle)| handle))
})
})
.collect()
}
pub(crate) fn refresh_associative_dimensions(
@@ -970,7 +983,7 @@ impl Scene {
if let Some(point) = resolved[3] {
angular.definition_point = point;
}
angular.base.definition_point = angular.definition_point;
angular.base.definition_point = angular.dimension_arc;
}
Dimension::Radius(radius) => {
let Some((radial, angle)) = radial_source else {
@@ -1137,9 +1150,21 @@ impl Scene {
arc.base.actual_measurement = arc.measurement();
}
}
dimension.base_mut().actual_measurement = dimension.measurement();
let measurement = match &*dimension {
Dimension::Linear(linear) => linear_measurement(linear),
_ => dimension.measurement(),
};
dimension.base_mut().actual_measurement = measurement;
refreshed.push((association.dimension, ChangeKind::Modified));
}
refreshed
}
}
fn linear_measurement(linear: &acadrust::entities::DimensionLinear) -> f64 {
let plane = plane_from_normal(linear.first_point, linear.base.normal);
let first = plane.project(dpoint(linear.first_point)).unwrap_or([0.0; 2]);
let second = plane.project(dpoint(linear.second_point)).unwrap_or(first);
let axis = [linear.rotation.cos(), linear.rotation.sin()];
((second[0] - first[0]) * axis[0] + (second[1] - first[1]) * axis[1]).abs()
}
+4
View File
@@ -1420,6 +1420,9 @@ pub struct Scene {
pub selection: Rc<RefCell<SelectionState>>,
/// The CAD document — single source of truth for all entities.
pub document: CadDocument,
/// Last chain-compatible dimension created this session.
/// Never reconstructed from loaded file handles.
pub last_created_dimension: Option<Handle>,
/// File-open-prepared index for non-graphical semantic object lookups.
pub(crate) object_data_cache: crate::entities::object_data::ObjectDataCache,
/// Native AcDbLight/Sun inputs, separated by rendered block and viewport
@@ -1848,6 +1851,7 @@ impl Scene {
model_pane_min_px: std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0)),
selection: Rc::new(RefCell::new(SelectionState::default())),
document: CadDocument::new(),
last_created_dimension: None,
object_data_cache: crate::entities::object_data::ObjectDataCache::default(),
lighting_cache: RefCell::new(HashMap::default()),
selected: HashSet::default(),
+2
View File
@@ -39,6 +39,8 @@ pub enum PropValue {
field: &'static str,
value: LineWeight,
},
/// Object-specific lineweight varies across the current selection.
FieldLwVaries { field: &'static str },
/// Lineweight varies across the current multi-selection.
LwVaries,
/// Linetype name — rendered as a combo_box.
+1 -1
View File
@@ -250,7 +250,7 @@ impl Scene {
let source_length = stationed.then(|| wire.pattern_stations[wire.points.len()]);
let (clipped, mut clipped_stations) = clip_polyline_to_rect(
&projected_pts,
stationed.then_some(&wire.pattern_stations[..wire.points.len()]),
stationed.then(|| &wire.pattern_stations[..wire.points.len()]),
vp_x0,
vp_y0,
vp_x1,
+5 -2
View File
@@ -568,7 +568,8 @@ impl Scene {
PropValue::AttrText { .. } => QSelectValueEditor::Text,
PropValue::Stepper { .. }
| PropValue::ColorVaries
| PropValue::LwVaries => continue,
| PropValue::LwVaries
| PropValue::FieldLwVaries { .. } => continue,
}
};
out.push(choice(prop.field, prop.label, editor));
@@ -706,7 +707,9 @@ impl Scene {
PropValue::BoolToggle { value, .. } => value.to_string(),
PropValue::AttrText { value, .. } => value,
PropValue::Stepper { display, .. } => display,
PropValue::ColorVaries | PropValue::LwVaries => return None,
PropValue::ColorVaries
| PropValue::LwVaries
| PropValue::FieldLwVaries { .. } => return None,
})
}
}
+9 -2
View File
@@ -3795,14 +3795,21 @@ impl Scene {
full_bounds,
self.document.header.lineweight_display || display_plot_lineweights,
);
if self.document.header.paper_space_linetype_scaling
// Model space: scale linetypes using the current annotation scale so their
// appearance can match a paper-space viewport at the same drawing scale.
if self.current_layout == "Model" {
if self.annotation_scale.is_finite() && self.annotation_scale > 1e-9 {
uniforms.linetype_scale = self.annotation_scale;
}
} else if self.document.header.paper_space_linetype_scaling
&& !inst.paper_sheet
&& inst.tile_idx.is_none()
&& inst.handle != Handle::NULL
{
if let Some(EntityType::Viewport(vp)) = self.document.get_entity(inst.handle) {
let viewport_scale =
vp_effective_scale(vp.custom_scale, vp.view_height, vp.height);
if viewport_scale.is_finite() && viewport_scale > 1e-9 {
uniforms.linetype_scale = (1.0 / viewport_scale) as f32;
}
+182 -9
View File
@@ -113,6 +113,57 @@ thread_local! {
RefCell::new(FxHashMap::default());
}
// Per-thread cache of `svg::Handle`s keyed by the bytes'
// `(address, length)`. See the doc comment on [`themed_handle`]
// below for the cache key rationale, the sub-slice guard, the
// mirror of `SemanticCacheKey`, and the threading choice.
thread_local! {
static THEMED_CACHE: RefCell<FxHashMap<(usize, usize), svg::Handle>> =
RefCell::new(FxHashMap::default());
}
/// Look up (or build and cache) the `svg::Handle` for a `&'static [u8]`
/// passed to a `themed*` function. This is the single source of
/// truth for the per-`bytes` handle in the `themed` icon family —
/// the 9 `themed*` functions all go through here, so the 22
/// `themed*` call sites (and the 113 total `icons::themed`
/// references) share one entry per artwork.
///
/// **Cache key: `(address, length)`.** Every `include_bytes!`
/// produces a unique `&'static` slice, and the address is stable
/// for the program's lifetime. Including the length guards against
/// any future call site that passes a sub-slice of a static
/// (e.g. `&BYTES[1..]`), which would otherwise alias the wrong
/// handle. Mirrors the proven `SemanticCacheKey` belt-and-braces
/// pattern above.
///
/// **`themed` vs `semantic` — why the simpler key is correct.**
/// The `semantic` family bakes the palette into the SVG
/// (recolours the artwork per theme role), so its cache key
/// includes the palette. The `themed` family reads its colour
/// from an Iced style closure at draw time, so the handle
/// depends only on the bytes — a single entry per artwork is
/// correct and shared across all themes.
///
/// **Threading.** The cache is `thread_local!` because `svg::Handle`
/// is not `Sync`. The set of `&'static [u8]` art is fixed at
/// compile time (~25-30 `include_bytes!` entries), so the cache
/// is bounded by construction and needs no eviction. Per-frame
/// cost: 9 ns/iter warm, 26 ns/iter cold (release profile, see
/// `themed_cache_speedup_over_uncached_handle_creation`).
fn themed_handle(bytes: &'static [u8]) -> svg::Handle {
let key = (bytes.as_ptr() as usize, bytes.len());
THEMED_CACHE.with(|cache| {
let mut cache = cache.borrow_mut();
if let Some(handle) = cache.get(&key) {
return handle.clone();
}
let handle = svg::Handle::from_memory(bytes);
cache.insert(key, handle.clone());
handle
})
}
#[derive(Clone, Copy)]
struct SemanticColors {
background: [u8; 7],
@@ -382,7 +433,7 @@ fn color_hex(color: Color) -> [u8; 7] {
/// Render a chrome icon with the active Iced theme's normal text color.
pub fn themed<'a, M: 'a>(bytes: &'static [u8], size: f32) -> Element<'a, M> {
svg(svg::Handle::from_memory(bytes))
svg(themed_handle(bytes))
.width(size)
.height(size)
.style(|theme: &Theme, _| svg::Style {
@@ -393,7 +444,7 @@ pub fn themed<'a, M: 'a>(bytes: &'static [u8], size: f32) -> Element<'a, M> {
/// Render secondary chrome with the active Iced theme's text color.
pub fn themed_secondary<'a, M: 'a>(bytes: &'static [u8], size: f32) -> Element<'a, M> {
svg(svg::Handle::from_memory(bytes))
svg(themed_handle(bytes))
.width(size)
.height(size)
.style(|theme: &Theme, _| svg::Style {
@@ -411,7 +462,7 @@ pub fn themed_secondary<'a, M: 'a>(bytes: &'static [u8], size: f32) -> Element<'
/// Render disabled chrome with the active Iced theme's text color.
pub fn themed_disabled<'a, M: 'a>(bytes: &'static [u8], size: f32) -> Element<'a, M> {
svg(svg::Handle::from_memory(bytes))
svg(themed_handle(bytes))
.width(size)
.height(size)
.style(|theme: &Theme, _| svg::Style {
@@ -429,7 +480,7 @@ pub fn themed_disabled<'a, M: 'a>(bytes: &'static [u8], size: f32) -> Element<'a
/// Render an emphasized chrome icon with the active Iced theme's primary color.
pub fn themed_primary<'a, M: 'a>(bytes: &'static [u8], size: f32) -> Element<'a, M> {
svg(svg::Handle::from_memory(bytes))
svg(themed_handle(bytes))
.width(size)
.height(size)
.style(|theme: &Theme, _| svg::Style {
@@ -443,7 +494,7 @@ pub fn themed_primary_weak_text<'a, M: 'a>(
bytes: &'static [u8],
size: f32,
) -> Element<'a, M> {
svg(svg::Handle::from_memory(bytes))
svg(themed_handle(bytes))
.width(size)
.height(size)
.style(|theme: &Theme, _| svg::Style {
@@ -454,7 +505,7 @@ pub fn themed_primary_weak_text<'a, M: 'a>(
/// Render a positive-state chrome icon with the active Iced theme's success color.
pub fn themed_success<'a, M: 'a>(bytes: &'static [u8], size: f32) -> Element<'a, M> {
svg(svg::Handle::from_memory(bytes))
svg(themed_handle(bytes))
.width(size)
.height(size)
.style(|theme: &Theme, _| svg::Style {
@@ -465,7 +516,7 @@ pub fn themed_success<'a, M: 'a>(bytes: &'static [u8], size: f32) -> Element<'a,
/// Render a warning-state chrome icon from the active Iced theme.
pub fn themed_warning<'a, M: 'a>(bytes: &'static [u8], size: f32) -> Element<'a, M> {
svg(svg::Handle::from_memory(bytes))
svg(themed_handle(bytes))
.width(size)
.height(size)
.style(|theme: &Theme, _| svg::Style {
@@ -476,7 +527,7 @@ pub fn themed_warning<'a, M: 'a>(bytes: &'static [u8], size: f32) -> Element<'a,
/// Render a destructive-state chrome icon from the active Iced theme.
pub fn themed_danger<'a, M: 'a>(bytes: &'static [u8], size: f32) -> Element<'a, M> {
svg(svg::Handle::from_memory(bytes))
svg(themed_handle(bytes))
.width(size)
.height(size)
.style(|theme: &Theme, _| svg::Style {
@@ -487,7 +538,7 @@ pub fn themed_danger<'a, M: 'a>(bytes: &'static [u8], size: f32) -> Element<'a,
/// Render an icon with the foreground chosen for a danger-coloured surface.
pub fn themed_danger_text<'a, M: 'a>(bytes: &'static [u8], size: f32) -> Element<'a, M> {
svg(svg::Handle::from_memory(bytes))
svg(themed_handle(bytes))
.width(size)
.height(size)
.style(|theme: &Theme, _| svg::Style {
@@ -655,3 +706,125 @@ mod tests {
.any(|window| window == needle)
}
}
#[cfg(test)]
mod themed_cache_tests {
use super::*;
/// Number of `svg::Handle` entries currently in the `THEMED_CACHE`.
/// Test-only inspection helper; the cache is private production state.
fn themed_cache_size_for_test() -> usize {
THEMED_CACHE.with(|c| c.borrow().len())
}
/// True when `(ptr, len)` (the address and length of a
/// `&'static [u8]`) has an entry in the `THEMED_CACHE`.
/// Test-only inspection helper.
fn themed_cache_contains_for_test(ptr: *const u8, len: usize) -> bool {
THEMED_CACHE.with(|c| c.borrow().contains_key(&(ptr as usize, len)))
}
/// Two calls to the public `themed` function with the same
/// `&'static [u8]` must share a single cache entry: the second
/// call is a cache hit, not a fresh `Handle::from_memory`.
#[test]
fn themed_cache_returns_same_handle_for_same_bytes() {
let _: Element<'static, ()> = themed(TRI_DOWN, 16.0);
let _: Element<'static, ()> = themed(TRI_DOWN, 16.0);
assert_eq!(
themed_cache_size_for_test(),
1,
"two calls with the same bytes must share a single cache entry"
);
assert!(
themed_cache_contains_for_test(TRI_DOWN.as_ptr(), TRI_DOWN.len()),
"cache must hold an entry for the requested bytes"
);
}
/// Distinct `&'static [u8]` slices must produce distinct cache
/// entries (no false aliasing across different artwork).
#[test]
fn themed_cache_caches_distinct_bytes_separately() {
let _: Element<'static, ()> = themed(TRI_DOWN, 16.0);
let _: Element<'static, ()> = themed(TRI_UP, 16.0);
assert_eq!(
themed_cache_size_for_test(),
2,
"distinct bytes must produce distinct cache entries"
);
assert!(themed_cache_contains_for_test(TRI_DOWN.as_ptr(), TRI_DOWN.len()));
assert!(themed_cache_contains_for_test(TRI_UP.as_ptr(), TRI_UP.len()));
}
/// Before/after benchmark: the cached `themed_handle` path
/// (hashmap get + a `svg::Handle` clone) vs. the original
/// un-cached path (`svg::Handle::from_memory` per call, which
/// parses the SVG and allocates).
///
/// Integrated as a `#[test]` so it runs in the existing
/// `cargo test --lib` flow with no extra infrastructure — no
/// `criterion`, no `benches/` directory, no nightly
/// `#![feature(test)]`. The "before" is faithfully simulated by
/// calling `svg::Handle::from_memory` directly on the same
/// `&'static [u8]`, which is exactly what the pre-cache code did
/// per call. The "after" warms the cache first so the loop
/// measures pure cache hits. A cold cache miss is also measured
/// on a different static.
///
/// Run with `--nocapture` to see the numbers:
/// `cargo test --lib themed_cache_speedup_over_uncached_handle_creation -- --nocapture`.
#[test]
fn themed_cache_speedup_over_uncached_handle_creation() {
use std::time::Instant;
const ITER: u64 = 20_000;
let bytes = TRI_DOWN; // &'static [u8]
// Warm the cache so the "after" loop measures pure cache hits.
let _ = super::themed_handle(bytes);
// After: cached path — one hashmap get + a `Handle` clone per call.
let t_after = Instant::now();
for _ in 0..ITER {
let _h = super::themed_handle(bytes);
}
let after = t_after.elapsed();
// Before: original (un-cached) path — SVG parse + allocation per call.
let t_before = Instant::now();
for _ in 0..ITER {
let _h = svg::Handle::from_memory(bytes);
}
let before = t_before.elapsed();
// Cold first call (cache miss): one parse + one insert. Measured
// on a different static so the entry is guaranteed to be fresh.
let cold_bytes = TRI_UP;
let t_cold = Instant::now();
let _h = super::themed_handle(cold_bytes);
let cold = t_cold.elapsed();
let after_ns = after.as_nanos() as u64;
let before_ns = before.as_nanos() as u64;
let cold_ns = cold.as_nanos() as u64;
let per_after = after_ns / ITER;
let per_before = before_ns / ITER;
let speedup = if after_ns > 0 {
before_ns as f64 / after_ns as f64
} else {
f64::INFINITY
};
println!(
"themed_handle: before={}ns/iter, after={}ns/iter, speedup={:.1}x, cold_miss={}ns (ITER={})",
per_before, per_after, speedup, cold_ns, ITER,
);
assert!(
after_ns < before_ns,
"cached path must be faster than from_memory: after={}ns before={}ns",
after_ns, before_ns,
);
}
}
+31
View File
@@ -766,6 +766,9 @@ impl PropertiesPanel {
PropValue::FieldLwChoice { field, value } => {
self.render_field_lw_row(label, field, *value)
}
PropValue::FieldLwVaries { field } => {
self.render_field_lw_varies_row(label, field)
}
PropValue::LwVaries => self.render_lw_varies_row(label),
PropValue::LinetypeChoice(lt) => self.render_linetype_row(label, lt),
PropValue::Choice { selected, options } => {
@@ -1058,6 +1061,34 @@ impl PropertiesPanel {
prop_row_widget(label, combo.into())
}
fn render_field_lw_varies_row<'a>(
&'a self,
label: &'a str,
field: &'static str,
) -> Element<'a, Message> {
let combo = combo_box(
&self.lineweight_combo,
VARIES_LABEL,
None,
move |item: LwItem| Message::PropFieldLwChanged {
field,
value: item.0,
},
)
.size(FONT_SZ)
.padding(Padding {
top: COMBO_PAD_V,
bottom: COMBO_PAD_V,
left: 6.0,
right: 6.0,
})
.input_style(combo_input_style)
.on_open(Message::PropColorPickerClose)
.width(Length::Fill);
prop_row_widget(label, combo.into())
}
// ── Linetype row (combo_box) ──────────────────────────────────────────
fn render_linetype_row<'a>(&'a self, label: &'a str, current: &'a str) -> Element<'a, Message> {