diff --git a/Cargo.lock b/Cargo.lock index fc84a9cbe..0583d5bfe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,7 +4,7 @@ version = 4 [[package]] name = "OpenCADStudio" -version = "0.9.7" +version = "0.9.8" dependencies = [ "acadrust", "ashpd", diff --git a/Cargo.toml b/Cargo.toml index 354d3e263..64b2533c9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "OpenCADStudio" -version = "0.9.7" +version = "0.9.8" edition = "2021" build = "build.rs" diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 0da5ad8c3..47769b7e3 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -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: "

,

,...;,;," - let parts: Vec<&str> = encoded.splitn(3, ';').collect(); - if parts.len() < 3 { - return; - } - let handles: Vec = parts[0] - .split(',') - .filter_map(|s| s.parse::().ok().map(acadrust::Handle::from)) - .collect(); - let from_parts: Vec = parts[1].split(',').filter_map(|s| s.parse().ok()).collect(); - let to_parts: Vec = 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 = parts[0] - .split(',') - .filter_map(|s| s.parse::().ok().map(acadrust::Handle::from)) - .collect(); - let pos_parts: Vec = 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 { + 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 = 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 diff --git a/src/app/commands/dim.rs b/src/app/commands/dim.rs index ad25ac384..0561d3e96 100644 --- a/src/app/commands/dim.rs +++ b/src/app/commands/dim.rs @@ -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 = 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 = 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 = 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) { - 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(); diff --git a/src/app/commands/styleprops.rs b/src/app/commands/styleprops.rs index 39d4cd93c..e738dfe41 100644 --- a/src/app/commands/styleprops.rs +++ b/src/app/commands/styleprops.rs @@ -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::() { + 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() diff --git a/src/app/mod.rs b/src/app/mod.rs index 2aefc8929..a2cd8ba9a 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -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, diff --git a/src/app/properties.rs b/src/app/properties.rs index e27751321..a98e40813 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -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, + 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 { + 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 { 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); + } +} diff --git a/src/app/settings.rs b/src/app/settings.rs index 8b65c73e5..791f4070a 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -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 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, diff --git a/src/app/update/command.rs b/src/app/update/command.rs index 23275c91b..49f50fd08 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -1608,19 +1608,17 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { } 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 { 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 { 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", <); - } - } // 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", <); + } + }); 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 { 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 { } _ => 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 { 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 { } _ => 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 diff --git a/src/app/update/file.rs b/src/app/update/file.rs index bbf906eae..fe960b6ca 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -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; diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index cabf95eaf..469b81411 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -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", <, ); } - } - 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() } diff --git a/src/command.rs b/src/command.rs index d4a2312b3..491672afc 100644 --- a/src/command.rs +++ b/src/command.rs @@ -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, + from: DVec3, + to: DVec3, + }, + /// Merge compatible block multileaders at a picked content point. + CollectMLeaders { + handles: Vec, + point: DVec3, }, /// Commit a Model-tab 3D solid: the acadrust entity (for selection / /// persistence) plus its B-rep (cached for boolean ops + shaded diff --git a/src/entities/dim_override.rs b/src/entities/dim_override.rs index 893c6c42f..9c9214041 100644 --- a/src/entities/dim_override.rs +++ b/src/entities/dim_override.rs @@ -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 { + 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 { match value.trim() { "ByLayer" => Some(-1), diff --git a/src/entities/dimension.rs b/src/entities/dimension.rs index 061dcaa76..c27fc87db 100644 --- a/src/entities/dimension.rs +++ b/src/entities/dimension.rs @@ -111,47 +111,11 @@ fn properties(dim: &Dimension) -> Vec { if let Dimension::LargeRadial(d) = dim { return vec![PropSection { title: t!("Misc").into_owned(), - props: vec![ - Property { - label: t!("Dimension style").into_owned(), - field: "style_name", - value: PropValue::PlainText(d.base.style_name.clone()), - }, - edit(t!("Center X").as_ref(), "definition_x", d.definition_point.x), - edit(t!("Center Y").as_ref(), "definition_y", d.definition_point.y), - edit(t!("Center Z").as_ref(), "definition_z", d.definition_point.z), - edit(t!("Chord X").as_ref(), "chord_x", d.chord_point.x), - edit(t!("Chord Y").as_ref(), "chord_y", d.chord_point.y), - edit(t!("Chord Z").as_ref(), "chord_z", d.chord_point.z), - edit( - t!("Override Center X").as_ref(), - "override_x", - d.override_center.x, - ), - edit( - t!("Override Center Y").as_ref(), - "override_y", - d.override_center.y, - ), - edit( - t!("Override Center Z").as_ref(), - "override_z", - d.override_center.z, - ), - edit(t!("Jog X").as_ref(), "jog_x", d.jog_point.x), - edit(t!("Jog Y").as_ref(), "jog_y", d.jog_point.y), - edit(t!("Jog Z").as_ref(), "jog_z", d.jog_point.z), - edit_angle( - t!("Jog Angle").as_ref(), - "jog_angle", - d.jog_angle.to_degrees(), - ), - ro( - t!("Measurement").as_ref(), - "measurement", - format!("{:.4}", d.measurement()), - ), - ], + props: vec![Property { + label: t!("Dim style").into_owned(), + field: "style_name", + value: PropValue::PlainText(d.base.style_name.clone()), + }], }]; } let compact_linear = match dim { @@ -406,16 +370,19 @@ fn apply_base_prop(base: &mut DimensionBase, field: &str, value: &str) -> bool { // Editing the text position in the properties panel pins it to a // user-defined location (stops following DIMTAD). See #94. "text_x" => { - base.text_user_positioned = true; - assign_f64(value, &mut base.text_middle_point.x) + let changed = assign_f64(value, &mut base.text_middle_point.x); + base.text_user_positioned |= changed; + changed } "text_y" => { - base.text_user_positioned = true; - assign_f64(value, &mut base.text_middle_point.y) + let changed = assign_f64(value, &mut base.text_middle_point.y); + base.text_user_positioned |= changed; + changed } "text_z" => { - base.text_user_positioned = true; - assign_f64(value, &mut base.text_middle_point.z) + let changed = assign_f64(value, &mut base.text_middle_point.z); + base.text_user_positioned |= changed; + changed } "text_rotation" => assign_deg(value, &mut base.text_rotation), "horizontal_direction" => assign_deg(value, &mut base.horizontal_direction), @@ -791,6 +758,26 @@ fn apply_arc_fields(d: &mut DimensionArc, field: &str, value: &str) { } fn apply_large_radial_fields(d: &mut DimensionLargeRadial, field: &str, value: &str) { + if field == "large_radial_rotation" { + let Some(angle) = parse_f64(value).map(f64::to_radians) else { + return; + }; + let delta = angle - large_radial_rotation(d); + let origin = d.definition_point; + let rotate = |point: &mut acadrust::types::Vector3| { + let x = point.x - origin.x; + let y = point.y - origin.y; + let (sin, cos) = delta.sin_cos(); + point.x = origin.x + x * cos - y * sin; + point.y = origin.y + x * sin + y * cos; + }; + rotate(&mut d.chord_point); + rotate(&mut d.override_center); + rotate(&mut d.jog_point); + rotate(&mut d.base.text_middle_point); + rotate(&mut d.base.insertion_point); + return; + } match field { "definition_x" => { let _ = assign_f64(value, &mut d.definition_point.x); @@ -835,6 +822,16 @@ fn apply_large_radial_fields(d: &mut DimensionLargeRadial, field: &str, value: & } } +fn large_radial_rotation(d: &DimensionLargeRadial) -> f64 { + let delta = d.chord_point - d.definition_point; + if delta.x.abs() <= 1.0e-12 && delta.y.abs() <= 1.0e-12 { + let visible = d.chord_point - d.override_center; + visible.y.atan2(visible.x) + } else { + delta.y.atan2(delta.x) + } +} + fn apply_transform(dim: &mut Dimension, t: &EntityTransform) { if matches!(dim, Dimension::Ordinate(_)) { match t { @@ -1701,6 +1698,139 @@ use acadrust::entities::{MText, Text}; use acadrust::tables::DimStyle; use acadrust::types::{Color as AcadColor, Vector3}; +pub(crate) fn resolved_dimension_style( + source: &DimStyle, + dimension: &Dimension, + document: &CadDocument, +) -> DimStyle { + use crate::entities::dim_override as ov; + + let mut style = source.clone(); + let data = &dimension.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!(dimexo, DIMEXO); + real!(dimdli, DIMDLI); + real!(dimexe, DIMEXE); + real!(dimrnd, DIMRND); + real!(dimdle, DIMDLE); + real!(dimtp, DIMTP); + real!(dimtm, DIMTM); + real!(dimfxl, DIMFXL); + real!(dimjogang, DIMJOGANG); + real!(dimtxt, DIMTXT); + real!(dimcen, DIMCEN); + real!(dimtsz, DIMTSZ); + real!(dimaltf, DIMALTF); + real!(dimlfac, DIMLFAC); + real!(dimtvp, DIMTVP); + real!(dimtfac, DIMTFAC); + real!(dimgap, DIMGAP); + real!(dimaltrnd, DIMALTRND); + real!(dimaltmzf, DIMALTMZF); + real!(dimmzf, DIMMZF); + flag!(dimtol, DIMTOL); + flag!(dimlim, DIMLIM); + flag!(dimtih, DIMTIH); + flag!(dimtoh, DIMTOH); + flag!(dimse1, DIMSE1); + flag!(dimse2, DIMSE2); + flag!(dimalt, DIMALT); + flag!(dimtofl, DIMTOFL); + flag!(dimsah, DIMSAH); + flag!(dimtix, DIMTIX); + flag!(dimsoxd, DIMSOXD); + flag!(dimsd1, DIMSD1); + flag!(dimsd2, DIMSD2); + flag!(dimupt, DIMUPT); + flag!(dimfxlon, DIMFXLON); + flag!(dimtxtdirection, DIMTXTDIRECTION); + int!(dimzin, DIMZIN); + int!(dimtad, DIMTAD); + int!(dimazin, DIMAZIN); + int!(dimarcsym, DIMARCSYM); + int!(dimclrd, DIMCLRD); + int!(dimclre, DIMCLRE); + int!(dimclrt, DIMCLRT); + int!(dimadec, DIMADEC); + int!(dimaltd, DIMALTD); + int!(dimdec, DIMDEC); + int!(dimtdec, DIMTDEC); + int!(dimaltu, DIMALTU); + int!(dimalttd, DIMALTTD); + int!(dimaunit, DIMAUNIT); + int!(dimfrac, DIMFRAC); + int!(dimlunit, DIMLUNIT); + int!(dimdsep, DIMDSEP); + int!(dimtmove, DIMTMOVE); + int!(dimjust, DIMJUST); + int!(dimtolj, DIMTOLJ); + int!(dimtzin, DIMTZIN); + int!(dimaltz, DIMALTZ); + int!(dimalttz, DIMALTTZ); + int!(dimatfit, DIMATFIT); + int!(dimtfill, DIMTFILL); + int!(dimtfillclr, DIMTFILLCLR); + int!(dimlwd, DIMLWD); + int!(dimlwe, DIMLWE); + handle!(dimldrblk, DIMLDRBLK); + handle!(dimblk, DIMBLK); + handle!(dimblk1, DIMBLK1); + handle!(dimblk2, DIMBLK2); + handle!(dimltex_handle, DIMLTYPE); + handle!(dimltex1_handle, DIMLTEX1); + handle!(dimltex2_handle, DIMLTEX2); + + if let Some(value) = ov::string(data, ov::DIMPOST) { + style.dimpost = value; + } + if let Some(value) = ov::string(data, ov::DIMAPOST) { + style.dimapost = value; + } + if let Some(value) = ov::string(data, ov::DIMALTMZS) { + style.dimaltmzs = value; + } + if let Some(value) = ov::string(data, ov::DIMMZS) { + style.dimmzs = value; + } + if let Some(value) = ov::handle(data, ov::DIMTXSTY) { + style.dimtxsty_handle = value; + if let Some(record) = document.text_styles.iter().find(|record| record.handle == value) { + style.dimtxsty = record.name.clone(); + } + } + style +} + /// Build the linear-dimension property groups from the assigned style plus /// any entity-level DSTYLE overrides. Conditional rows remain visible but are /// read-only while their controlling option is disabled. @@ -1711,6 +1841,7 @@ pub fn style_sections( ) -> Vec { use crate::entities::dim_override as ov; + let effective_style = resolved_dimension_style(style, dimension, document); let data = &dimension.base().common.extended_data; let real = |code, inherited| ov::real(data, code).unwrap_or(inherited); let int = |code, inherited| ov::int(data, code).unwrap_or(inherited); @@ -1757,7 +1888,7 @@ pub fn style_sections( ) }; - let s = style; + let s = &effective_style; let dimfxlon = int(ov::DIMFXLON, s.dimfxlon as i16) != 0; let dimtix = int(ov::DIMTIX, s.dimtix as i16) != 0; let dimlunit = int(ov::DIMLUNIT, s.dimlunit); @@ -1861,18 +1992,10 @@ pub fn style_sections( linetype_name(document, ov::handle(data, code).unwrap_or(inherited)) }; let arrow_1 = if matches!(dimension, Dimension::Radius(_) | Dimension::LargeRadial(_)) { - let inherited = if s.dimblk1.is_null() { s.dimblk } else { s.dimblk1 }; - let inherited_name = if s.dimblk1.is_null() { - &s.dimblk_name - } else { - &s.dimblk1_name - }; block_name( document, - ov::handle(data, ov::DIMBLK1) - .or_else(|| ov::handle(data, ov::DIMBLK)) - .unwrap_or(inherited), - inherited_name, + ov::handle(data, ov::DIMLDRBLK).unwrap_or(s.dimldrblk), + "Closed filled", ) } else { let inherited = if matches!(dimension, Dimension::Diameter(_)) && !s.dimsah { @@ -1902,7 +2025,7 @@ pub fn style_sections( } } - let precision_options: Vec = (0..=8).map(|value| value.to_string()).collect(); + let precision_options: Vec = (0..=8).map(precision_label).collect(); let linear_units = [ "Scientific", "Decimal", @@ -2149,7 +2272,7 @@ pub fn style_sections( "dim_text_inside_align", on(int(ov::DIMTIH, s.dimtih as i16) != 0), &["On", "Off"], - dimtix, + dimtix || matches!(dimension, Dimension::LargeRadial(_)), ), property( t!("Text position X").as_ref(), @@ -2179,11 +2302,11 @@ pub fn style_sections( t!("Text view direction").as_ref(), "dim_text_view_direction", if int(ov::DIMTXTDIRECTION, s.dimtxtdirection as i16) != 0 { - "Right-to-left" + "Right-to-Left" } else { - "Left-to-right" + "Left-to-Right" }, - &["Left-to-right", "Right-to-left"], + &["Left-to-Right", "Right-to-Left"], true, ), property( @@ -2262,13 +2385,13 @@ pub fn style_sections( true, ), text( - t!("Prefix").as_ref(), + t!("Dim prefix").as_ref(), "dim_prefix", dim_prefix.clone(), true, ), text( - t!("Suffix").as_ref(), + t!("Dim suffix").as_ref(), "dim_suffix", dim_suffix.clone(), true, @@ -2323,20 +2446,20 @@ pub fn style_sections( "dim_suppress_zero_feet", yes(suppresses_zero_feet(dimzin)), &["Yes", "No"], - true, + matches!(dimlunit, 3 | 4), ), choice( t!("Suppress zero inches").as_ref(), "dim_suppress_zero_inches", yes(suppresses_zero_inches(dimzin)), &["Yes", "No"], - true, + matches!(dimlunit, 3 | 4), ), property( t!("Precision").as_ref(), "dim_precision", PropValue::Choice { - selected: int(ov::DIMDEC, s.dimdec).to_string(), + selected: precision_label(int(ov::DIMDEC, s.dimdec)), options: precision_options.clone(), }, ), @@ -2364,11 +2487,11 @@ pub fn style_sections( "dim_alt_precision", if dimalt { PropValue::Choice { - selected: int(ov::DIMALTD, s.dimaltd).to_string(), + selected: precision_label(int(ov::DIMALTD, s.dimaltd)), options: precision_options.clone(), } } else { - PropValue::ReadOnly(int(ov::DIMALTD, s.dimaltd).to_string()) + PropValue::ReadOnly(precision_label(int(ov::DIMALTD, s.dimaltd))) }, ), number( @@ -2384,7 +2507,7 @@ pub fn style_sections( dimalt && dimaltz & 4 != 0, ), number( - t!("Alt roundoff").as_ref(), + t!("Alt round").as_ref(), "dim_alt_roundoff", real(ov::DIMALTRND, s.dimaltrnd), dimalt, @@ -2452,11 +2575,11 @@ pub fn style_sections( "dim_tolerance_precision", if tolerance_enabled { PropValue::Choice { - selected: int(ov::DIMTDEC, s.dimtdec).to_string(), + selected: precision_label(int(ov::DIMTDEC, s.dimtdec)), options: precision_options.clone(), } } else { - PropValue::ReadOnly(int(ov::DIMTDEC, s.dimtdec).to_string()) + PropValue::ReadOnly(precision_label(int(ov::DIMTDEC, s.dimtdec))) }, ), number( @@ -2488,7 +2611,7 @@ pub fn style_sections( t!("Tolerance alignment").as_ref(), "dim_tolerance_alignment", tolerance_alignment_label(int(ov::DIMTALN, 0)), - &["Align decimal separators", "Align operational symbols"], + &["Decimal Separator", "Operational Symbols"], true, ), choice( @@ -2524,11 +2647,11 @@ pub fn style_sections( "dim_alt_tolerance_precision", if alternate_tolerance_enabled { PropValue::Choice { - selected: int(ov::DIMALTTD, s.dimalttd).to_string(), + selected: precision_label(int(ov::DIMALTTD, s.dimalttd)), options: precision_options, } } else { - PropValue::ReadOnly(int(ov::DIMALTTD, s.dimalttd).to_string()) + PropValue::ReadOnly(precision_label(int(ov::DIMALTTD, s.dimalttd))) }, ), choice( @@ -2568,9 +2691,9 @@ pub fn style_sections( ) { let dimcen = real(ov::DIMCEN, s.dimcen); let center_type = if dimcen > 1e-12 { - "Center marks" + "Mark" } else if dimcen < -1e-12 { - "Centerlines" + "Line" } else { "None" }; @@ -2583,7 +2706,7 @@ pub fn style_sections( "dim_arrow_size", "dim_line_lineweight", "dim_ext_line_lineweight", - "dim_line_1", + "dim_line_2", "dim_line_color", "dim_linetype", "dim_ext_linetype_1", @@ -2612,9 +2735,15 @@ pub fn style_sections( "dim_arrowhead_1", "dim_arrow_size", "dim_line_lineweight", - "dim_line_1", + "dim_ext_line_lineweight", + "dim_line_2", "dim_line_color", "dim_linetype", + "dim_ext_linetype_1", + "dim_ext_line_1", + "dim_ext_line_color", + "dim_ext_line_ext", + "dim_ext_line_offset", ]; let retained = if matches!(dimension, Dimension::Diameter(_)) { DIAMETER_LINE_FIELDS @@ -2630,7 +2759,7 @@ pub fn style_sections( t!("Center mark").as_ref(), "dim_center_type", center_type, - &["None", "Center marks", "Centerlines"], + &["None", "Mark", "Line"], true, )); lines.props.push(number( @@ -2639,6 +2768,87 @@ pub fn style_sections( dimcen.abs(), center_type != "None", )); + if let Dimension::LargeRadial(radial) = dimension { + lines.props.push(edit( + t!("Center location override X").as_ref(), + "override_x", + radial.override_center.x, + )); + lines.props.push(edit( + t!("Center location override Y").as_ref(), + "override_y", + radial.override_center.y, + )); + lines.props.push(edit( + t!("Jog location X").as_ref(), + "jog_x", + radial.jog_point.x, + )); + lines.props.push(edit( + t!("Jog location Y").as_ref(), + "jog_y", + radial.jog_point.y, + )); + lines.props.push(edit_angle( + t!("Jog angle").as_ref(), + "jog_angle", + radial.jog_angle.to_degrees(), + )); + } + if matches!(dimension, Dimension::Radius(_) | Dimension::LargeRadial(_)) { + if let Some(arrow) = lines + .props + .iter_mut() + .find(|property| property.field == "dim_arrowhead_1") + { + arrow.label = t!("Arrow").into_owned(); + arrow.field = "dim_radial_arrow"; + } + if let Some(dim_line) = lines + .props + .iter_mut() + .find(|property| property.field == "dim_line_2") + { + dim_line.label = t!("Dim line").into_owned(); + } + } + if matches!(dimension, Dimension::LargeRadial(_)) { + for property in &mut lines.props { + property.label = match property.field { + "dim_ext_line_1" => t!("Ext line").into_owned(), + "dim_ext_line_lineweight" => t!("Ext line weight").into_owned(), + "dim_ext_linetype_1" => t!("Ext line type").into_owned(), + _ => property.label.clone(), + }; + } + const LARGE_RADIAL_ORDER: &[&str] = &[ + "dim_radial_arrow", + "dim_arrow_size", + "dim_center_type", + "dim_center_size", + "dim_line_lineweight", + "dim_line_2", + "dim_linetype", + "dim_line_color", + "override_x", + "override_y", + "jog_x", + "jog_y", + "dim_ext_line_1", + "jog_angle", + "dim_ext_line_lineweight", + "dim_ext_linetype_1", + "dim_ext_line_ext", + "dim_ext_line_color", + "dim_ext_line_offset", + ]; + lines.props.sort_by_key(|property| { + LARGE_RADIAL_ORDER + .iter() + .position(|field| *field == property.field) + .unwrap_or(LARGE_RADIAL_ORDER.len()) + }); + } } if let Some(primary_units) = sections .iter_mut() @@ -2651,6 +2861,134 @@ pub fn style_sections( ) }); } + if matches!(dimension, Dimension::LargeRadial(_)) { + for (section_name, excluded) in [ + (t!("Text"), &["dim_text_pos_hor"][..]), + (t!("Fit"), &["dim_line_inside"][..]), + ( + t!("Alternate Units"), + &["dim_alt_sub_units_scale", "dim_alt_sub_units_suffix"][..], + ), + ] { + if let Some(section) = sections + .iter_mut() + .find(|section| section.title == section_name.as_ref()) + { + section + .props + .retain(|property| !excluded.contains(&property.field)); + } + } + for (section_name, fields) in [ + ( + t!("Text"), + &["dim_text_outside_align", "dim_text_pos_vert"][..], + ), + (t!("Fit"), &["dim_fit", "dim_text_inside"][..]), + ] { + if let Some(section) = sections + .iter_mut() + .find(|section| section.title == section_name.as_ref()) + { + for property in &mut section.props { + if fields.contains(&property.field) { + if let PropValue::Choice { selected, .. } = &property.value { + property.value = PropValue::ReadOnly(selected.clone()); + } + } + } + } + } + if let Some(fit) = sections + .iter_mut() + .find(|section| section.title == t!("Fit").as_ref()) + { + const LARGE_RADIAL_FIT_ORDER: &[&str] = &[ + "dim_line_forced", + "dim_scale_overall", + "dim_fit", + "dim_text_inside", + "dim_text_movement", + ]; + fit.props.sort_by_key(|property| { + LARGE_RADIAL_FIT_ORDER + .iter() + .position(|field| *field == property.field) + .unwrap_or(LARGE_RADIAL_FIT_ORDER.len()) + }); + } + if let Some(alternate_units) = sections + .iter_mut() + .find(|section| section.title == t!("Alternate Units").as_ref()) + { + const LARGE_RADIAL_ALT_UNITS_ORDER: &[&str] = &[ + "dim_alt_enabled", + "dim_alt_format", + "dim_alt_precision", + "dim_alt_roundoff", + "dim_alt_scale_factor", + "dim_alt_suppress_leading_zeros", + "dim_alt_suppress_trailing_zeros", + "dim_alt_suppress_zero_feet", + "dim_alt_suppress_zero_inches", + "dim_alt_prefix", + "dim_alt_suffix", + ]; + alternate_units.props.sort_by_key(|property| { + LARGE_RADIAL_ALT_UNITS_ORDER + .iter() + .position(|field| *field == property.field) + .unwrap_or(LARGE_RADIAL_ALT_UNITS_ORDER.len()) + }); + } + if let Some(tolerances) = sections + .iter_mut() + .find(|section| section.title == t!("Tolerances").as_ref()) + { + const LARGE_RADIAL_TOLERANCE_ORDER: &[&str] = &[ + "dim_alt_tolerance_suppress_zero_inches", + "dim_tolerance_alignment", + "dim_tolerance_display", + "dim_tolerance_limit_lower", + "dim_tolerance_limit_upper", + "dim_tolerance_pos_vert", + "dim_tolerance_precision", + "dim_tolerance_suppress_leading_zeros", + "dim_tolerance_suppress_trailing_zeros", + "dim_tolerance_suppress_zero_feet", + "dim_tolerance_suppress_zero_inches", + "dim_tolerance_text_height", + "dim_alt_tolerance_precision", + "dim_alt_tolerance_suppress_leading_zeros", + "dim_alt_tolerance_suppress_trailing_zeros", + "dim_alt_tolerance_suppress_zero_feet", + ]; + tolerances.props.sort_by_key(|property| { + LARGE_RADIAL_TOLERANCE_ORDER + .iter() + .position(|field| *field == property.field) + .unwrap_or(LARGE_RADIAL_TOLERANCE_ORDER.len()) + }); + } + if !dimension.base().text_user_positioned { + let text_position = styled_dimension_text_position(dimension, s, 1.0); + for section in &mut sections { + for property in &mut section.props { + match property.field { + "text_x" => { + property.value = + PropValue::EditText(format!("{:.4}", text_position.x)); + } + "text_y" => { + property.value = + PropValue::EditText(format!("{:.4}", text_position.y)); + } + _ => {} + } + } + } + } + } } if matches!(dimension, Dimension::Arc(_)) { @@ -2801,6 +3139,55 @@ pub fn style_sections( sections } +fn dimension_style_scale(style: &DimStyle, fallback: f64) -> f64 { + if style.dimscale > 1.0e-6 { + style.dimscale + } else { + fallback + } +} + +fn styled_dimension_text_position( + dimension: &Dimension, + style: &DimStyle, + fallback_scale: f64, +) -> Vector3 { + let scale = dimension_style_scale(style, fallback_scale); + dimension_text_pos_f64(dimension, Some(style), style.dimtxt * scale, scale) +} + +pub(crate) fn materialize_large_radial_text_position( + document: &mut CadDocument, + handle: Handle, + value: &str, +) { + if parse_f64(value).is_none() { + return; + } + let Some(EntityType::Dimension(dimension)) = document.get_entity(handle) else { + return; + }; + if !matches!(dimension, Dimension::LargeRadial(_)) + || dimension.base().text_user_positioned + { + return; + } + let source = document.dim_styles.iter().find(|style| { + style.name.eq_ignore_ascii_case(&dimension.base().style_name) + || (dimension.base().style_name.trim().is_empty() + && style.name.eq_ignore_ascii_case("Standard")) + }); + let position = source + .map(|style| resolved_dimension_style(style, dimension, document)) + .map(|style| styled_dimension_text_position(dimension, &style, 1.0)) + .unwrap_or_else(|| dimension_text_pos_f64(dimension, None, 2.5, 1.0)); + if let Some(EntityType::Dimension(Dimension::LargeRadial(radial))) = + document.get_entity_mut(handle) + { + radial.base.text_middle_point = position; + } +} + fn property(label: &str, field: &'static str, value: PropValue) -> Property { Property { label: label.to_string(), @@ -2899,11 +3286,20 @@ fn tolerance_vertical_label(value: i16) -> &'static str { } } +fn precision_label(value: i16) -> String { + let decimals = value.clamp(0, 8) as usize; + if decimals == 0 { + "0".to_string() + } else { + format!("0.{}", "0".repeat(decimals)) + } +} + fn tolerance_alignment_label(value: i16) -> &'static str { if value == 0 { - "Align decimal separators" + "Decimal Separator" } else { - "Align operational symbols" + "Operational Symbols" } } @@ -2953,8 +3349,8 @@ use acadrust::{CadDocument, EntityType, Handle}; use crate::scene::convert::tess_util::aci_to_rgba; use crate::scene::convert::tessellate::{ - add_polyline, add_segment, append_arrow, arrow_from_block_with_deferred_hatch, - normalized_or, ArrowKind, DimGeom, + add_polyline, add_segment, append_arrow, arrow_from_block, + arrow_from_block_with_deferred_hatch, normalized_or, ArrowKind, DimGeom, }; use crate::scene::model::wire_model::{SnapHint, TangentGeom, WireModel}; @@ -3173,130 +3569,7 @@ fn tessellate_dimension_inner( s.name.eq_ignore_ascii_case(style_name) || (style_name.trim().is_empty() && s.name.eq_ignore_ascii_case("Standard")) }); - let mut effective_style = source_style.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!(dimexo, DIMEXO); - real!(dimdli, DIMDLI); - real!(dimexe, DIMEXE); - real!(dimrnd, DIMRND); - real!(dimdle, DIMDLE); - real!(dimtp, DIMTP); - real!(dimtm, DIMTM); - real!(dimfxl, DIMFXL); - real!(dimjogang, DIMJOGANG); - real!(dimtxt, DIMTXT); - real!(dimcen, DIMCEN); - real!(dimtsz, DIMTSZ); - real!(dimaltf, DIMALTF); - real!(dimlfac, DIMLFAC); - real!(dimtvp, DIMTVP); - real!(dimtfac, DIMTFAC); - real!(dimgap, DIMGAP); - real!(dimaltrnd, DIMALTRND); - real!(dimaltmzf, DIMALTMZF); - real!(dimmzf, DIMMZF); - flag!(dimtol, DIMTOL); - flag!(dimlim, DIMLIM); - flag!(dimtih, DIMTIH); - flag!(dimtoh, DIMTOH); - flag!(dimse1, DIMSE1); - flag!(dimse2, DIMSE2); - flag!(dimalt, DIMALT); - flag!(dimtofl, DIMTOFL); - flag!(dimsah, DIMSAH); - flag!(dimtix, DIMTIX); - flag!(dimsoxd, DIMSOXD); - flag!(dimsd1, DIMSD1); - flag!(dimsd2, DIMSD2); - flag!(dimupt, DIMUPT); - flag!(dimfxlon, DIMFXLON); - flag!(dimtxtdirection, DIMTXTDIRECTION); - int!(dimzin, DIMZIN); - int!(dimtad, DIMTAD); - int!(dimazin, DIMAZIN); - int!(dimarcsym, DIMARCSYM); - int!(dimclrd, DIMCLRD); - int!(dimclre, DIMCLRE); - int!(dimclrt, DIMCLRT); - int!(dimadec, DIMADEC); - int!(dimaltd, DIMALTD); - int!(dimdec, DIMDEC); - int!(dimtdec, DIMTDEC); - int!(dimaltu, DIMALTU); - int!(dimalttd, DIMALTTD); - int!(dimaunit, DIMAUNIT); - int!(dimfrac, DIMFRAC); - int!(dimlunit, DIMLUNIT); - int!(dimdsep, DIMDSEP); - int!(dimtmove, DIMTMOVE); - int!(dimjust, DIMJUST); - int!(dimtolj, DIMTOLJ); - int!(dimtzin, DIMTZIN); - int!(dimaltz, DIMALTZ); - int!(dimalttz, DIMALTTZ); - int!(dimatfit, DIMATFIT); - int!(dimtfill, DIMTFILL); - int!(dimtfillclr, DIMTFILLCLR); - int!(dimlwd, DIMLWD); - int!(dimlwe, DIMLWE); - handle!(dimldrblk, DIMLDRBLK); - handle!(dimblk, DIMBLK); - handle!(dimblk1, DIMBLK1); - handle!(dimblk2, DIMBLK2); - handle!(dimltex_handle, DIMLTYPE); - handle!(dimltex1_handle, DIMLTEX1); - handle!(dimltex2_handle, DIMLTEX2); - if let Some(value) = ov::string(data, ov::DIMPOST) { - style.dimpost = value; - } - if let Some(value) = ov::string(data, ov::DIMAPOST) { - style.dimapost = value; - } - if let Some(value) = ov::string(data, ov::DIMALTMZS) { - style.dimaltmzs = value; - } - if let Some(value) = ov::string(data, ov::DIMMZS) { - style.dimmzs = value; - } - if let Some(value) = ov::handle(data, ov::DIMTXSTY) { - style.dimtxsty_handle = value; - if let Some(record) = document.text_styles.iter().find(|record| record.handle == value) { - style.dimtxsty = record.name.clone(); - } - } - } + let effective_style = source_style.map(|style| resolved_dimension_style(style, dim, document)); let style = effective_style.as_ref(); // A positive style scale is fixed. A zero style scale uses the multiplier @@ -3353,9 +3626,10 @@ fn tessellate_dimension_inner( )); // Arrow selection precedence: - // 1. DIMTSZ>0 → oblique tick (overrides DIMBLK*). - // 2. DIMSAH false → DIMBLK on both ends. - // 3. DIMSAH true → DIMBLK1 (first end), DIMBLK2 (second end). + // 1. DIMTSZ>0 → oblique tick (overrides arrow blocks). + // 2. Radius and large-radius dimensions → DIMLDRBLK. + // 3. DIMSAH false → DIMBLK on both ends. + // 4. DIMSAH true → DIMBLK1 (first end), DIMBLK2 (second end). // Unknown / NULL block handles fall back to ClosedFilled. let dimasz = (dimasz_raw as f32).max(0.001); let defer_arrow_hatches = { @@ -3374,8 +3648,12 @@ fn tessellate_dimension_inner( }; (t.clone(), t) } else if let Some(s) = style { - if matches!(dim, Dimension::Radius(_)) { - let handle = if s.dimblk1.is_null() { s.dimblk } else { s.dimblk1 }; + if matches!(dim, Dimension::Radius(_) | Dimension::LargeRadial(_)) { + let handle = crate::entities::dim_override::handle( + &dim.base().common.extended_data, + crate::entities::dim_override::DIMLDRBLK, + ) + .unwrap_or(s.dimldrblk); let arrow = arrow_from_block_with_deferred_hatch( document, handle, @@ -3442,37 +3720,7 @@ fn tessellate_dimension_inner( (a.clone(), a) }; - // Text box (local space) so the dim line can be broken where the text - // crosses it — lets a DIMTFILL background sit over the line. The renderer - // draws 2D fills under all wires, so the line is gapped rather than masked. - let dimgap_local = style - .map(|s| (s.dimgap.abs() * dim_scale) as f32) - .unwrap_or(0.09); - let text_width = dimension_text_value(dim, style) - .map(|text| text.chars().count() as f32 * dim_txt as f32 * 0.6 + dimgap_local * 2.0) - .unwrap_or(0.0); - let text_break = { - let tp = vec3_local(dimension_text_pos_f64(dim, style, dim_txt, dim_scale)); - let tw = (text_width - dimgap_local * 2.0).max(0.0); - if tw > 0.0 { - // Vertical threshold is the bare text half-height (no DIMGAP): the - // line only breaks when it actually passes under the glyphs. Text - // placed above/below the line (DIMTAD 1/4) sits exactly - // `text_half + DIMGAP` away, so excluding the gap here keeps it - // strictly outside and the line continuous — otherwise the two - // terms cancel at the same scaled value and the gap flickers with - // DIMGAP/DIMSCALE. The horizontal half-width keeps DIMGAP so a - // genuine break still clears the text comfortably. (#94) - Some((tp, tw * 0.5 + dimgap_local, dim_txt as f32 * 0.5)) - } else { - None - } - }; - let text_position = vec3_local(dimension_text_pos_f64(dim, style, dim_txt, dim_scale)); - let text_is_outside = dimension_text_is_outside(dim, style); - let horizontal_text = style.is_some_and(|style| { - (text_is_outside && style.dimtoh) || (!text_is_outside && style.dimtih) - }); + let text_layout = dimension_text_layout(dim, style, dim_txt, dim_scale); let mut geom = dimension_geometry( dim, @@ -3488,13 +3736,14 @@ fn tessellate_dimension_inner( dimcen, ticks: dimtsz_raw > 1e-9, arrow_len: dimasz, - text_width, + text_width: text_layout.width, dimatfit: style.map(|s| s.dimatfit).unwrap_or(3), + dimtix: style.is_some_and(|s| s.dimtix), dimtofl: style.map(|s| s.dimtofl).unwrap_or(false), - text_position, - horizontal_text, + text_position: text_layout.position, + horizontal_text: text_layout.horizontal, text_movement: style.map(|s| s.dimtmove).unwrap_or(0), - text_break, + text_break: text_layout.break_box, }, SuppressFlags { ext1: dimse1, @@ -3516,15 +3765,18 @@ fn tessellate_dimension_inner( } } - // DIMTMOVE = 1: when the saved text_middle_point sits far from the - // dim-line anchor, draw a short leader connecting them. (=0 anchors text - // to the dim line — no leader; =2 frees text without a leader.) + // DIMTMOVE=1 connects the dimension line to rendered text. if let Some(s) = style { if s.dimtmove == 1 { - if let Some((anchor, txt)) = dimtmove_leader_endpoints(dim) { + if let Some((anchor, txt)) = dimtmove_leader_endpoints(dim, text_layout.position) { let gap = dim_txt as f32 * 0.5; if (txt - anchor).length() > gap * 2.0 { - add_segment(&mut geom.dim_lines, anchor, txt); + add_segment_with_text_break( + &mut geom.dim_lines, + anchor, + txt, + text_layout.break_box, + ); } } } @@ -4008,16 +4260,8 @@ fn split_ext_lines(points: &[[f32; 3]]) -> (Vec<[f32; 3]>, Vec<[f32; 3]>) { (first, rest) } -/// Endpoints for the DIMTMOVE=1 leader: (anchor on the dim line, saved -/// text_middle_point). Returns None when the dim has no saved text position -/// or has no well-defined dim-line midpoint (radius/diameter handled by -/// their own leg). -fn dimtmove_leader_endpoints(dim: &Dimension) -> Option<(Vec3, Vec3)> { - let base = dim.base(); - let txt = base.text_middle_point; - if txt.x * txt.x + txt.y * txt.y + txt.z * txt.z <= 1e-16 { - return None; - } +/// Endpoints for the DIMTMOVE=1 leader. +fn dimtmove_leader_endpoints(dim: &Dimension, txt: Vec3) -> Option<(Vec3, Vec3)> { let lv = |v| vec3_local(v); let anchor = match dim { Dimension::Linear(d) => { @@ -4043,7 +4287,7 @@ fn dimtmove_leader_endpoints(dim: &Dimension) -> Option<(Vec3, Vec3)> { Dimension::Diameter(d) => { let chord = lv(d.angle_vertex); let far_chord = lv(d.definition_point); - if chord.distance_squared(lv(txt)) <= far_chord.distance_squared(lv(txt)) { + if chord.distance_squared(txt) <= far_chord.distance_squared(txt) { chord } else { far_chord @@ -4051,9 +4295,29 @@ fn dimtmove_leader_endpoints(dim: &Dimension) -> Option<(Vec3, Vec3)> { } Dimension::Angular2Ln(d) => lv(d.dimension_arc), Dimension::Angular3Pt(d) => lv(d.definition_point), + Dimension::LargeRadial(d) => { + let chord = lv(d.chord_point); + let jog = lv(d.jog_point); + let override_center = lv(d.override_center); + let (near, far) = + jogged_radial_break(chord, jog, override_center, d.jog_angle as f32); + let curves = [(chord, near), (near, far), (far, override_center)].map( + |(start, end)| { + cadkernel::geom2d::Curve::Line(cadkernel::geom2d::Line { + start: [start.x as f64, start.y as f64], + end: [end.x as f64, end.y as f64], + }) + }, + ); + let (_, closest) = cadkernel::geom2d::nearest_of( + curves.iter(), + [txt.x as f64, txt.y as f64], + )?; + Vec3::new(closest.point[0] as f32, closest.point[1] as f32, chord.z) + } _ => return None, }; - Some((anchor, lv(txt))) + Some((anchor, txt)) } /// Build a rectangle of filled triangles sitting under the dim text, used @@ -4156,6 +4420,61 @@ struct SuppressFlags { dim2: bool, } +#[derive(Clone, Copy)] +struct TextBreak { + center: Vec3, + half_width: f32, + half_height: f32, + padding: f32, + rotation: f64, +} + +#[derive(Clone, Copy)] +struct DimensionTextLayout { + position: Vec3, + width: f32, + break_box: Option, + horizontal: bool, +} + +fn dimension_text_layout( + dimension: &Dimension, + style: Option<&DimStyle>, + text_height: f64, + dim_scale: f64, +) -> DimensionTextLayout { + let gap = style + .map(|style| (style.dimgap.abs() * dim_scale) as f32) + .unwrap_or(0.09); + let width = dimension_text_value(dimension, style) + .map(|text| text.chars().count() as f32 * text_height as f32 * 0.6 + gap * 2.0) + .unwrap_or(0.0); + let position = vec3_local(dimension_text_pos_f64( + dimension, + style, + text_height, + dim_scale, + )); + let bare_width = (width - gap * 2.0).max(0.0); + let break_box = (bare_width > 0.0).then_some(TextBreak { + center: position, + half_width: bare_width * 0.5, + half_height: text_height as f32 * 0.5, + padding: gap, + rotation: dimension_text_rotation(dimension, style), + }); + let outside = dimension_text_is_outside(dimension, style); + let horizontal = style.is_some_and(|style| { + (outside && style.dimtoh) || (!outside && style.dimtih) + }); + DimensionTextLayout { + position, + width, + break_box, + horizontal, + } +} + #[derive(Clone, Copy)] struct DimLineParams { dimexo: f32, @@ -4170,14 +4489,12 @@ struct DimLineParams { arrow_len: f32, text_width: f32, dimatfit: i16, + dimtix: bool, dimtofl: bool, text_position: Vec3, horizontal_text: bool, text_movement: i16, - /// Text box (local centre, half-width, half-height) used to break the - /// dimension line where the text sits on it, so a DIMTFILL background reads - /// over the line. None when the text doesn't overlap the line. - text_break: Option<(Vec3, f32, f32)>, + text_break: Option, } fn dimension_geometry( dim: &Dimension, @@ -4245,17 +4562,17 @@ fn dimension_geometry( let mid = center + u * (dist * 0.5); let a = mid - u * half + perp * half; let b = mid + u * half - perp * half; - if !suppress.dim1 { + if !suppress.dim2 { add_segment(&mut g.dim_lines, center, a); add_segment(&mut g.dim_lines, a, b); add_segment(&mut g.dim_lines, b, point); } - } else if !suppress.dim1 { + } else if !suppress.dim2 { add_segment(&mut g.dim_lines, center, point); } let radius = (point - center).length(); let text_is_outside = text.distance(center) > radius + 1e-5; - if text_is_outside && !suppress.dim1 { + if text_is_outside && !suppress.dim2 { let radial = normalized_or(point - center, Vec3::X); let angle_from_horizontal = radial.y.abs().atan2(radial.x.abs()); if params.horizontal_text @@ -4280,12 +4597,14 @@ fn dimension_geometry( add_segment(&mut g.dim_lines, point, text); } } - append_arrow( - &mut g, - point, - normalized_or(center - point, Vec3::X), - arrow1, - ); + if !suppress.dim2 { + append_arrow( + &mut g, + point, + normalized_or(center - point, Vec3::X), + arrow1, + ); + } if text_is_outside { append_center_mark(&mut g, center, params.dimcen, radius); } @@ -4411,28 +4730,131 @@ fn dimension_geometry( let override_center = lv(d.override_center); let (near, far) = jogged_radial_break(chord, jog, override_center, d.jog_angle as f32); - if !suppress.dim1 { - add_segment(&mut g.dim_lines, chord, near); - add_segment(&mut g.dim_lines, near, far); - add_segment(&mut g.dim_lines, far, override_center); + let axis = normalized_or(chord - override_center, Vec3::X); + let available = chord.distance(override_center); + let arrows_outside = if params.ticks || params.arrow_len <= 1.0e-6 { + false + } else if available < params.arrow_len { + true + } else if available < params.text_width + params.arrow_len { + if params.dimtix { + true + } else { + match params.dimatfit { + 0 | 1 => true, + 2 => false, + _ => params.text_width <= available, + } + } + } else { + false + }; + let true_center = lv(d.definition_point); + let radius = chord.distance(true_center); + let text_outside = params.text_position.distance(true_center) > radius + 1.0e-5; + let draw_inside_line = !arrows_outside || params.dimtofl; + if draw_inside_line && !suppress.dim2 { + add_segment_with_text_break(&mut g.dim_lines, chord, near, params.text_break); + add_segment_with_text_break(&mut g.dim_lines, near, far, params.text_break); + add_segment_with_text_break( + &mut g.dim_lines, + far, + override_center, + params.text_break, + ); + } + if arrows_outside && !suppress.dim2 { + add_segment( + &mut g.dim_lines, + chord, + chord + axis * (params.arrow_len * 2.0), + ); + } + if !suppress.dim2 { + append_arrow( + &mut g, + chord, + if arrows_outside { axis } else { -axis }, + arrow1, + ); + } + if text_outside && params.text_movement == 0 && !suppress.dim2 { + add_segment_with_text_break( + &mut g.dim_lines, + chord, + params.text_position, + params.text_break, + ); + } + if arrows_outside || text_outside { + append_center_mark(&mut g, true_center, params.dimcen, radius); } - append_arrow( - &mut g, - chord, - normalized_or(near - chord, Vec3::X), - arrow1, - ); - append_center_mark( - &mut g, - lv(d.definition_point), - params.dimcen, - (chord - lv(d.definition_point)).length(), - ); } } g } +fn add_segment_with_text_break( + points: &mut Vec<[f32; 3]>, + start: Vec3, + end: Vec3, + text_break: Option, +) { + let segment = end - start; + let length = segment.length(); + if length <= 1.0e-6 { + return; + } + let Some(text_break) = text_break else { + add_segment(points, start, end); + return; + }; + let segment_curve = cadkernel::geom2d::Curve::Line(cadkernel::geom2d::Line { + start: [start.x as f64, start.y as f64], + end: [end.x as f64, end.y as f64], + }); + let tolerance = cadkernel::geom2d::Tolerance::new((length as f64 * 1.0e-9).max(1.0e-9)); + let inside = |padding: f32| { + let (sin, cos) = text_break.rotation.sin_cos(); + let x_axis = Vec3::new(cos as f32, sin as f32, 0.0); + let y_axis = Vec3::new(-sin as f32, cos as f32, 0.0); + let half_width = text_break.half_width + padding; + let half_height = text_break.half_height + padding; + let corners = [ + text_break.center - x_axis * half_width - y_axis * half_height, + text_break.center + x_axis * half_width - y_axis * half_height, + text_break.center + x_axis * half_width + y_axis * half_height, + text_break.center - x_axis * half_width + y_axis * half_height, + ]; + let boundary: Vec<_> = (0..4) + .map(|index| { + let next = (index + 1) % 4; + cadkernel::geom2d::Curve::Line(cadkernel::geom2d::Line { + start: [corners[index].x as f64, corners[index].y as f64], + end: [corners[next].x as f64, corners[next].y as f64], + }) + }) + .collect(); + cadkernel::geom2d::inside_spans(&boundary, &segment_curve, tolerance) + }; + if inside(0.0).is_empty() { + add_segment(points, start, end); + return; + } + let mut cursor = 0.0_f32; + for [cut_start, cut_end] in inside(text_break.padding.max(0.0)) { + let cut_start = (cut_start as f32).clamp(0.0, 1.0); + let cut_end = (cut_end as f32).clamp(0.0, 1.0); + if cut_start - cursor > 1.0e-6 { + add_segment(points, start.lerp(end, cursor), start.lerp(end, cut_start)); + } + cursor = cursor.max(cut_end); + } + if 1.0 - cursor > 1.0e-6 { + add_segment(points, start.lerp(end, cursor), end); + } +} + fn jogged_radial_break( chord: Vec3, jog: Vec3, @@ -4543,44 +4965,20 @@ fn append_linear_dimension( false }; - // The two suppression flags address the portions at the first and second - // measured points independently. The text gap is also the natural split; - // when there is no gap, split at the projected text position or midpoint. let line_dir = normalized_or(d2_out - d1_out, axis); let line_len = (d2_out - d1_out).length(); - let mut split = line_len * 0.5; - let mut left_end = split; - let mut right_start = split; - if let Some((text_center, half_width, half_height)) = params.text_break { - let along = (text_center - d1_out).dot(line_dir); - if along > 0.0 && along < line_len { - split = along; - left_end = split; - right_start = split; - let perpendicular = (text_center - (d1_out + line_dir * along)).length(); - if perpendicular < half_height - && along - half_width > 0.0 - && along + half_width < line_len - { - left_end = along - half_width; - right_start = along + half_width; - } - } - } + let split = params + .text_break + .map(|text_break| (text_break.center - d1_out).dot(line_dir)) + .filter(|along| *along > 0.0 && *along < line_len) + .unwrap_or(line_len * 0.5); + let split_point = d1_out + line_dir * split; let draw_inside_line = !arrows_outside || params.dimtofl; - if draw_inside_line && !suppress.dim1 && left_end > 1e-6 { - add_segment( - &mut g.dim_lines, - d1_out, - d1_out + line_dir * left_end, - ); + if draw_inside_line && !suppress.dim1 && split > 1e-6 { + add_segment_with_text_break(&mut g.dim_lines, d1_out, split_point, params.text_break); } - if draw_inside_line && !suppress.dim2 && line_len - right_start > 1e-6 { - add_segment( - &mut g.dim_lines, - d1_out + line_dir * right_start, - d2_out, - ); + if draw_inside_line && !suppress.dim2 && line_len - split > 1e-6 { + add_segment_with_text_break(&mut g.dim_lines, split_point, d2_out, params.text_break); } if arrows_outside && !params.dimsoxd { let stub = params.arrow_len * 2.0; @@ -4636,34 +5034,19 @@ fn append_diameter_dimension( let first = chord - axis * extension; let second = far_chord + axis * extension; let line_length = first.distance(second); - let mut left_end = line_length * 0.5; - let mut right_start = left_end; - if let Some((text_center, half_width, half_height)) = params.text_break { - let along = (text_center - first).dot(axis); - if along > 0.0 && along < line_length { - left_end = along; - right_start = along; - let perpendicular = (text_center - (first + axis * along)).length(); - if perpendicular < half_height - && along - half_width > 0.0 - && along + half_width < line_length - { - left_end = along - half_width; - right_start = along + half_width; - } - } - } + let split = params + .text_break + .map(|text_break| (text_break.center - first).dot(axis)) + .filter(|along| *along > 0.0 && *along < line_length) + .unwrap_or(line_length * 0.5); + let split_point = first + axis * split; let draw_inside_line = !arrows_outside || params.dimtofl; - if draw_inside_line && !suppress.dim1 && left_end > 1e-6 { - add_segment(&mut g.dim_lines, first, first + axis * left_end); + if draw_inside_line && !suppress.dim1 && split > 1e-6 { + add_segment_with_text_break(&mut g.dim_lines, first, split_point, params.text_break); } - if draw_inside_line && !suppress.dim2 && line_length - right_start > 1e-6 { - add_segment( - &mut g.dim_lines, - first + axis * right_start, - second, - ); + if draw_inside_line && !suppress.dim2 && line_length - split > 1e-6 { + add_segment_with_text_break(&mut g.dim_lines, split_point, second, params.text_break); } if arrows_outside && !params.dimsoxd { let stub = params.arrow_len * 2.0; @@ -5126,17 +5509,7 @@ fn append_angular_dimension( } let a = arc_pts[index]; let b = arc_pts[index + 1]; - let hidden_by_text = params.text_break.is_some_and(|(center, half_width, half_height)| { - let angle = draw_start + draw_delta * t; - let radial = Vec3::new(angle.cos(), angle.sin(), 0.0); - let tangent = Vec3::new(-angle.sin(), angle.cos(), 0.0); - let offset = center - (a + b) * 0.5; - offset.dot(tangent).abs() <= half_width - && offset.dot(radial).abs() <= half_height - }); - if !hidden_by_text { - add_segment(&mut g.dim_lines, a, b); - } + add_segment_with_text_break(&mut g.dim_lines, a, b, params.text_break); } } @@ -5162,28 +5535,29 @@ fn append_angular_dimension( } } - if let Some((text_center, half_width, _)) = params.text_break { - let text_angle = (text_center.y - vertex.y).atan2(text_center.x - vertex.x); + if let Some(text_break) = params.text_break { + let text_angle = (text_break.center.y - vertex.y).atan2(text_break.center.x - vertex.x); let into = (text_angle - start).rem_euclid(std::f32::consts::TAU); if into > delta.abs() + 1.0e-6 { let back_to_start = (start - text_angle).rem_euclid(std::f32::consts::TAU); let forward_from_end = (text_angle - end).rem_euclid(std::f32::consts::TAU); - let text_gap = (half_width / radius).min(std::f32::consts::FRAC_PI_2); if back_to_start <= forward_from_end && !suppress.dim1 { - append_sampled_arc( + append_sampled_arc_with_text_break( &mut g.dim_lines, vertex, radius, - text_angle + text_gap, + text_angle, start, + params.text_break, ); } else if !suppress.dim2 { - append_sampled_arc( + append_sampled_arc_with_text_break( &mut g.dim_lines, vertex, radius, end, - text_angle - text_gap, + text_angle, + params.text_break, ); } } @@ -5222,6 +5596,19 @@ fn append_sampled_arc( } } +fn append_sampled_arc_with_text_break( + lines: &mut Vec<[f32; 3]>, + vertex: Vec3, + radius: f32, + start: f32, + end: f32, + text_break: Option, +) { + for pair in sample_angular_arc(vertex, radius, start, end - start).windows(2) { + add_segment_with_text_break(lines, pair[0], pair[1], text_break); + } +} + fn sample_angular_arc(vertex: Vec3, radius: f32, start: f32, sweep: f32) -> Vec { if sweep.abs() <= 1.0e-6 { return vec![vertex + Vec3::new(start.cos() * radius, start.sin() * radius, 0.0)]; @@ -5533,6 +5920,37 @@ fn dimension_text_is_outside(dim: &Dimension, style: Option<&DimStyle>) -> bool _ => text_width > span, }; } + if let Dimension::LargeRadial(radial) = dim { + let radius = radial.definition_point.distance(&radial.chord_point); + if dim.base().text_user_positioned { + return radial + .definition_point + .distance(&dim.base().text_middle_point) + > radius + 1.0e-9; + } + if style.dimtix { + return false; + } + let available = radial.override_center.distance(&radial.chord_point); + let scale = if style.dimscale > 1.0e-9 { + style.dimscale + } else { + 1.0 + }; + let height = style.dimtxt * scale; + let gap = style.dimgap.abs() * scale; + let text_width = dimension_text_value(dim, Some(style)) + .map(|value| value.chars().count() as f64 * height * 0.6 + gap * 2.0) + .unwrap_or(0.0); + let arrow = style.dimasz * scale; + let insufficient = text_width + arrow > available; + return insufficient + && match style.dimatfit { + 0 | 2 => true, + 1 | 3 => text_width > available, + _ => text_width > available, + }; + } if let Dimension::Radius(radius) = dim { let dx = radius.definition_point.x - radius.angle_vertex.x; let dy = radius.definition_point.y - radius.angle_vertex.y; @@ -5625,6 +6043,8 @@ fn dimension_text_natural_rotation(dim: &Dimension) -> f64 { .atan2(d.definition_point.x - d.angle_vertex.x), Dimension::Diameter(d) => (d.definition_point.y - d.angle_vertex.y) .atan2(d.definition_point.x - d.angle_vertex.x), + Dimension::LargeRadial(d) => (d.chord_point.y - d.override_center.y) + .atan2(d.chord_point.x - d.override_center.x), Dimension::Ordinate(d) => { let axis_rotation = -d.base.horizontal_direction; if d.is_ordinate_type_x { @@ -5633,7 +6053,6 @@ fn dimension_text_natural_rotation(dim: &Dimension) -> f64 { axis_rotation } } - _ => 0.0, }; // Clamp to (-π/2, π/2] so text never appears upside-down. let pi = std::f64::consts::PI; @@ -6321,6 +6740,59 @@ fn text_on_dim_line( ) } +fn text_on_single_arrow_dim_line( + first: Vector3, + second: Vector3, + defpt: Vector3, + ax: f64, + ay: f64, + perp_off: f64, + text_width: f64, + arrow: f64, + dimtix: bool, + dimatfit: i16, + tad: i16, +) -> Vector3 { + let (mut nx, mut ny) = (ax, ay); + if nx < 0.0 || (nx == 0.0 && ny < 0.0) { + nx = -nx; + ny = -ny; + } + let (perpendicular_x, perpendicular_y) = (-ny, nx); + let perpendicular_sign = match tad { + 4 => -1.0, + 2 => { + let offset = (defpt.x - first.x) * perpendicular_x + + (defpt.y - first.y) * perpendicular_y; + if offset >= 0.0 { 1.0 } else { -1.0 } + } + _ => 1.0, + }; + let first_along = (first.x - defpt.x) * ax + (first.y - defpt.y) * ay; + let second_along = (second.x - defpt.x) * ax + (second.y - defpt.y) * ay; + let low = first_along.min(second_along); + let high = first_along.max(second_along); + let available = high - low; + let insufficient = text_width + arrow > available; + let text_outside = !dimtix + && insufficient + && match dimatfit { + 0 | 2 => true, + 1 | 3 => text_width > available, + _ => text_width > available, + }; + let along = if text_outside { + high + arrow + text_width * 0.5 + } else { + (first_along + second_along) * 0.5 + }; + Vector3::new( + defpt.x + ax * along + perpendicular_x * perp_off * perpendicular_sign, + defpt.y + ay * along + perpendicular_y * perp_off * perpendicular_sign, + defpt.z, + ) +} + fn dimension_text_pos_f64( dim: &Dimension, style: Option<&DimStyle>, @@ -6352,7 +6824,13 @@ fn dimension_text_pos_f64( let text_w = dimension_text_value(dim, style) .map(|t| t.chars().count() as f64 * text_height * 0.6 + 2.0 * dimgap) .unwrap_or(0.0); - let arrow = text_height; // arrows are roughly text-height sized + let arrow = if matches!(dim, Dimension::LargeRadial(_)) { + style + .map(|style| style.dimasz * dim_scale) + .unwrap_or(text_height) + } else { + text_height + }; // Explicit per-entity override (text dragged to a custom location): the // saved point wins. Otherwise the dimension style governs placement. @@ -6454,6 +6932,24 @@ fn dimension_text_pos_f64( } Vector3::new(x, y, d.definition_point.z) } + Dimension::LargeRadial(d) => { + let dx = d.chord_point.x - d.override_center.x; + let dy = d.chord_point.y - d.override_center.y; + let length = dx.hypot(dy).max(1.0e-12); + text_on_single_arrow_dim_line( + d.override_center, + d.chord_point, + d.definition_point, + dx / length, + dy / length, + perp_off, + text_w, + arrow, + dimtix, + dimatfit, + dimtad, + ) + } Dimension::Diameter(d) => { let dx = d.definition_point.x - d.angle_vertex.x; let dy = d.definition_point.y - d.angle_vertex.y; @@ -6499,7 +6995,6 @@ fn dimension_text_pos_f64( Dimension::Angular2Ln(d) => d.dimension_arc, Dimension::Angular3Pt(d) => d.definition_point, Dimension::Arc(d) => d.definition_point, - Dimension::LargeRadial(d) => d.jog_point, _ => base.text_middle_point, }; Vector3::new(mid.x, mid.y + perp_off * perp_sign_default(), mid.z) @@ -6511,39 +7006,130 @@ fn perp_sign_default() -> f64 { 1.0 } -/// The measurement-text entity (Text or MText) for a baked `*D` block, built -/// through the SAME `dimension_text_entity` the live renderer uses — so the -/// baked text matches the on-screen value, position, height, rotation, -/// alignment, text style and MText handling, and nothing shifts when the file -/// is saved and reopened (the reload renders from the block). Returns `None` -/// when the text is suppressed (`user_text` is a single space). `anno_scale` is -/// the annotative scale (1.0 for a plain model-space bake). +pub(crate) fn baked_large_radial_geometry( + dimension: &Dimension, + document: &CadDocument, +) -> Option { + if !matches!(dimension, Dimension::LargeRadial(_)) { + return None; + } + let style_name = dimension.base().style_name.as_str(); + let effective_style = document + .dim_styles + .iter() + .find(|style| { + style.name.eq_ignore_ascii_case(style_name) + || (style_name.trim().is_empty() + && style.name.eq_ignore_ascii_case("Standard")) + }) + .map(|style| resolved_dimension_style(style, dimension, document)); + let style = effective_style.as_ref(); + let scale = style + .map(|style| dimension_style_scale(style, 1.0)) + .unwrap_or(1.0); + let text_height = style.map(|style| style.dimtxt * scale).unwrap_or(2.5 * scale); + let arrow_size_raw = style.map(|style| style.dimasz * scale).unwrap_or(0.18 * scale); + let tick_size = style.map(|style| style.dimtsz * scale).unwrap_or(0.0); + let arrow_size = (arrow_size_raw as f32).max(0.001); + let arrow = if tick_size > 1.0e-9 { + ArrowKind::Tick { + size: (tick_size as f32).max(0.001), + } + } else if let Some(style) = style { + arrow_from_block(document, style.dimldrblk, arrow_size) + } else { + ArrowKind::Triangle { + size: arrow_size, + filled: true, + size_mul: 1.0, + } + }; + let text = dimension_text_layout(dimension, style, text_height, scale); + let mut geometry = dimension_geometry( + dimension, + &arrow, + &arrow, + DimLineParams { + dimexo: style.map(|style| (style.dimexo * scale) as f32).unwrap_or(0.0), + dimexe: style.map(|style| (style.dimexe * scale) as f32).unwrap_or(0.0), + dimdle: style.map(|style| (style.dimdle * scale) as f32).unwrap_or(0.0), + dimfxl: style.map(|style| (style.dimfxl * scale) as f32).unwrap_or(1.0), + dimfxlon: style.is_some_and(|style| style.dimfxlon), + dimsoxd: style.is_some_and(|style| style.dimsoxd), + dimcen: style.map(|style| (style.dimcen * scale) as f32).unwrap_or(0.09), + ticks: tick_size > 1.0e-9, + arrow_len: arrow_size, + text_width: text.width, + dimatfit: style.map(|style| style.dimatfit).unwrap_or(3), + dimtix: style.is_some_and(|style| style.dimtix), + dimtofl: style.is_some_and(|style| style.dimtofl), + text_position: text.position, + horizontal_text: text.horizontal, + text_movement: style.map(|style| style.dimtmove).unwrap_or(0), + text_break: text.break_box, + }, + SuppressFlags { + ext1: style.is_some_and(|style| style.dimse1), + ext2: style.is_some_and(|style| style.dimse2), + dim1: style.is_some_and(|style| style.dimsd1), + dim2: style.is_some_and(|style| style.dimsd2), + }, + ); + if !style.is_some_and(|style| style.dimse1) { + if let Some(points) = crate::scene::dimension_assoc::radial_extension_points( + document, + dimension.base().common.handle, + style.map(|style| style.dimexo * scale).unwrap_or(0.0), + style.map(|style| style.dimexe * scale).unwrap_or(0.0), + ) { + let points: Vec<_> = points.into_iter().map(vec3_local).collect(); + add_polyline(&mut geometry.ext_lines, &points); + } + } + if style.is_some_and(|style| style.dimtmove == 1) { + if let Some((anchor, endpoint)) = dimtmove_leader_endpoints(dimension, text.position) { + if anchor.distance(endpoint) > text_height as f32 { + add_segment_with_text_break( + &mut geometry.dim_lines, + anchor, + endpoint, + text.break_box, + ); + } + } + } + apply_dimension_breaks( + document, + dimension.base().common.handle, + &mut geometry.dim_lines, + ); + Some(geometry) +} + +/// Build measurement text for a saved dimension block. pub(crate) fn baked_dimension_text_entity( dim: &Dimension, document: &CadDocument, anno_scale: f64, ) -> Option { let style_name = dim.base().style_name.as_str(); - let style = document.dim_styles.iter().find(|s| { - s.name.eq_ignore_ascii_case(style_name) - || (style_name.trim().is_empty() && s.name.eq_ignore_ascii_case("Standard")) - }); - let dim_scale = style - .map(|s| { - if s.dimscale > 1e-6 { - s.dimscale - } else { - anno_scale - } + let effective_style = document + .dim_styles + .iter() + .find(|style| { + style.name.eq_ignore_ascii_case(style_name) + || (style_name.trim().is_empty() + && style.name.eq_ignore_ascii_case("Standard")) }) + .map(|style| resolved_dimension_style(style, dim, document)); + let style = effective_style.as_ref(); + let dim_scale = style + .map(|style| dimension_style_scale(style, anno_scale)) .unwrap_or(1.0); let dim_txt = style .map(|s| s.dimtxt * dim_scale) .unwrap_or(2.5 * dim_scale); let mut ent = dimension_text_entity(dim, dim_txt, style, document, dim_scale)?; - // For a non-default-aligned Text, pin the DXF alignment point (group 11) to - // the insertion point so other CAD programs anchor the centred text where - // OCS does, not at the world origin. if let EntityType::Text(t) = &mut ent { t.alignment_point = Some(t.insertion_point); } diff --git a/src/entities/multileader.rs b/src/entities/multileader.rs index 4e80f01b8..49b4a8127 100644 --- a/src/entities/multileader.rs +++ b/src/entities/multileader.rs @@ -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) diff --git a/src/modules/annotate/aligned_dim.rs b/src/modules/annotate/aligned_dim.rs index 2ec212d1e..9ecfa7c70 100644 --- a/src/modules/annotate/aligned_dim.rs +++ b/src/modules/annotate/aligned_dim.rs @@ -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, } } } diff --git a/src/modules/annotate/angular_dim.rs b/src/modules/annotate/angular_dim.rs index 6bd3ef2e4..f23e3b555 100644 --- a/src/modules/annotate/angular_dim.rs +++ b/src/modules/annotate/angular_dim.rs @@ -234,6 +234,8 @@ impl AngularDimensionCommand { CmdResult::CommitDimension { entity, association: DimensionAssociationInput::Explicit(self.source_refs.clone()), + preserve_base_style: false, + continue_command: false, } } diff --git a/src/modules/annotate/arc_length_dim.rs b/src/modules/annotate/arc_length_dim.rs index f0225c1c9..bbdfaf412 100644 --- a/src/modules/annotate/arc_length_dim.rs +++ b/src/modules/annotate/arc_length_dim.rs @@ -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, } } } diff --git a/src/modules/annotate/diameter_dim.rs b/src/modules/annotate/diameter_dim.rs index 010434c71..19dfcb1ca 100644 --- a/src/modules/annotate/diameter_dim.rs +++ b/src/modules/annotate/diameter_dim.rs @@ -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, } } } diff --git a/src/modules/annotate/dim_baseline.rs b/src/modules/annotate/dim_baseline.rs index 207e9c352..834222a34 100644 --- a/src/modules/annotate/dim_baseline.rs +++ b/src/modules/annotate/dim_baseline.rs @@ -1,20 +1,21 @@ -// DIMBASELINE command — stacked baseline dimensions all measured from the same origin. -// -// Each new point becomes the second extension line origin of a new dimension. -// The first extension line is always the same base origin point. -// Each new dimension line is placed further from the baseline by DIMDLI (increment). -// -// Constructed from commands.rs after finding the last placed linear/aligned dimension. +use std::collections::HashMap; +use std::f64::consts::PI; -use acadrust::entities::{Dimension, DimensionLinear}; +use acadrust::entities::{ + Dimension, DimensionAligned, 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, intersect, nearest_of, Arc, Curve, Line, 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_baseline.svg")); @@ -27,100 +28,353 @@ pub fn tool() -> ToolDef { } } -/// Fallback stacking increment (world units) when no DimStyle is available. -const DEFAULT_DIMDLI: f64 = 1.5; +#[derive(Clone)] +struct SourceStyle { + layer: String, + style_name: String, + normal: Vector3, + text_rotation: f64, + horizontal_direction: f64, +} + +impl SourceStyle { + fn from_base(base: &DimensionBase) -> Self { + Self { + layer: base.common.layer.clone(), + style_name: base.style_name.clone(), + normal: base.normal, + text_rotation: base.text_rotation, + horizontal_direction: base.horizontal_direction, + } + } + + 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; + } +} + +enum BaselineKind { + Linear { + fixed: Vec2, + rotation: f64, + aligned: bool, + perpendicular: Vec2, + next_offset: f64, + increment: f64, + }, + Angular2Ln { + fixed_line: [Vec2; 2], + fixed_is_first: bool, + vertex: Vec2, + fixed_ray: Vec2, + next_radius: f64, + increment: f64, + }, + Angular3Pt { + vertex: Vec2, + fixed_point: Vec2, + fixed_ray: Vec2, + next_radius: f64, + increment: f64, + }, + Ordinate { + definition: Vec2, + leader: Vec2, + is_x: bool, + }, +} + +struct BaselineState { + kind: BaselineKind, + style: SourceStyle, + plane: Plane, +} pub struct DimBaselineCommand { - /// Fixed first-extension-line origin (never changes). - base_p1: DVec3, - /// Direction along the measurement direction (0.0 = horizontal, PI/2 = vertical). - rotation: f64, - /// Text reading rotation inherited from the base dim (keeps a UCS-aligned - /// chain's text consistent with the originating DIMLINEAR). 0 = natural. - text_rotation: f64, - /// Unit vector perpendicular to the dimension axis, pointing toward the dim line side. - perp: DVec3, - /// Perpendicular distance of the NEXT dimension line from the extension-line axis. - next_offset: f64, - /// Stacking increment from the active DimStyle (DIMDLI). - dimdli: f64, - /// True once we have a base dimension loaded. - ready: bool, + base: Option, + injected: Option, + dimdli_by_style: HashMap, + current_style_name: String, + fallback_dimdli: f64, + preserve_base_style: bool, + committed: usize, } impl DimBaselineCommand { - /// No base dim found — cancel immediately. - pub fn new() -> Self { - Self { - base_p1: DVec3::ZERO, - rotation: 0.0, - text_rotation: 0.0, - perp: DVec3::Y, - next_offset: DEFAULT_DIMDLI, - dimdli: DEFAULT_DIMDLI, - ready: false, - } - } - - /// Build from the last placed dimension. - /// - /// `p1` — first extension line origin (fixed baseline). - /// `p2` — second extension line origin of the base dim (unused for placement, kept for context). - /// `definition_point` — dim-line position of the base dim (defines perpendicular side). - /// `rotation` — 0.0 = horizontal, PI/2 = vertical. - /// `dimdli` — DimStyle stacking increment (use [`DEFAULT_DIMDLI`] when no style is active). - pub fn from_base( - p1: Vec3, - _p2: Vec3, - definition_point: Vec3, - rotation: f64, - text_rotation: f64, - dimdli: f32, + pub fn new( + recent: Option, + dimdli_by_style: HashMap, + current_style_name: String, + fallback_dimdli: f64, + preserve_base_style: bool, ) -> Self { - // The base dim's points enter as f32 from the caller; promote to f64 so - // all committed-coordinate math downstream stays exact. - let p1 = p1.as_dvec3(); - let definition_point = definition_point.as_dvec3(); - // Measurement axis = the base dim's rotation angle (any angle, incl. a - // UCS-aligned one), not a world horizontal/vertical. - let axis = DVec3::new(rotation.cos(), rotation.sin(), 0.0); - let perp = DVec3::new(-axis.y, axis.x, 0.0); - let base_offset = (definition_point - p1).dot(perp); - let dimdli = dimdli as f64; - let dimdli = if dimdli.abs() < 1e-6 { - DEFAULT_DIMDLI - } else { - dimdli.abs() + let mut command = Self { + base: None, + injected: None, + dimdli_by_style, + current_style_name, + fallback_dimdli, + preserve_base_style, + committed: 0, }; - // Stack each baseline outward — in the SIGN direction of the base dim's - // own offset. When the base dim sits on the -perp side (below / left of - // the points) a positive increment would march the stack back toward and - // across the points; the signed increment keeps it moving away. (#181) - let dir = if base_offset >= 0.0 { 1.0 } else { -1.0 }; - let dimdli = dimdli * dir; - let next_offset = base_offset + dimdli; - Self { - base_p1: p1, - rotation, - text_rotation, - perp, - next_offset, - dimdli, - ready: true, + if let Some(entity) = recent { + let _ = command.install_base(&entity, None); + } + command + } + + fn effective_increment(&self, style_name: &str) -> f64 { + let style_name = if self.preserve_base_style { + style_name + } else { + &self.current_style_name + }; + self.dimdli_by_style + .get(&style_name.to_ascii_lowercase()) + .copied() + .filter(|value| value.is_finite() && value.abs() > 1.0e-9) + .unwrap_or(self.fallback_dimdli) + .abs() + } + + fn install_base(&mut self, entity: &EntityType, pick: Option) -> bool { + let EntityType::Dimension(dimension) = entity else { + return false; + }; + let style = SourceStyle::from_base(dimension.base()); + let increment = self.effective_increment(&style.style_name); + let plane = plane_from_normal(dimension.base().definition_point, style.normal); + let pick = pick.and_then(|point| plane.project(point.to_array()).map(Vec2::from)); + let kind = match dimension { + Dimension::Linear(source) => { + let first = project(&plane, source.first_point); + let second = project(&plane, source.second_point); + let (fixed, _) = nearest_end(first, second, pick); + linear_base( + fixed, + source.rotation, + false, + project(&plane, source.base.definition_point), + increment, + ) + } + Dimension::Aligned(source) => { + let first = project(&plane, source.first_point); + let second = project(&plane, source.second_point); + let (fixed, other) = nearest_end(first, second, pick); + linear_base( + fixed, + (other - fixed).angle(), + true, + project(&plane, source.base.definition_point), + increment, + ) + } + 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]); + let fixed_index = nearest_line(lines, pick); + let fixed_line = lines[fixed_index]; + let fixed_end = if fixed_index == 0 { + lines[0][1] + } else { + lines[1][1] + }; + let fixed_ray = fixed_end - vertex; + let radius = project(&plane, source.dimension_arc) + .distance(vertex) + .max(1.0e-9); + BaselineKind::Angular2Ln { + fixed_line, + fixed_is_first: fixed_index == 0, + vertex, + fixed_ray, + next_radius: radius + increment, + increment, + } + } + Dimension::Angular3Pt(source) => { + let vertex = project(&plane, source.angle_vertex); + let points = [ + project(&plane, source.first_point), + project(&plane, source.second_point), + ]; + let fixed_index = nearest_line( + [[vertex, points[0]], [vertex, points[1]]], + pick, + ); + let fixed_point = points[fixed_index]; + let radius = project(&plane, source.definition_point) + .distance(vertex) + .max(1.0e-9); + BaselineKind::Angular3Pt { + vertex, + fixed_point, + fixed_ray: fixed_point - vertex, + next_radius: radius + increment, + increment, + } + } + Dimension::Ordinate(source) => BaselineKind::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(BaselineState { kind, style, plane }); + self.committed = 0; + true + } + + fn build_dimension(&self, point_world: DVec3) -> Option { + let state = self.base.as_ref()?; + let point = state.plane.project(point_world.to_array()).map(Vec2::from)?; + let mut dimension = match &state.kind { + BaselineKind::Linear { + fixed, + rotation, + aligned, + perpendicular, + next_offset, + .. + } => build_linear( + state.plane, + *fixed, + point, + *rotation, + *aligned, + *perpendicular, + *next_offset, + )?, + BaselineKind::Angular2Ln { + fixed_line, + fixed_is_first, + vertex, + fixed_ray, + next_radius, + .. + } => { + let moving = point - *vertex; + let (definition, text) = + angular_definition_and_text(*vertex, *fixed_ray, moving, *next_radius)?; + let mut result = DimensionAngular2Ln::default(); + if *fixed_is_first { + result.first_point = world(state.plane, fixed_line[0]); + result.second_point = world(state.plane, fixed_line[1]); + result.angle_vertex = world(state.plane, *vertex); + result.definition_point = world(state.plane, point); + } else { + result.first_point = world(state.plane, *vertex); + result.second_point = world(state.plane, point); + result.angle_vertex = world(state.plane, fixed_line[0]); + result.definition_point = world(state.plane, fixed_line[1]); + } + 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) + } + BaselineKind::Angular3Pt { + vertex, + fixed_point, + fixed_ray, + next_radius, + .. + } => { + let moving = point - *vertex; + let (definition, text) = + angular_definition_and_text(*vertex, *fixed_ray, moving, *next_radius)?; + let mut result = DimensionAngular3Pt::new( + world(state.plane, *vertex), + world(state.plane, *fixed_point), + 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) + } + BaselineKind::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, direction: f64) { + let Some(state) = self.base.as_mut() else { + return; + }; + match &mut state.kind { + BaselineKind::Linear { + next_offset, + increment, + .. + } => *next_offset += *increment * direction, + BaselineKind::Angular2Ln { + next_radius, + increment, + .. + } + | BaselineKind::Angular3Pt { + next_radius, + increment, + .. + } => *next_radius = (*next_radius + *increment * direction).max(1.0e-9), + BaselineKind::Ordinate { .. } => {} } } -} -impl DimBaselineCommand { - /// The two dimension-line endpoints for the new baseline at `p2`: the fixed - /// base origin and `p2` each projected INDEPENDENTLY onto the dim line (at - /// the current stacking offset). Projecting both keeps the line straight - /// even when the two origins aren't level — a shared offset tilts it. (#181) - fn dim_line_pts(&self, p2: DVec3) -> (DVec3, DVec3) { - let target = self.base_p1.dot(self.perp) + self.next_offset; - let d1 = self.base_p1 + self.perp * (target - self.base_p1.dot(self.perp)); - let d2 = p2 + self.perp * (target - p2.dot(self.perp)); - (d1, d2) + fn undo_one(&mut self) -> Option { + if self.committed == 0 { + return Some(CmdResult::NeedPoint); + } + self.committed -= 1; + self.advance(-1.0); + Some(CmdResult::UndoDocument) } } @@ -130,104 +384,464 @@ impl CadCommand for DimBaselineCommand { } fn prompt(&self) -> String { - if !self.ready { - t!("DIMBASELINE No base dimension found. Place a dimension first.").into_owned() + if self.base.is_none() { + "DIMBASELINE Select base dimension:".to_string() + } else if self + .base + .as_ref() + .is_some_and(|base| matches!(&base.kind, BaselineKind::Ordinate { .. })) + { + "DIMBASELINE Specify feature location [Undo/Select] :" + .to_string() } } - fn on_point(&mut self, pt: DVec3) -> CmdResult { - if !self.ready { - return CmdResult::Cancel; + fn options(&self) -> Vec { + self.base.as_ref().map_or_else(Vec::new, |base| { + if matches!(&base.kind, BaselineKind::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; + }; + self.committed += 1; + self.advance(1.0); + CmdResult::CommitDimension { + entity: EntityType::Dimension(dimension), + association: DimensionAssociationInput::Infer(None), + preserve_base_style: self.preserve_base_style, + continue_command: true, } - let p1 = self.base_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; - } - - let (dim_line_pt, dim_line_pt2) = self.dim_line_pts(p2); - dim.definition_point = v3(dim_line_pt); - dim.base.definition_point = v3(dim_line_pt); - dim.base.text_middle_point = v3((dim_line_pt + dim_line_pt2) * 0.5); - dim.base.insertion_point = dim.base.text_middle_point; - dim.base.actual_measurement = dim.measurement(); - - // Stack the next dim line further out. - self.next_offset += self.dimdli; - - CmdResult::CommitEntity(EntityType::Dimension(Dimension::Linear(dim))) } fn on_enter(&mut self) -> CmdResult { - CmdResult::Cancel - } - - fn on_mouse_move(&mut self, pt: DVec3) -> Option { - if !self.ready { - return None; + if self.base.take().is_some() { + self.committed = 0; + CmdResult::NeedPoint + } else { + CmdResult::Cancel } - // dim_line_pts stays in f64; downcast only here, into the preview's - // [f32;3] GPU buffer — correct pixel-space, never touches committed geometry. - let (dim_line_pt, dim_line_pt2) = self.dim_line_pts(pt); - let p1 = self.base_p1.as_vec3(); - let dim_line_pt = dim_line_pt.as_vec3(); - let dim_line_pt2 = dim_line_pt2.as_vec3(); - let pt = pt.as_vec3(); - 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: "dimbase_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 { + match text.trim().to_ascii_uppercase().as_str() { + "S" | "SELECT" => { + self.base = None; + self.committed = 0; + Some(CmdResult::NeedPoint) + } + "U" | "UNDO" => self.undo_one(), + _ => Some(CmdResult::NeedPoint), + } + } + + fn on_undo_step(&mut self) -> Option { + self.undo_one() + } + + fn on_mouse_move(&mut self, point: DVec3) -> Option { + self.build_dimension(point) + .map(|dimension| preview_for_dimension(&dimension)) } } -fn v3(p: DVec3) -> Vector3 { - Vector3::new(p.x, p.y, p.z) +fn linear_base( + fixed: Vec2, + rotation: f64, + aligned: bool, + definition: Vec2, + increment: f64, +) -> BaselineKind { + let perpendicular = Vec2::new(-rotation.sin(), rotation.cos()); + let offset = (definition - fixed).dot(perpendicular); + let increment = increment * if offset >= 0.0 { 1.0 } else { -1.0 }; + BaselineKind::Linear { + fixed, + rotation, + aligned, + perpendicular, + next_offset: offset + increment, + increment, + } } +fn build_linear( + plane: Plane, + fixed: Vec2, + point: Vec2, + rotation: f64, + aligned: bool, + source_perpendicular: Vec2, + offset: f64, +) -> Option { + let (first_line, second_line) = if aligned { + let mut perpendicular = (point - fixed).normalize()?.perpendicular(); + let source_side = source_perpendicular * offset.signum(); + if perpendicular.dot(source_side) < 0.0 { + perpendicular = -perpendicular; + } + let distance = offset.abs(); + (fixed + perpendicular * distance, point + perpendicular * distance) + } else { + let target = fixed.dot(source_perpendicular) + offset; + ( + project_to_line(fixed, source_perpendicular, target), + project_to_line(point, source_perpendicular, target), + ) + }; + if aligned { + let mut result = DimensionAligned::new(world(plane, fixed), world(plane, point)); + 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; + Some(Dimension::Aligned(result)) + } else { + let mut result = DimensionLinear::new(world(plane, fixed), world(plane, point)); + 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; + Some(Dimension::Linear(result)) + } +} -// ── Autocomplete registry ───────────────────────────────── -inventory::submit!(crate::command::CommandRegistration { names: &["DIMBASELINE"] }); // DimBaselineCommand +fn nearest_end(first: Vec2, second: Vec2, pick: Option) -> (Vec2, Vec2) { + if pick.is_some_and(|point| point.distance_squared(second) < point.distance_squared(first)) { + (second, first) + } else { + (first, second) + } +} + +fn nearest_line(lines: [[Vec2; 2]; 2], pick: Option) -> usize { + let Some(pick) = pick else { + return 0; + }; + let curves = lines.map(|line| { + Curve::Line(Line { + start: line[0].into(), + end: line[1].into(), + }) + }); + nearest_of(curves.iter(), pick.into()) + .map(|(index, _)| index) + .unwrap_or(0) +} + +fn angular2_vertex(lines: [[Vec2; 2]; 2]) -> Option { + 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 + .into_iter() + .flatten() + .map(Vec2::length) + .fold(1.0, f64::max); + intersect(&first, &second, Tolerance::new(scale * 1.0e-9)) + .first() + .map(|crossing| Vec2::from(crossing.point)) +} + +fn angular_definition_and_text( + vertex: Vec2, + first: Vec2, + second: Vec2, + radius: f64, +) -> Option<(Vec2, Vec2)> { + let curve = angular_arc(vertex, first, second, radius)?; + Some(( + Vec2::from(curve.point_at(2.0 / 3.0)), + Vec2::from(curve.point_at(0.5)), + )) +} + +fn angular_arc(vertex: Vec2, first: Vec2, second: Vec2, radius: f64) -> Option { + let first = first.normalize()?; + let second = second.normalize()?; + let first_angle = first.angle(); + let second_angle = second.angle(); + let (start_angle, end_angle) = if arc_span(first_angle, second_angle) <= 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::Aligned(value) => value.base.actual_measurement = value.measurement(), + 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::<[f32; 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), + ); + } + Dimension::Aligned(value) => { + let plane = plane_from_normal(value.first_point, value.base.normal); + let first = project(&plane, value.first_point); + let second = project(&plane, value.second_point); + linear_preview( + &mut points, + plane, + first, + second, + (second - first).angle(), + project(&plane, value.definition_point), + ); + } + 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), + ); + } + 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), + ); + } + Dimension::Ordinate(value) => segment_world( + &mut points, + point(value.feature_location), + point(value.leader_endpoint), + ), + _ => {} + } + 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: "dimbaseline_preview".into(), + points, + 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::new(), + tangent_geoms: Vec::new(), + aci: 0, + key_vertices: Vec::new(), + aabb: WireModel::UNBOUNDED_AABB, + plinegen: true, + fill_tris: Vec::new(), + fill_tris_low: Vec::new(), + } +} + +fn linear_preview( + points: &mut Vec<[f32; 3]>, + plane: Plane, + first: Vec2, + second: Vec2, + rotation: f64, + definition: Vec2, +) { + let perpendicular = Vec2::new(-rotation.sin(), rotation.cos()); + let target = definition.dot(perpendicular); + let first_line = project_to_line(first, perpendicular, target); + let second_line = project_to_line(second, perpendicular, target); + segment_local(points, plane, first, first_line); + segment_local(points, plane, second, second_line); + segment_local(points, plane, first_line, second_line); + append_arrows(points, plane, first_line, second_line); +} + +fn append_arrows(points: &mut Vec<[f32; 3]>, plane: Plane, first: Vec2, second: Vec2) { + let Some(axis) = (second - first).normalize() else { + return; + }; + let normal = axis.perpendicular(); + let size = first.distance(second).clamp(1.0, 100.0) * 0.035; + for (tip, inward) in [(first, axis), (second, -axis)] { + let back = tip + inward * size; + segment_local(points, plane, tip, back + normal * size * 0.4); + segment_local(points, plane, tip, back - normal * size * 0.4); + } +} + +fn angular_preview( + points: &mut Vec<[f32; 3]>, + plane: Plane, + vertex: Vec2, + first: Vec2, + second: Vec2, + arc_point: Vec2, +) { + 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_local(points, plane, vertex, vertex + first_direction * radius); + segment_local(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))); + } +} + +fn project_to_line(point: Vec2, perpendicular: Vec2, offset: f64) -> Vec2 { + point + perpendicular * (offset - point.dot(perpendicular)) +} + +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_local(points: &mut Vec<[f32; 3]>, plane: Plane, first: Vec2, second: Vec2) { + segment_world(points, plane.point_at(first.into()), plane.point_at(second.into())); +} + +fn segment_world(points: &mut Vec<[f32; 3]>, first: [f64; 3], second: [f64; 3]) { + points.push(float3(first)); + points.push(float3(second)); + points.push([f32::NAN, 0.0, 0.0]); +} + +fn push_local(points: &mut Vec<[f32; 3]>, plane: Plane, point: Vec2) { + points.push(float3(plane.point_at(point.into()))); +} + +fn float3(value: [f64; 3]) -> [f32; 3] { + [value[0] as f32, value[1] as f32, value[2] as f32] +} + +inventory::submit!(crate::command::CommandRegistration { names: &["DIMBASELINE"] }); diff --git a/src/modules/annotate/dim_continue.rs b/src/modules/annotate/dim_continue.rs index b7eb336b7..45d69520c 100644 --- a/src/modules/annotate/dim_continue.rs +++ b/src/modules/annotate/dim_continue.rs @@ -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, + injected: Option, + history: Vec, + preserve_base_style: bool, +} + +impl DimContinueCommand { + pub fn new(recent: Option, 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) -> 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 { + 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] :" + .to_string() } } - fn on_point(&mut self, pt: DVec3) -> CmdResult { - if !self.ready { - return CmdResult::Cancel; + fn options(&self) -> Vec { + 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 { - 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 { + 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 { + if self.history.is_empty() { + None + } else { + Some(self.undo_one()) + } + } + + fn on_mouse_move(&mut self, point: DVec3) -> Option { + 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 { + 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 { + 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 { + 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 { + 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"] }); diff --git a/src/modules/annotate/jogged_radius_dim.rs b/src/modules/annotate/jogged_radius_dim.rs index fdfc6097b..9a517c968 100644 --- a/src/modules/annotate/jogged_radius_dim.rs +++ b/src/modules/annotate/jogged_radius_dim.rs @@ -139,6 +139,8 @@ impl JoggedRadiusDimensionCommand { dimension, ))), association, + preserve_base_style: false, + continue_command: false, } } } diff --git a/src/modules/annotate/linear_dim.rs b/src/modules/annotate/linear_dim.rs index c2f927c80..53d09b08e 100644 --- a/src/modules/annotate/linear_dim.rs +++ b/src/modules/annotate/linear_dim.rs @@ -187,6 +187,8 @@ impl CadCommand for LinearDimensionCommand { CmdResult::CommitDimension { entity, association: DimensionAssociationInput::Infer(self.source_handle), + preserve_base_style: false, + continue_command: false, } } } diff --git a/src/modules/annotate/mleader_cmd.rs b/src/modules/annotate/mleader_cmd.rs index 1843272e6..1214d45d7 100644 --- a/src/modules/annotate/mleader_cmd.rs +++ b/src/modules/annotate/mleader_cmd.rs @@ -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() diff --git a/src/modules/annotate/mleader_edit.rs b/src/modules/annotate/mleader_edit.rs index a8d77da59..07a7feab9 100644 --- a/src/modules/annotate/mleader_edit.rs +++ b/src/modules/annotate/mleader_edit.rs @@ -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 = 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 = 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 = 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 diff --git a/src/modules/annotate/ordinate_dim.rs b/src/modules/annotate/ordinate_dim.rs index 4ac1d8a17..1437ba953 100644 --- a/src/modules/annotate/ordinate_dim.rs +++ b/src/modules/annotate/ordinate_dim.rs @@ -116,6 +116,8 @@ impl CadCommand for OrdinateDimCommand { Dimension::Ordinate(dim), )), association: DimensionAssociationInput::Infer(None), + preserve_base_style: false, + continue_command: false, } } } diff --git a/src/modules/annotate/qdim.rs b/src/modules/annotate/qdim.rs index 151cfd4af..ab10ea0b7 100644 --- a/src/modules/annotate/qdim.rs +++ b/src/modules/annotate/qdim.rs @@ -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 }, -} +#[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 } pub struct QdimCommand { step: Step, + mode: Mode, + snap_priority: SnapPriority, + plane: WorkingPlane, + selection: Vec, + datum: Option, + excluded: Vec, + dim_spacing: f64, } impl QdimCommand { - pub fn new() -> Self { + pub fn new(selection: Vec, 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 { + 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 { + 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 = 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 { + 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 { + 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) -> CmdResult { CmdResult::NeedPoint } + fn inject_selection_entities(&mut self, entities: Vec) { 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 { + 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) -> 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 { 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 { + 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) -> 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 { + 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 { + let EntityType::Dimension(dimension) = entity else { return Vec::new(); }; + let mut lines: Vec> = 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"] }); diff --git a/src/modules/annotate/radius_dim.rs b/src/modules/annotate/radius_dim.rs index 62a833489..4cb6acc9d 100644 --- a/src/modules/annotate/radius_dim.rs +++ b/src/modules/annotate/radius_dim.rs @@ -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, } } } diff --git a/src/modules/draw/modify/explode.rs b/src/modules/draw/modify/explode.rs index f8626854d..5c4bba066 100644 --- a/src/modules/draw/modify/explode.rs +++ b/src/modules/draw/modify/explode.rs @@ -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 { + 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 { )); } } - 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` 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(), diff --git a/src/scene/annotative.rs b/src/scene/annotative.rs index 7d176bb96..1e0a3b4cd 100644 --- a/src/scene/annotative.rs +++ b/src/scene/annotative.rs @@ -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::>(); 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; diff --git a/src/scene/cache/properties.rs b/src/scene/cache/properties.rs index 974fc5a26..8b3d04711 100644 --- a/src/scene/cache/properties.rs +++ b/src/scene/cache/properties.rs @@ -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. diff --git a/src/scene/convert/tessellate.rs b/src/scene/convert/tessellate.rs index 108c25155..98c28613f 100644 --- a/src/scene/convert/tessellate.rs +++ b/src/scene/convert/tessellate.rs @@ -1535,6 +1535,8 @@ pub fn tessellate( | EntityType::Circle(_) | EntityType::Arc(_) | EntityType::Ellipse(_) + | EntityType::Spline(_) + | EntityType::LwPolyline(_) ) { (pattern_length, pattern) } else { diff --git a/src/scene/dimension_assoc.rs b/src/scene/dimension_assoc.rs index 9e752c68b..82359b26c 100644 --- a/src/scene/dimension_assoc.rs +++ b/src/scene/dimension_assoc.rs @@ -408,12 +408,23 @@ fn resolve_reference(scene: &Scene, reference: &AssocDimensionReference) -> Opti .copied() } -fn dimension_inference_points(dimension: &Dimension) -> Option<[Option; 2]> { +fn dimension_inference_points(dimension: &Dimension) -> Vec> { 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; 2] { + ) -> Vec> { 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() +} diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 0894fe12c..9596cc5fa 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -1420,6 +1420,9 @@ pub struct Scene { pub selection: Rc>, /// 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, /// 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(), diff --git a/src/scene/model/object.rs b/src/scene/model/object.rs index 054c6ab3a..e0f30da28 100644 --- a/src/scene/model/object.rs +++ b/src/scene/model/object.rs @@ -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. diff --git a/src/scene/project.rs b/src/scene/project.rs index 786b11639..29ef0735d 100644 --- a/src/scene/project.rs +++ b/src/scene/project.rs @@ -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, diff --git a/src/scene/selection.rs b/src/scene/selection.rs index 8efd4413d..db367e61c 100644 --- a/src/scene/selection.rs +++ b/src/scene/selection.rs @@ -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, }) } } diff --git a/src/scene/view/render.rs b/src/scene/view/render.rs index daaa3f5a6..feeff457d 100644 --- a/src/scene/view/render.rs +++ b/src/scene/view/render.rs @@ -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; } diff --git a/src/ui/icons.rs b/src/ui/icons.rs index 7c0828aae..52c3990cc 100644 --- a/src/ui/icons.rs +++ b/src/ui/icons.rs @@ -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> = + 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, + ); + } +} diff --git a/src/ui/properties.rs b/src/ui/properties.rs index 88ee200b3..7023f0774 100644 --- a/src/ui/properties.rs +++ b/src/ui/properties.rs @@ -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> {