diff --git a/Cargo.lock b/Cargo.lock
index 0583d5bfe..07257c96d 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -72,7 +72,7 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618"
[[package]]
name = "acadrust"
version = "0.4.1"
-source = "git+https://git@github.com/HakanSeven12/cadcodec.git?rev=788eea0161ba9f3eb7ee6569fd8bb52a8207a2ed#788eea0161ba9f3eb7ee6569fd8bb52a8207a2ed"
+source = "git+https://git@github.com/HakanSeven12/cadcodec.git?rev=9a332bb3c48eca90c3eefaa17aed514da0b8694a#9a332bb3c48eca90c3eefaa17aed514da0b8694a"
dependencies = [
"ahash 0.8.12",
"anyhow",
@@ -878,7 +878,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "cadkernel"
version = "0.1.0"
-source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=b2b1d4b#b2b1d4b0cd7def2d26a0d519991fc89a89f916d3"
+source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=da915bff09ba254e8bb5164e8627a6a24b37951e#da915bff09ba254e8bb5164e8627a6a24b37951e"
dependencies = [
"acadrust",
"cavalier_contours",
diff --git a/Cargo.toml b/Cargo.toml
index 64b2533c9..8fd8217be 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -27,8 +27,8 @@ glam = { version = "0.33", features = ["bytemuck"] }
rfd = "0.17"
clap = { version = "4", features = ["derive"] }
env_logger = "0.11"
-acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "788eea0161ba9f3eb7ee6569fd8bb52a8207a2ed", features = ["serde"] }
-cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "b2b1d4b", features = ["acis", "offset"] }
+acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "9a332bb3c48eca90c3eefaa17aed514da0b8694a", features = ["serde"] }
+cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "da915bff09ba254e8bb5164e8627a6a24b37951e", features = ["acis", "offset"] }
dwg-thumbnailer = { path = "crates/dwg-thumbnailer" }
flate2 = "1"
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] }
@@ -61,7 +61,7 @@ iced_core = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aa
iced_widget = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39" }
[patch."https://github.com/HakanSeven12/cadcodec.git"]
-acadrust = { git = "https://git@github.com/HakanSeven12/cadcodec.git", rev = "788eea0161ba9f3eb7ee6569fd8bb52a8207a2ed" }
+acadrust = { git = "https://git@github.com/HakanSeven12/cadcodec.git", rev = "9a332bb3c48eca90c3eefaa17aed514da0b8694a" }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
ocs_plugin_api = { path = "crates/ocs_plugin_api", features = ["host"] }
diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl
index 03332480c..87bed4168 100644
--- a/locales/en-US/opencadstudio.ftl
+++ b/locales/en-US/opencadstudio.ftl
@@ -2447,6 +2447,8 @@ properties =
.associative = Associative
.at-least = At least
.attachment-type = Attachment type
+ .attachment = Attachment
+ .arrowhead-size = Arrowhead Size
.attenuation = Attenuation
.attribute-definition = Attribute Definition
.attributedefinition = AttributeDefinition
@@ -2829,7 +2831,7 @@ properties =
.polyfacemesh = PolyfaceMesh
.polygonmesh = PolygonMesh
.polyline2d = Polyline2D
- .polyline3d = Polyline3D
+ .polyline3d = 3D Polyline
.predefined = Predefined
.property-bag = Property Bag
.proxy-entity = Proxy Entity
diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl
index 32ac287e0..51d0db147 100644
--- a/locales/tr-TR/opencadstudio.ftl
+++ b/locales/tr-TR/opencadstudio.ftl
@@ -2387,6 +2387,8 @@ properties =
.associative = İlişkisel
.at-least = En az
.attachment-type = Bağlantı türü
+ .attachment = Bağlantı
+ .arrowhead-size = Ok Ucu Boyutu
.attenuation = Zayıflama
.attribute-definition = Öznitelik Tanımı
.attributedefinition = Öznitelik Tanımı
diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs
index 2a0cc6f23..cb72dd4a8 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__") {
- if let Some(encoded) = layer.strip_prefix("__MLEADERALIGN__") {
- apply_mleader_align(&mut self.tabs[i].scene, encoded);
- }
- self.push_undo_snapshot(i, "MLEADERALIGN");
- 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__") {
- if let Some(encoded) = layer.strip_prefix("__MLEADERCOLLECT__") {
- apply_mleader_collect(&mut self.tabs[i].scene, encoded);
- }
- self.push_undo_snapshot(i, "MLEADERCOLLECT");
- 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();
- }
}
}
@@ -2752,6 +2931,9 @@ impl OpenCADStudio {
.map(|e| apply_pedit(e, &op))
.unwrap_or(false);
if changed {
+ if let Some(command) = self.tabs[i].active_cmd.as_mut() {
+ command.on_pedit_applied();
+ }
self.tabs[i].dirty = true;
// Repaint immediately (wide-band fill included).
self.tabs[i].scene.refresh_fill_model(handle);
@@ -4437,92 +4619,154 @@ 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 fz = from_parts[1];
- let tx = to_parts[0];
- let tz = to_parts[1];
- let dx = tx - fx;
- let dz = tz - fz;
- let len = (dx * dx + dz * dz).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`).
- for h in handles {
- if let Some(acadrust::EntityType::MultiLeader(ml)) = scene.document.get_entity_mut(h) {
- let cp = &mut ml.context.content_base_point;
- // Project onto line from_pt + t * dir: keep t component, set perpendicular = 0
- let rel_x = cp.x - fx;
- let rel_z = cp.z - fz;
- let t = (rel_x * (dx / len) + rel_z * (dz / len)) / len;
- let t = t.clamp(0.0, 1.0);
- cp.x = fx + t * dx;
- cp.z = fz + t * dz;
+ 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 &handle in handles {
+ if let Some(acadrust::EntityType::MultiLeader(ml)) =
+ scene.document.get_entity_mut(handle)
+ {
+ let old = ml.context.content_base_point;
+ 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;
+ ml.context.text_location.y += shift_y;
+ ml.context.block_content_location.x += shift_x;
+ ml.context.block_content_location.y += shift_y;
+ for root in &mut ml.context.leader_roots {
+ root.connection_point.x += shift_x;
+ root.connection_point.y += shift_y;
+ }
+ 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 pz = pos_parts[1];
-
- // Collect all leader roots from the secondary multileaders.
- let mut extra_roots: Vec = Vec::new();
- for &h in &handles[1..] {
- if let Some(acadrust::EntityType::MultiLeader(ml)) = scene.document.get_entity(h) {
- extra_roots.extend(ml.context.leader_roots.iter().cloned());
+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();
+ };
+ 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;
+
+ let mut extra_roots: Vec = Vec::new();
+ let mut merged_handles = Vec::new();
+ for &h in &handles[1..] {
+ if let Some(acadrust::EntityType::MultiLeader(ml)) = scene.document.get_entity(h) {
+ if ml.content_type == acadrust::entities::LeaderContentType::Block
+ && ml.block_content_handle == base_block
+ && ml.style_handle == base_style
+ {
+ let shift_x = px - ml.context.content_base_point.x;
+ let shift_y = py - ml.context.content_base_point.y;
+ extra_roots.extend(ml.context.leader_roots.iter().cloned().map(|mut root| {
+ root.connection_point.x += shift_x;
+ root.connection_point.y += shift_y;
+ root
+ }));
+ merged_handles.push(h);
+ }
+ }
+ }
+ if merged_handles.is_empty() {
+ 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;
+ for root in &mut ml.context.leader_roots {
+ root.connection_point.x += shift_x;
+ root.connection_point.y += shift_y;
+ }
ml.context.content_base_point.x = px;
- ml.context.content_base_point.z = pz;
+ ml.context.content_base_point.y = py;
+ ml.context.block_content_location.x = px;
+ ml.context.block_content_location.y = py;
+ ml.context.text_location.x = px;
+ ml.context.text_location.y = py;
for root in extra_roots {
ml.context.leader_roots.push(root);
}
}
- // Erase the secondary multileaders.
- scene.erase_entities(&handles[1..]);
+ 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 22ee0d96e..0561d3e96 100644
--- a/src/app/commands/dim.rs
+++ b/src/app/commands/dim.rs
@@ -604,9 +604,31 @@ impl OpenCADStudio {
_ => None,
});
let multiplier = self.tabs[i].scene.creation_annotation_multiplier();
+ let block_sources = self.tabs[i]
+ .scene
+ .document
+ .block_records
+ .iter()
+ .filter(|block| !block.name.is_empty() && !block.name.starts_with('*'))
+ .map(|block| (block.name.clone(), block.handle))
+ .collect();
+ let layers = self.tabs[i]
+ .scene
+ .document
+ .layers
+ .iter()
+ .map(|layer| layer.name.clone())
+ .collect();
+ let text_styles = self.tabs[i]
+ .scene
+ .document
+ .text_styles
+ .iter()
+ .map(|style| (style.name.clone(), style.handle))
+ .collect();
let new_cmd = style.map_or_else(MLeaderCommand::new, |style| {
MLeaderCommand::with_style(style, multiplier)
- });
+ }).with_drawing_resources(block_sources, layers, text_styles);
self.command_line.push_info(&new_cmd.prompt());
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
}
@@ -646,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" => {
@@ -1376,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/draw.rs b/src/app/commands/draw.rs
index 9c888b04a..5bd714fe4 100644
--- a/src/app/commands/draw.rs
+++ b/src/app/commands/draw.rs
@@ -461,6 +461,31 @@ impl OpenCADStudio {
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
}
+ "3DFACE" => {
+ use crate::modules::draw::draw::face3d::Face3dCommand;
+ let new_cmd = Face3dCommand::new();
+ self.command_line.push_info(&new_cmd.prompt());
+ self.tabs[i].active_cmd = Some(Box::new(new_cmd));
+ }
+
+ "EDGE" => {
+ use crate::modules::draw::draw::face3d::FaceEdgeCommand;
+ let faces = self.tabs[i]
+ .scene
+ .document
+ .entities()
+ .filter_map(|entity| match entity {
+ acadrust::EntityType::Face3D(face) => {
+ Some((face.common.handle, face.clone()))
+ }
+ _ => None,
+ })
+ .collect();
+ let new_cmd = FaceEdgeCommand::new(faces);
+ self.command_line.push_info(&new_cmd.prompt());
+ self.tabs[i].active_cmd = Some(Box::new(new_cmd));
+ }
+
// 2D filled solid. Reached via SO / SOLID2D — the bare SOLID verb is
// currently the shaded-display toggle (token collision tracked).
"SOLID" | "SOLID2D" => {
diff --git a/src/app/commands/styleprops.rs b/src/app/commands/styleprops.rs
index c4ecd2565..e738dfe41 100644
--- a/src/app/commands/styleprops.rs
+++ b/src/app/commands/styleprops.rs
@@ -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 b18e53012..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,
@@ -2398,6 +2402,11 @@ pub enum Message {
PropColorChanged(AcadColor),
/// User selected a lineweight from the Properties pick_list.
PropLwChanged(LineWeight),
+ /// User selected an object-specific lineweight override.
+ PropFieldLwChanged {
+ field: &'static str,
+ value: LineWeight,
+ },
/// User selected a linetype from the linetype pick_list.
PropLinetypeChanged(String),
/// User toggled a boolean property (e.g. Invisible).
@@ -3210,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 2a64f06e5..57efdd611 100644
--- a/src/app/properties.rs
+++ b/src/app/properties.rs
@@ -122,7 +122,13 @@ impl OpenCADStudio {
acadrust::EntityType::LwPolyline(polyline) => Some(polyline.vertices.len()),
acadrust::EntityType::Polyline2D(polyline) => Some(polyline.vertices.len()),
acadrust::EntityType::PolygonMesh(mesh) => Some(mesh.vertices.len()),
+ acadrust::EntityType::Polyline3D(polyline) => Some(
+ crate::entities::polyline::polyline3d_control_vertex_count(polyline),
+ ),
acadrust::EntityType::Leader(leader) => Some(leader.vertices.len()),
+ acadrust::EntityType::Face3D(face) => {
+ Some(if face.is_triangle() { 3 } else { 4 })
+ }
acadrust::EntityType::Spline(spline) => {
Some(if crate::entities::spline::shows_fit_points(spline) {
spline.fit_points.len()
@@ -846,6 +852,12 @@ impl OpenCADStudio {
.map(|b| b.name.clone()),
)
.collect();
+ let block_names: Vec = doc
+ .block_records
+ .iter()
+ .map(|block| block.name.clone())
+ .filter(|name| !name.is_empty() && !name.starts_with('*'))
+ .collect();
let tstyle_names = text_style_names.clone();
// Currently selected names.
let cur_style = ml
@@ -879,6 +891,15 @@ impl OpenCADStudio {
doc.line_types.iter().find(|l| l.handle == h).map(|l| l.name.clone())
})
.unwrap_or_else(|| "ByBlock".to_string());
+ let cur_block = ml
+ .block_content_handle
+ .and_then(|handle| {
+ doc.block_records
+ .iter()
+ .find(|block| block.handle == handle)
+ .map(|block| block.name.clone())
+ })
+ .unwrap_or_else(|| "(none)".to_string());
let mut set_choice =
|field: &str, selected: String, options: Vec| {
for section in sections.iter_mut() {
@@ -897,6 +918,9 @@ impl OpenCADStudio {
set_choice("text_style_handle", cur_tstyle, tstyle_names);
set_choice("arrowhead_handle", cur_arrow, arrow_names);
set_choice("line_type_handle", cur_ltype, ltype_names);
+ if !block_names.is_empty() {
+ set_choice("block_content_handle", cur_block, block_names);
+ }
}
// Inject viewport-only properties that require doc access.
@@ -1004,6 +1028,26 @@ impl OpenCADStudio {
}
}
+ // Legacy leaders omit Handle and report annotation association.
+ if let acadrust::EntityType::Leader(leader) = entity {
+ if let Some(general) = sections.first_mut() {
+ general.props.retain(|property| property.field != "handle");
+ let associative = !leader.annotation_handle.is_null()
+ && self.tabs[i]
+ .scene
+ .document
+ .get_entity(leader.annotation_handle)
+ .is_some();
+ general.props.push(crate::scene::model::object::Property {
+ label: t!("Associative").into_owned(),
+ field: "associative",
+ value: crate::scene::model::object::PropValue::ReadOnly(
+ if associative { "Yes" } else { "No" }.to_string(),
+ ),
+ });
+ }
+ }
+
// Inject DimStyle picker + style-derived groups for Dimensions.
if let acadrust::EntityType::Dimension(d) = entity {
if let Some(general) = sections.first_mut() {
@@ -1198,14 +1242,7 @@ impl OpenCADStudio {
}
}
}
- // Lines & Arrows / Text / Fit default to the assigned
- // dimension style (the same source the tessellator
- // uses); most rows are editable per-object overrides
- // stored in ACAD_DSTYLE. Arrow size / block / overall
- // scale and dim-line lineweight drive the render; text
- // offset / vertical position round-trip to file but
- // don't change the leader glyph here (its annotation
- // is a separate entity). Dim-line colour is read-only.
+ // Resolve editable overrides from the assigned dimension style.
if let Some(ds) = find_dim_style(doc, &ld.dimension_style) {
use crate::entities::dim_override as dov;
use crate::scene::model::object::PropValue;
@@ -1251,7 +1288,7 @@ impl OpenCADStudio {
set_row_value(
&mut sections,
"arrow_size",
- PropValue::EditText(format!("{asz:.4}")),
+ PropValue::EditText(asz.to_string()),
);
let lwd = dov::int(xd, dov::DIMLWD).unwrap_or(ds.dimlwd);
@@ -1269,10 +1306,7 @@ impl OpenCADStudio {
},
);
- // Dim-line colour: a per-object ACAD_DSTYLE
- // override (code 176, an ACI index) wins over the
- // style's DIMCLRD. Editable — the picked colour is
- // written back as that override so it round-trips.
+ // A per-object color override wins over the style.
let dim_c = dov::color(xd, dov::DIMCLRD)
.unwrap_or_else(|| acadrust::types::Color::from_index(ds.dimclrd));
set_row_value(
@@ -1285,7 +1319,7 @@ impl OpenCADStudio {
set_row_value(
&mut sections,
"text_offset",
- PropValue::EditText(format!("{gap:.4}")),
+ PropValue::EditText(gap.to_string()),
);
let tad = dov::int(xd, dov::DIMTAD).unwrap_or(ds.dimtad);
@@ -1302,7 +1336,7 @@ impl OpenCADStudio {
set_row_value(
&mut sections,
"dim_scale_overall",
- PropValue::EditText(format!("{scl:.4}")),
+ PropValue::EditText(scl.to_string()),
);
}
}
@@ -1395,6 +1429,21 @@ impl OpenCADStudio {
// MultiLeader: max points + segment-angle constraints
// are MLeaderStyle settings, not stored on the entity.
acadrust::EntityType::MultiLeader(ml) => {
+ if ml
+ .text_style_handle
+ .and_then(|handle| {
+ doc.text_styles.iter().find(|style| style.handle == handle)
+ })
+ .is_some_and(|style| style.has_fixed_height())
+ {
+ set_row_value(
+ &mut sections,
+ "text_height",
+ PropValue::ReadOnly(
+ crate::entities::common::format_length(ml.text_height),
+ ),
+ );
+ }
if let Some(sh) = ml.style_handle {
if let Some((mx, a1, a2)) =
doc.objects.iter().find_map(|(h, o)| match o {
@@ -1819,6 +1868,11 @@ impl OpenCADStudio {
doc, entity,
)
}
+ acadrust::EntityType::Leader(_) => {
+ crate::scene::annotative::annotation_style_is_annotative(
+ doc, entity,
+ )
+ }
_ => false,
};
// Dimensions/tables/tolerances carry no Annotative row yet — add one
@@ -1856,8 +1910,17 @@ impl OpenCADStudio {
"annotative",
if is_anno { "Yes" } else { "No" }.to_string(),
),
+ acadrust::EntityType::Leader(_)
+ if crate::scene::annotative::annotation_style_is_annotative(
+ doc, entity,
+ ) => set_row(
+ &mut sections,
+ "annotative",
+ "Yes".to_string(),
+ ),
acadrust::EntityType::Text(_)
| acadrust::EntityType::Insert(_)
+ | acadrust::EntityType::Leader(_)
| acadrust::EntityType::Hatch(_)
| acadrust::EntityType::Dimension(_) => set_row_value(
&mut sections,
@@ -2474,10 +2537,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) {
@@ -2533,9 +2626,21 @@ impl OpenCADStudio {
| acadrust::EntityType::Polyline2D(_)
| acadrust::EntityType::Polyline3D(_)
);
- let layer = &self.tabs[i].active_layer;
- if layer != "0" || entity.as_entity().layer().is_empty() {
- entity.as_entity_mut().set_layer(layer.clone());
+ let explicit_mleader_layer = match &entity {
+ acadrust::EntityType::MultiLeader(leader) => leader
+ .common
+ .layer
+ .strip_prefix("__MLEADER_LAYER__")
+ .map(str::to_owned),
+ _ => None,
+ };
+ if let Some(layer) = explicit_mleader_layer {
+ entity.as_entity_mut().set_layer(layer);
+ } else {
+ let layer = &self.tabs[i].active_layer;
+ if layer != "0" || entity.as_entity().layer().is_empty() {
+ entity.as_entity_mut().set_layer(layer.clone());
+ }
}
// INSUNITS: when inserting a block whose BlockRecord.units differ
@@ -2579,6 +2684,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
@@ -2738,6 +2849,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
}
}
@@ -2768,8 +2882,13 @@ 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::LwChoice(lineweight) => {
+ PropValue::ColorVaries
+ | PropValue::LwVaries
+ | PropValue::FieldLwVaries { .. } => VARIES_LABEL.to_string(),
+ PropValue::LwChoice(lineweight)
+ | PropValue::FieldLwChoice {
+ value: lineweight, ..
+ } => {
ui::properties::LwItem(*lineweight).to_string()
}
PropValue::BoolToggle { value, .. } => {
@@ -2927,6 +3046,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())
}
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 81b3007b0..49f50fd08 100644
--- a/src/app/update/command.rs
+++ b/src/app/update/command.rs
@@ -2053,6 +2053,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task {
| "text_style_handle"
| "arrowhead_handle"
| "line_type_handle"
+ | "block_content_handle"
) {
// Resolve a picked name back to the handle the MLEADER
// stores. The style/text-style rows keep their existing
@@ -2098,6 +2099,11 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task {
.map(|l| l.handle)
}
}
+ "block_content_handle" => doc
+ .block_records
+ .iter()
+ .find(|block| block.name == value)
+ .map(|block| block.handle),
_ => None,
};
for &handle in &handles {
@@ -2121,10 +2127,52 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task {
"text_style_handle" => {
if let Some(h) = resolved {
ml.text_style_handle = Some(h);
+ ml.context.text_style_handle = Some(h);
+ ml.property_override_flags.insert(
+ acadrust::entities::MultiLeaderPropertyOverrideFlags::TEXT_STYLE,
+ );
+ }
+ }
+ "arrowhead_handle" => {
+ ml.arrowhead_handle = resolved;
+ for root in &mut ml.context.leader_roots {
+ for line in &mut root.lines {
+ line.arrowhead_handle = resolved;
+ line.override_flags.insert(
+ acadrust::entities::LeaderLinePropertyOverrideFlags::ARROWHEAD,
+ );
+ }
+ }
+ ml.property_override_flags.insert(
+ acadrust::entities::MultiLeaderPropertyOverrideFlags::ARROWHEAD,
+ );
+ }
+ "line_type_handle" => {
+ ml.line_type_handle = resolved;
+ for root in &mut ml.context.leader_roots {
+ for line in &mut root.lines {
+ line.line_type_handle = resolved;
+ line.override_flags.insert(
+ acadrust::entities::LeaderLinePropertyOverrideFlags::LINE_TYPE,
+ );
+ }
+ }
+ ml.property_override_flags.insert(
+ acadrust::entities::MultiLeaderPropertyOverrideFlags::LEADER_LINE_TYPE,
+ );
+ }
+ "block_content_handle" => {
+ if let Some(block_handle) = resolved {
+ ml.block_content_handle = Some(block_handle);
+ ml.context.block_content_handle = Some(block_handle);
+ ml.context.has_block_contents = true;
+ ml.context.block_content_location =
+ ml.context.content_base_point;
+ ml.property_override_flags.insert(
+ acadrust::entities::MultiLeaderPropertyOverrideFlags::BLOCK_CONTENT,
+ );
}
}
- "arrowhead_handle" => ml.arrowhead_handle = resolved,
- "line_type_handle" => ml.line_type_handle = resolved,
_ => {}
}
}
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 65835793d..4dbe55bcb 100644
--- a/src/app/update/mod.rs
+++ b/src/app/update/mod.rs
@@ -4657,6 +4657,32 @@ impl OpenCADStudio {
Task::none()
}
+ Message::PropFieldLwChanged { field, value } => {
+ let i = self.active_tab;
+ let handles = self.property_target_handles(i);
+ self.apply_property_op(i, "CHPROP", &handles, |app, handle| {
+ if let Some(acadrust::EntityType::MultiLeader(leader)) =
+ app.tabs[i].scene.document.get_entity_mut(handle)
+ {
+ if field == "line_weight" {
+ leader.line_weight = value;
+ leader.property_override_flags.insert(
+ acadrust::entities::MultiLeaderPropertyOverrideFlags::LEADER_LINE_WEIGHT,
+ );
+ for root in &mut leader.context.leader_roots {
+ for line in &mut root.lines {
+ line.line_weight = value;
+ line.override_flags.insert(
+ acadrust::entities::LeaderLinePropertyOverrideFlags::LINE_WEIGHT,
+ );
+ }
+ }
+ }
+ }
+ });
+ Task::none()
+ }
+
Message::PropLinetypeChanged(lt) => {
let i = self.active_tab;
let handles = self.property_target_handles(i);
@@ -4896,12 +4922,18 @@ impl OpenCADStudio {
Message::PropVertexStep(delta) => {
let i = self.active_tab;
let handles = self.property_target_handles(i);
- // Vertex navigation applies to a single selected polyline.
+ // Navigate the active vertex or table cell.
let n = if handles.len() == 1 {
match self.tabs[i].scene.document.get_entity(handles[0]) {
Some(acadrust::EntityType::LwPolyline(p)) => p.vertices.len(),
Some(acadrust::EntityType::Polyline2D(p)) => p.vertices.len(),
Some(acadrust::EntityType::PolygonMesh(p)) => p.vertices.len(),
+ Some(acadrust::EntityType::Face3D(face)) => {
+ if face.is_triangle() { 3 } else { 4 }
+ }
+ Some(acadrust::EntityType::Polyline3D(p)) => {
+ crate::entities::polyline::polyline3d_control_vertex_count(p)
+ }
Some(acadrust::EntityType::Table(table)) => {
table.row_count().saturating_mul(table.column_count())
}
@@ -5115,6 +5147,54 @@ impl OpenCADStudio {
}
return Task::none();
}
+ if matches!(
+ field.as_str(),
+ "line_color" | "text_color" | "block_content_color"
+ | "background_fill_color"
+ ) {
+ self.apply_property_op(i, "CHPROP", &handles, |app, handle| {
+ if let Some(acadrust::EntityType::MultiLeader(leader)) =
+ app.tabs[i].scene.document.get_entity_mut(handle)
+ {
+ match field.as_str() {
+ "line_color" => {
+ leader.line_color = color;
+ leader.property_override_flags.insert(
+ acadrust::entities::MultiLeaderPropertyOverrideFlags::LINE_COLOR,
+ );
+ for root in &mut leader.context.leader_roots {
+ for line in &mut root.lines {
+ line.line_color = color;
+ line.override_flags.insert(
+ acadrust::entities::LeaderLinePropertyOverrideFlags::LINE_COLOR,
+ );
+ }
+ }
+ }
+ "text_color" => {
+ leader.text_color = color;
+ leader.context.text_color = color;
+ leader.property_override_flags.insert(
+ acadrust::entities::MultiLeaderPropertyOverrideFlags::TEXT_COLOR,
+ );
+ }
+ "block_content_color" => {
+ leader.block_content_color = color;
+ leader.context.block_content_color = color;
+ leader.property_override_flags.insert(
+ acadrust::entities::MultiLeaderPropertyOverrideFlags::BLOCK_CONTENT_COLOR,
+ );
+ }
+ "background_fill_color" => {
+ leader.context.background_fill_color = color;
+ }
+ _ => {}
+ }
+ }
+ });
+ self.tabs[i].properties.open_color_field = None;
+ return Task::none();
+ }
if matches!(
field.as_str(),
"tbl_cell_content_color" | "tbl_cell_background_color"
diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs
index a9c104fa1..7a073e03d 100644
--- a/src/app/view/mod.rs
+++ b/src/app/view/mod.rs
@@ -432,7 +432,10 @@ impl OpenCADStudio {
let indexed = match tab.scene.document.get_entity(h) {
Some(acadrust::EntityType::LwPolyline(_))
| Some(acadrust::EntityType::Polyline2D(_))
- | Some(acadrust::EntityType::Spline(_)) => true,
+ | Some(acadrust::EntityType::Polyline3D(_))
+ | Some(acadrust::EntityType::Spline(_))
+ | Some(acadrust::EntityType::Face3D(_))
+ | Some(acadrust::EntityType::PolygonMesh(_)) => true,
_ => false,
};
indexed.then_some(tab.properties.prop_vertex)
diff --git a/src/command.rs b/src/command.rs
index d4a2312b3..405e8140e 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
@@ -1930,6 +1952,9 @@ pub trait CadCommand: Send {
/// Commands that stay active across replaces should update their internal snapshots here.
fn on_entity_replaced(&mut self, _old: Handle, _new_handles: &[Handle]) {}
+ /// Called after a PEDIT operation changed its target.
+ fn on_pedit_applied(&mut self) {}
+
/// Consume a lasso or drag-box gesture while the command is active.
/// `fence` is the gesture boundary in drawing coordinates; `window` is
/// present for rectangular gestures. Returning `Some` prevents the normal
diff --git a/src/entities/helix.rs b/src/entities/helix.rs
index 2e5bf7d1c..aa753c6e3 100644
--- a/src/entities/helix.rs
+++ b/src/entities/helix.rs
@@ -1,106 +1,427 @@
-// Helix entity (AcDbHelix): a spline curve plus its generating parameters.
-//
-// A loaded helix carries a fully evaluated `spline` (the NURBS the file
-// stored) alongside the parameters that generated it (base point, axis,
-// radius, turns, turn height, twist, constraint). We render and grip the
-// entity through its embedded spline, and expose the generating parameters
-// as the Geometry property group.
-
-use acadrust::entities::{Helix, HelixConstraint};
-use crate::t;
+use acadrust::entities::{Helix, HelixConstraint, Spline};
+use acadrust::types::Vector3;
+use cadkernel::space::{HelixCurve, HelixDirection, NurbsCurve3, Vec3};
+use glam::DVec3;
use crate::command::EntityTransform;
-use crate::entities::common::ro_prop as ro;
-use crate::entities::traits::{Grippable, Transformable, RenderConvertible};
+use crate::entities::common::{
+ center_grip, edit_prop as edit, format_angle, format_length, parse_f64, ro_prop as ro,
+ square_grip,
+};
+use crate::entities::traits::{Grippable, RenderConvertible, Transformable};
use crate::scene::convert::acad_to_render::RenderEntity;
-use crate::scene::model::object::{GripApply, GripDef, PropSection};
+use crate::scene::model::object::{GripApply, GripDef, PropSection, PropValue, Property};
+use crate::t;
-/// Perpendicular distance from `p` to the helix axis (line through
-/// `axis_base_point` along `axis_vector`). Used to recover the top radius
-/// from the last defining point of the spline.
-fn radius_from_axis(helix: &Helix, p: &acadrust::types::Vector3) -> Option {
- let base = &helix.axis_base_point;
- let axis = helix.axis_vector.normalize();
- // A zero axis vector cannot define a line.
- if axis.length() < 1e-9 {
- return None;
+const EPSILON: f64 = 1.0e-9;
+
+fn point(vector: Vector3) -> Vec3 {
+ Vec3::new(vector.x, vector.y, vector.z)
+}
+
+fn vector(value: Vec3) -> Vector3 {
+ Vector3::new(value.x, value.y, value.z)
+}
+
+fn display(value: Vec3) -> DVec3 {
+ DVec3::new(value.x, value.y, value.z)
+}
+
+fn kernel(value: DVec3) -> Vec3 {
+ Vec3::new(value.x, value.y, value.z)
+}
+
+fn finite(value: Vec3) -> bool {
+ value.x.is_finite() && value.y.is_finite() && value.z.is_finite()
+}
+
+fn spline_curve(spline: &Spline) -> Option {
+ let controls: Vec<[f64; 3]> = spline
+ .control_points
+ .iter()
+ .map(|value| [value.x, value.y, value.z])
+ .collect();
+ let degree = usize::try_from(spline.degree).ok()?;
+ let weights = if spline.weights.is_empty() {
+ vec![1.0; controls.len()]
+ } else {
+ spline.weights.clone()
+ };
+ NurbsCurve3::new_strict(degree, controls, spline.knots.clone(), weights)
+}
+
+fn axis_frame(helix: &Helix) -> Option<(Vec3, Vec3, Vec3)> {
+ let base = point(helix.axis_base_point);
+ let axis = point(helix.axis_vector).normalize()?;
+ let radial = point(helix.start_point) - base;
+ let start_direction = (radial - axis * radial.dot(axis)).normalize()?;
+ Some((base, axis, start_direction))
+}
+
+fn radius_from_axis(helix: &Helix, value: Vec3) -> Option {
+ let (base, axis, _) = axis_frame(helix)?;
+ let delta = value - base;
+ Some((delta - axis * delta.dot(axis)).length())
+}
+
+fn top_radius(helix: &Helix) -> f64 {
+ spline_curve(&helix.spline)
+ .map(|curve| Vec3::from(curve.point_at(1.0)))
+ .and_then(|endpoint| radius_from_axis(helix, endpoint))
+ .unwrap_or(helix.radius)
+}
+
+fn kernel_curve(helix: &Helix, top_radius: f64) -> Option {
+ let (base, axis, start_direction) = axis_frame(helix)?;
+ Some(HelixCurve {
+ base_center: base.to_array(),
+ axis_direction: axis.to_array(),
+ start_direction: start_direction.to_array(),
+ base_radius: helix.radius,
+ top_radius,
+ height: helix.turns * helix.turn_height,
+ turns: helix.turns,
+ direction: if helix.handedness {
+ HelixDirection::CounterClockwise
+ } else {
+ HelixDirection::Clockwise
+ },
+ })
+}
+
+fn projected_direction(direction: Vec3, axis: Vec3) -> Option {
+ (direction - axis * direction.dot(axis)).normalize()
+}
+
+fn perpendicular(axis: Vec3) -> Option {
+ let basis = if axis.x.abs() <= axis.y.abs() && axis.x.abs() <= axis.z.abs() {
+ Vec3::X
+ } else if axis.y.abs() <= axis.z.abs() {
+ Vec3::Y
+ } else {
+ Vec3::Z
+ };
+ projected_direction(basis, axis)
+}
+
+fn rebuild(helix: &mut Helix, top_radius: f64) -> bool {
+ let Some(curve) = kernel_curve(helix, top_radius) else {
+ return false;
+ };
+ let Some(nurbs) = curve.nurbs() else {
+ return false;
+ };
+ helix.spline.degree = nurbs.degree() as i32;
+ helix.spline.knots = nurbs.knots().to_vec();
+ helix.spline.control_points = nurbs
+ .control_points()
+ .iter()
+ .map(|value| Vector3::new(value[0], value[1], value[2]))
+ .collect();
+ helix.spline.weights = if nurbs.is_rational() {
+ nurbs.weights().to_vec()
+ } else {
+ Vec::new()
+ };
+ helix.spline.fit_points.clear();
+ helix.spline.flags.closed = false;
+ helix.spline.flags.periodic = false;
+ helix.spline.flags.rational = nurbs.is_rational();
+ helix.spline.flags.planar = false;
+ helix.spline.flags.linear = false;
+ true
+}
+
+fn choice(label: &str, field: &'static str, selected: &str, options: &[String]) -> Property {
+ Property {
+ label: label.to_string(),
+ field,
+ value: PropValue::Choice {
+ selected: selected.to_string(),
+ options: options.to_vec(),
+ },
}
- let vx = p.x - base.x;
- let vy = p.y - base.y;
- let vz = p.z - base.z;
- let proj = vx * axis.x + vy * axis.y + vz * axis.z;
- let px = vx - proj * axis.x;
- let py = vy - proj * axis.y;
- let pz = vz - proj * axis.z;
- Some((px * px + py * py + pz * pz).sqrt())
}
fn grips(helix: &Helix) -> Vec {
- Grippable::grips(&helix.spline)
+ let Some((base, axis, _)) = axis_frame(helix) else {
+ return Vec::new();
+ };
+ let height = helix.turns * helix.turn_height;
+ let top_center = base + axis * height;
+ let top_endpoint = spline_curve(&helix.spline)
+ .map(|curve| Vec3::from(curve.point_at(1.0)))
+ .unwrap_or(top_center);
+ vec![
+ center_grip(0, display(base)),
+ square_grip(1, display(point(helix.start_point))),
+ square_grip(2, display(top_center)),
+ square_grip(3, display(top_endpoint)),
+ ]
}
fn properties(helix: &Helix) -> Vec {
- let bp = &helix.axis_base_point;
- let turns = helix.turns;
- let turn_height = helix.turn_height;
- // Overall height is turns × per-turn rise.
- let height = turns * turn_height;
- let base_radius = helix.radius;
- // Top radius: the axial distance of the last defining point. For a
- // cylindrical helix this equals the base radius.
- let top_radius = helix
- .spline
- .control_points
- .last()
- .or_else(|| helix.spline.fit_points.last())
- .and_then(|p| radius_from_axis(helix, p))
- .unwrap_or(base_radius);
- // Turn slope: rise angle of a single turn against the base circumference.
- let turn_slope = if base_radius.abs() > 1e-9 {
- (turn_height / (std::f64::consts::TAU * base_radius))
- .atan()
- .to_degrees()
- } else {
- 0.0
- };
- let twist = if helix.handedness { "CW" } else { "CCW" };
+ let base = point(helix.axis_base_point);
+ let height = helix.turns * helix.turn_height;
+ let top_radius = top_radius(helix);
+ let curve = kernel_curve(helix, top_radius);
+ let turn_slope = curve.as_ref().and_then(HelixCurve::turn_slope).unwrap_or(0.0);
+ let total_length = curve.as_ref().and_then(HelixCurve::length).unwrap_or(0.0);
let constrain = match helix.constraint {
- HelixConstraint::TurnHeight => "Turn Height",
- HelixConstraint::Turns => "Turns",
- HelixConstraint::Height => "Height",
+ HelixConstraint::TurnHeight => t!("Turn Height").into_owned(),
+ HelixConstraint::Turns => t!("Turns").into_owned(),
+ HelixConstraint::Height => t!("Height").into_owned(),
};
+ let twist = if helix.handedness {
+ t!("CCW").into_owned()
+ } else {
+ t!("CW").into_owned()
+ };
+ let constraint_options = vec![
+ t!("Turn Height").into_owned(),
+ t!("Turns").into_owned(),
+ t!("Height").into_owned(),
+ ];
+ let twist_options = vec![t!("CW").into_owned(), t!("CCW").into_owned()];
vec![PropSection {
title: t!("Geometry").into_owned(),
props: vec![
- ro(t!("Base point X").as_ref(), "base_x", format!("{:.4}", bp.x)),
- ro(t!("Base point Y").as_ref(), "base_y", format!("{:.4}", bp.y)),
- ro(t!("Base point Z").as_ref(), "base_z", format!("{:.4}", bp.z)),
- ro(t!("Number of turns").as_ref(), "num_turns", format!("{turns:.4}")),
- ro(t!("Turn height").as_ref(), "turn_height", format!("{turn_height:.4}")),
- ro(t!("Turns").as_ref(), "turns", format!("{turns:.4}")),
- ro(t!("Height").as_ref(), "height", format!("{height:.4}")),
- ro(t!("Base radius").as_ref(), "base_radius", format!("{base_radius:.4}")),
- ro(t!("Top radius").as_ref(), "top_radius", format!("{top_radius:.4}")),
- ro(t!("Turn slope").as_ref(), "turn_slope", format!("{turn_slope:.4}")),
- ro(t!("Twist").as_ref(), "twist", twist),
- ro(t!("Constrain").as_ref(), "constrain", constrain),
+ edit(t!("Position X").as_ref(), "position_x", base.x),
+ edit(t!("Position Y").as_ref(), "position_y", base.y),
+ edit(t!("Position Z").as_ref(), "position_z", base.z),
+ choice(t!("Constrain").as_ref(), "constrain", &constrain, &constraint_options),
+ edit(t!("Height").as_ref(), "height", height),
+ edit(t!("Turns").as_ref(), "turns", helix.turns),
+ edit(t!("Turn height").as_ref(), "turn_height", helix.turn_height),
+ edit(t!("Base radius").as_ref(), "base_radius", helix.radius),
+ edit(t!("Top radius").as_ref(), "top_radius", top_radius),
+ choice(t!("Twist").as_ref(), "twist", &twist, &twist_options),
+ ro(t!("Turn slope").as_ref(), "turn_slope", format_angle(turn_slope)),
+ ro(t!("Total length").as_ref(), "total_length", format_length(total_length)),
],
}]
}
-/// Helix geometry is derived from the stored spline; the generating
-/// parameters are read-only (editing one would require regenerating the
-/// spline, which the loaded entity does not carry a recipe for).
-fn apply_geom_prop(_helix: &mut Helix, _field: &str, _value: &str) {}
-
-fn apply_grip(helix: &mut Helix, grip_id: usize, apply: GripApply) {
- Grippable::apply_grip(&mut helix.spline, grip_id, apply);
+fn apply_geom_prop(helix: &mut Helix, field: &str, value: &str) {
+ if field == "constrain" {
+ let constraint = if value == t!("Height").as_ref() {
+ HelixConstraint::Height
+ } else if value == t!("Turns").as_ref() {
+ HelixConstraint::Turns
+ } else if value == t!("Turn Height").as_ref() {
+ HelixConstraint::TurnHeight
+ } else {
+ return;
+ };
+ helix.constraint = constraint;
+ return;
+ }
+ let original = helix.clone();
+ let top = top_radius(helix);
+ if field == "twist" {
+ if value == t!("CCW").as_ref() {
+ helix.handedness = true;
+ } else if value == t!("CW").as_ref() {
+ helix.handedness = false;
+ } else {
+ return;
+ }
+ if !rebuild(helix, top) {
+ *helix = original;
+ }
+ return;
+ }
+ let Some(number) = parse_f64(value).filter(|number| number.is_finite()) else {
+ return;
+ };
+ let old_base = point(helix.axis_base_point);
+ let old_height = helix.turns * helix.turn_height;
+ match field {
+ "position_x" | "position_y" | "position_z" => {
+ let mut base = old_base;
+ match field {
+ "position_x" => base.x = number,
+ "position_y" => base.y = number,
+ _ => base.z = number,
+ }
+ let delta = base - old_base;
+ helix.axis_base_point = vector(base);
+ helix.start_point = vector(point(helix.start_point) + delta);
+ }
+ "height" if number.abs() > EPSILON => {
+ if helix.constraint == HelixConstraint::TurnHeight && helix.turn_height.abs() > EPSILON {
+ let turns = number / helix.turn_height;
+ if turns <= EPSILON {
+ return;
+ }
+ helix.turns = turns;
+ } else {
+ helix.turn_height = number / helix.turns.max(EPSILON);
+ }
+ }
+ "turns" if number > EPSILON => {
+ if helix.constraint == HelixConstraint::TurnHeight {
+ helix.turns = number;
+ } else {
+ helix.turns = number;
+ helix.turn_height = old_height / number;
+ }
+ }
+ "turn_height" if number.abs() > EPSILON => {
+ if helix.constraint == HelixConstraint::Turns {
+ helix.turn_height = number;
+ } else {
+ let turns = old_height / number;
+ if turns <= EPSILON {
+ return;
+ }
+ helix.turn_height = number;
+ helix.turns = turns;
+ }
+ }
+ "base_radius" if number > EPSILON => {
+ let Some((base, _, start_direction)) = axis_frame(helix) else {
+ return;
+ };
+ helix.radius = number;
+ helix.start_point = vector(base + start_direction * number);
+ }
+ "top_radius" if number >= 0.0 => {
+ if !rebuild(helix, number) {
+ *helix = original;
+ }
+ return;
+ }
+ _ => return,
+ }
+ if !rebuild(helix, top) {
+ *helix = original;
+ }
}
-fn apply_transform(helix: &mut Helix, t: &EntityTransform) {
- // Move the visible curve, then keep the generating points in step.
- Transformable::apply_transform(&mut helix.spline, t);
+fn apply_grip(helix: &mut Helix, grip_id: usize, apply: GripApply) {
+ let original = helix.clone();
+ let top = top_radius(helix);
+ let Some((base, axis, start_direction)) = axis_frame(helix) else {
+ return;
+ };
+ match (grip_id, apply) {
+ (0, GripApply::Translate(delta)) => {
+ let delta = kernel(delta);
+ if !finite(delta) {
+ return;
+ }
+ helix.axis_base_point = vector(base + delta);
+ helix.start_point = vector(point(helix.start_point) + delta);
+ }
+ (0, GripApply::Absolute(position)) => {
+ let position = kernel(position);
+ if !finite(position) {
+ return;
+ }
+ let delta = position - base;
+ helix.axis_base_point = vector(position);
+ helix.start_point = vector(point(helix.start_point) + delta);
+ }
+ (1, GripApply::Absolute(position)) => {
+ let position = kernel(position);
+ if !finite(position) {
+ return;
+ }
+ let radial = position - base - axis * (position - base).dot(axis);
+ let radius = radial.length();
+ if radius <= EPSILON {
+ return;
+ }
+ helix.radius = radius;
+ helix.start_point = vector(base + radial);
+ }
+ (2, GripApply::Absolute(position)) => {
+ let position = kernel(position);
+ if !finite(position) {
+ return;
+ }
+ let axis_vector = position - base;
+ let height = axis_vector.length();
+ if height <= EPSILON {
+ return;
+ }
+ let target_axis = axis_vector / height;
+ if helix.constraint == HelixConstraint::TurnHeight && helix.turn_height.abs() > EPSILON {
+ helix.axis_vector = vector(target_axis * helix.turn_height.signum());
+ helix.turns = height / helix.turn_height.abs();
+ } else {
+ helix.axis_vector = vector(target_axis);
+ helix.turn_height = height / helix.turns.max(EPSILON);
+ }
+ let new_axis = point(helix.axis_vector);
+ let start_direction = projected_direction(start_direction, new_axis)
+ .or_else(|| perpendicular(new_axis));
+ let Some(start_direction) = start_direction else {
+ *helix = original;
+ return;
+ };
+ helix.start_point = vector(base + start_direction * helix.radius);
+ }
+ (3, GripApply::Absolute(position)) => {
+ let position = kernel(position);
+ if !finite(position) {
+ return;
+ }
+ let delta = position - base;
+ let height = delta.dot(axis);
+ let radius = (delta - axis * height).length();
+ if height.abs() <= EPSILON {
+ return;
+ }
+ if helix.constraint == HelixConstraint::TurnHeight && helix.turn_height.abs() > EPSILON {
+ let turns = height / helix.turn_height;
+ if turns <= EPSILON {
+ return;
+ }
+ helix.turns = turns;
+ } else {
+ helix.turn_height = height / helix.turns.max(EPSILON);
+ }
+ if !rebuild(helix, radius) {
+ *helix = original;
+ }
+ return;
+ }
+ _ => return,
+ }
+ if !rebuild(helix, top) {
+ *helix = original;
+ }
+}
+
+fn apply_transform(helix: &mut Helix, transform: &EntityTransform) {
+ match transform {
+ EntityTransform::Translate(delta) => {
+ acadrust::Entity::translate(helix, Vector3::new(delta.x, delta.y, delta.z));
+ }
+ EntityTransform::Rotate { center, axis, angle_rad } => {
+ crate::scene::view::transform::apply_standard_transform(
+ helix,
+ *center,
+ *axis,
+ *angle_rad,
+ );
+ }
+ EntityTransform::Scale { center, factor } => {
+ crate::scene::view::transform::apply_standard_scale(helix, *center, *factor);
+ }
+ EntityTransform::Mirror { p1, p2, working_normal } => {
+ let reflection = crate::scene::view::transform::reflection_about_working_line(
+ *p1,
+ *p2,
+ *working_normal,
+ );
+ acadrust::Entity::apply_transform(helix, &reflection);
+ }
+ EntityTransform::Affine(value) => acadrust::Entity::apply_transform(helix, value),
+ }
}
impl RenderConvertible for Helix {
@@ -113,6 +434,7 @@ impl Grippable for Helix {
fn grips(&self) -> Vec {
grips(self)
}
+
fn apply_grip(&mut self, grip_id: usize, apply: GripApply) {
apply_grip(self, grip_id, apply);
}
@@ -122,13 +444,14 @@ impl crate::entities::traits::PropertyEditable for Helix {
fn geometry_properties(&self, _text_style_names: &[String]) -> Vec {
properties(self)
}
+
fn apply_geom_prop(&mut self, field: &str, value: &str) {
apply_geom_prop(self, field, value);
}
}
impl Transformable for Helix {
- fn apply_transform(&mut self, t: &EntityTransform) {
- apply_transform(self, t);
+ fn apply_transform(&mut self, transform: &EntityTransform) {
+ apply_transform(self, transform);
}
}
diff --git a/src/entities/leader.rs b/src/entities/leader.rs
index 58937cda6..91aee670c 100644
--- a/src/entities/leader.rs
+++ b/src/entities/leader.rs
@@ -269,11 +269,10 @@ fn properties(leader: &Leader) -> Vec {
let vertex_label = if n == 0 {
"—".to_string()
} else {
- format!("{} / {}", vi + 1, n)
+ (vi + 1).to_string()
};
- // Geometry sits right after General — a leader owns editable path vertices,
- // navigated one at a time by the Current Vertex spinner.
+ // Geometry exposes one editable vertex at a time.
let mut geometry = vec![stepper(t!("Current Vertex").as_ref(), "current_vertex", vertex_label)];
if let Some(v) = leader.vertices.get(vi) {
geometry.push(edit(t!("Vertex X").as_ref(), "vertex_x", v.x));
@@ -285,8 +284,7 @@ fn properties(leader: &Leader) -> Vec {
geometry.push(ro(t!("Vertex Z").as_ref(), "vertex_z", String::new()));
}
- // Misc: Dim style (upgraded to a dropdown by the panel builder), the
- // combined path/arrow Type, and annotative state (from the dim style).
+ // The panel builder resolves the dimension style and annotative state.
let misc = vec![
Property {
label: t!("Dim style").into_owned(),
diff --git a/src/entities/mesh.rs b/src/entities/mesh.rs
index 86450d3cc..0e60322d4 100644
--- a/src/entities/mesh.rs
+++ b/src/entities/mesh.rs
@@ -2,144 +2,57 @@ use acadrust::entities::{mesh::Mesh, polygon_mesh::PolygonMesh, Face3D, Polyface
use cadkernel::geom2d::{triangulate, Tolerance};
use cadkernel::space::{polygon, NurbsSurface3, Plane, Vec3 as KernelVec3};
use glam::Vec3;
-use crate::t;
use crate::command::EntityTransform;
use crate::entities::common::{
- edit_prop, edit_scalar_prop, parse_f64, ro_prop as ro, square_grip,
- stepper_prop,
+ edit_prop as edit, edit_scalar_prop as edit_scalar, parse_f64, ro_prop as ro,
+ square_grip, stepper_prop as stepper,
};
-use crate::entities::traits::{Grippable, PropertyEditable, Transformable, RenderConvertible};
+use crate::entities::traits::{Grippable, PropertyEditable, RenderConvertible, Transformable};
use crate::scene::convert::acad_to_render::{RenderEntity, RenderObject};
use crate::scene::model::object::{GripApply, GripDef, PropSection, PropValue, Property};
use crate::scene::model::wire_model::SnapHint;
+use crate::t;
-/// Triangulate a planar (possibly concave) polygon into a flat triangle-soup
-/// (3 vertices per triangle), preserving the polygon's winding. A simple fan
-/// from vertex 0 is only valid for convex faces — a concave face (e.g. an
-/// L-shaped mesh face) fans into triangles that spill outside the outline. Ear
-/// clipping handles both. Falls back to a fan when the polygon is degenerate.
+/// Triangulate a planar polygon through the kernel.
pub(crate) fn triangulate_planar(poly: &[[f64; 3]]) -> Vec<[f64; 3]> {
- let n = poly.len();
- if n < 3 {
+ if poly.len() < 3 {
return Vec::new();
}
- if n == 3 {
- return vec![poly[0], poly[1], poly[2]];
- }
- let cross = |a: [f64; 3], b: [f64; 3]| {
- [
- a[1] * b[2] - a[2] * b[1],
- a[2] * b[0] - a[0] * b[2],
- a[0] * b[1] - a[1] * b[0],
- ]
+ let origin = KernelVec3::from(poly[0]);
+ let tolerance = Tolerance::default().linear();
+ let Some(axis) = poly[1..]
+ .iter()
+ .map(|point| KernelVec3::from(*point) - origin)
+ .find(|axis| axis.length() > tolerance)
+ else {
+ return Vec::new();
};
- let dot = |a: [f64; 3], b: [f64; 3]| a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
- let fan = || {
- let mut out = Vec::new();
- for i in 1..n - 1 {
- out.push(poly[0]);
- out.push(poly[i]);
- out.push(poly[i + 1]);
- }
- out
+ let Some(normal) = polygon::normal(poly) else {
+ return Vec::new();
};
- // Face normal via Newell's method (robust for near-planar polygons).
- let mut normal = [0.0f64; 3];
- for i in 0..n {
- let a = poly[i];
- let b = poly[(i + 1) % n];
- normal[0] += (a[1] - b[1]) * (a[2] + b[2]);
- normal[1] += (a[2] - b[2]) * (a[0] + b[0]);
- normal[2] += (a[0] - b[0]) * (a[1] + b[1]);
- }
- let nlen = dot(normal, normal).sqrt();
- if nlen < 1e-12 {
- return fan();
- }
- let normal = [normal[0] / nlen, normal[1] / nlen, normal[2] / nlen];
- // Orthonormal in-plane basis.
- let seed = if normal[0].abs() < 0.9 { [1.0, 0.0, 0.0] } else { [0.0, 1.0, 0.0] };
- let mut u = cross(seed, normal);
- let ul = dot(u, u).sqrt();
- if ul < 1e-12 {
- return fan();
- }
- u = [u[0] / ul, u[1] / ul, u[2] / ul];
- let v = cross(normal, u);
- let p2: Vec<[f64; 2]> = poly.iter().map(|&p| [dot(p, u), dot(p, v)]).collect();
- // Signed area → winding (CCW when positive in the (u, v) frame).
- let mut area = 0.0;
- for i in 0..n {
- let a = p2[i];
- let b = p2[(i + 1) % n];
- area += a[0] * b[1] - b[0] * a[1];
- }
- let ccw = area > 0.0;
- let tri_area2 = |a: [f64; 2], b: [f64; 2], c: [f64; 2]| {
- (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])
+ let Some(plane) = Plane::orthonormal(poly[0], axis.to_array(), normal) else {
+ return Vec::new();
};
- let in_tri = |p: [f64; 2], a: [f64; 2], b: [f64; 2], c: [f64; 2]| {
- let d1 = tri_area2(a, b, p);
- let d2 = tri_area2(b, c, p);
- let d3 = tri_area2(c, a, p);
- let neg = d1 < 0.0 || d2 < 0.0 || d3 < 0.0;
- let pos = d1 > 0.0 || d2 > 0.0 || d3 > 0.0;
- !(neg && pos)
- };
- let mut idx: Vec = (0..n).collect();
- let mut out: Vec<[f64; 3]> = Vec::with_capacity((n - 2) * 3);
- let mut guard = 0usize;
- while idx.len() > 3 && guard < n * n {
- guard += 1;
- let m = idx.len();
- let mut clipped = false;
- for k in 0..m {
- let i0 = idx[(k + m - 1) % m];
- let i1 = idx[k];
- let i2 = idx[(k + 1) % m];
- let (a, b, c) = (p2[i0], p2[i1], p2[i2]);
- let convex = if ccw { tri_area2(a, b, c) > 0.0 } else { tri_area2(a, b, c) < 0.0 };
- if !convex {
- continue;
- }
- let mut contains = false;
- for &j in &idx {
- if j == i0 || j == i1 || j == i2 {
- continue;
- }
- if in_tri(p2[j], a, b, c) {
- contains = true;
- break;
- }
- }
- if contains {
- continue;
- }
- out.push(poly[i0]);
- out.push(poly[i1]);
- out.push(poly[i2]);
- idx.remove(k);
- clipped = true;
- break;
- }
- if !clipped {
- // No ear found (self-intersecting / numerically degenerate) — bail
- // to a fan of the remainder rather than loop forever.
- for i in 1..idx.len() - 1 {
- out.push(poly[idx[0]]);
- out.push(poly[idx[i]]);
- out.push(poly[idx[i + 1]]);
- }
- return out;
- }
+ let projected: Vec<[f64; 2]> = poly
+ .iter()
+ .filter_map(|&point| plane.project(point))
+ .collect();
+ if projected.len() != poly.len() {
+ return Vec::new();
}
- if idx.len() == 3 {
- out.push(poly[idx[0]]);
- out.push(poly[idx[1]]);
- out.push(poly[idx[2]]);
+ let (points, triangles) = triangulate(&projected, &[]);
+ let source: Vec = points
+ .iter()
+ .filter_map(|point| projected.iter().position(|candidate| candidate == point))
+ .collect();
+ if source.len() != points.len() {
+ return Vec::new();
}
- out
+ triangles
+ .into_iter()
+ .flat_map(|triangle| triangle.map(|index| poly[source[index]]))
+ .collect()
}
// ── Face3D ────────────────────────────────────────────────────────────────────
@@ -177,32 +90,7 @@ fn face3d_fill(corners: [[f64; 3]; 4]) -> Vec<[f64; 3]> {
return Vec::new();
}
- let Some(normal) = polygon::normal(&ring) else {
- return Vec::new();
- };
- let axis = (KernelVec3::from(ring[1]) - KernelVec3::from(ring[0])).to_array();
- let Some(plane) = Plane::orthonormal(ring[0], axis, normal) else {
- return Vec::new();
- };
- let projected: Vec<[f64; 2]> = ring
- .iter()
- .filter_map(|&point| plane.project(point))
- .collect();
- if projected.len() != ring.len() {
- return Vec::new();
- }
- let (points, triangles) = triangulate(&projected, &[]);
- let source: Vec = points
- .iter()
- .filter_map(|point| projected.iter().position(|candidate| candidate == point))
- .collect();
- if source.len() != points.len() {
- return Vec::new();
- }
- triangles
- .into_iter()
- .flat_map(|triangle| triangle.map(|index| ring[source[index]]))
- .collect()
+ triangulate_planar(&ring)
}
impl RenderConvertible for Face3D {
@@ -241,15 +129,6 @@ impl RenderConvertible for Face3D {
add_edge(p3, p0);
}
- if pts.is_empty() {
- // All edges invisible — show a tiny cross at centroid.
- let cx = (p0[0] + p1[0] + p2[0] + p3[0]) / 4.0;
- let cy = (p0[1] + p1[1] + p2[1] + p3[1]) / 4.0;
- let cz = (p0[2] + p1[2] + p2[2] + p3[2]) / 4.0;
- let s = 0.1_f64;
- pts = vec![[cx - s, cy, cz], [cx + s, cy, cz]];
- }
-
Some(RenderEntity {
pick_tris: Vec::new(),
object: RenderObject::Lines(pts),
@@ -268,15 +147,22 @@ impl RenderConvertible for Face3D {
impl Grippable for Face3D {
fn grips(&self) -> Vec {
- vec![
+ let mut grips = vec![
square_grip(0, dvec3(&self.first_corner)),
square_grip(1, dvec3(&self.second_corner)),
square_grip(2, dvec3(&self.third_corner)),
- square_grip(3, dvec3(&self.fourth_corner)),
- ]
+ ];
+ if !self.is_triangle() {
+ grips.push(square_grip(3, dvec3(&self.fourth_corner)));
+ }
+ grips
}
fn apply_grip(&mut self, grip_id: usize, apply: GripApply) {
+ let was_triangle = self.is_triangle();
+ if was_triangle && grip_id == 3 {
+ return;
+ }
let corner = match grip_id {
0 => &mut self.first_corner,
1 => &mut self.second_corner,
@@ -284,69 +170,107 @@ impl Grippable for Face3D {
3 => &mut self.fourth_corner,
_ => return,
};
- match apply {
- GripApply::Translate(d) => {
- corner.x += d.x as f64;
- corner.y += d.y as f64;
- corner.z += d.z as f64;
- }
- GripApply::Absolute(p) => {
- corner.x = p.x as f64;
- corner.y = p.y as f64;
- corner.z = p.z as f64;
- }
+ let current = dvec3(corner);
+ let updated = match apply {
+ GripApply::Translate(delta) => current + delta,
+ GripApply::Absolute(position) => position,
+ };
+ if !updated.is_finite() {
+ return;
+ }
+ corner.x = updated.x;
+ corner.y = updated.y;
+ corner.z = updated.z;
+ if was_triangle && grip_id == 2 {
+ self.fourth_corner = self.third_corner;
}
}
}
impl PropertyEditable for Face3D {
fn geometry_properties(&self, _text_style_names: &[String]) -> Vec {
- use crate::entities::common::edit_prop as edit;
+ let was_triangle = self.is_triangle();
+ let vertex_count = if was_triangle { 3 } else { 4 };
+ let current = crate::scene::view::dispatch::prop_current_vertex()
+ .min(vertex_count - 1);
+ let corner = match current {
+ 0 => &self.first_corner,
+ 1 => &self.second_corner,
+ 2 => &self.third_corner,
+ _ => &self.fourth_corner,
+ };
let inv = self.invisible_edges;
- let edge = |hidden: bool| if hidden { "Invisible" } else { "Visible" };
+ let edge = |label: &str, field: &'static str, hidden: bool| Property {
+ label: label.to_string(),
+ field,
+ value: PropValue::Choice {
+ selected: if hidden {
+ t!("Invisible").into_owned()
+ } else {
+ t!("Visible").into_owned()
+ },
+ options: vec![t!("Visible").into_owned(), t!("Invisible").into_owned()],
+ },
+ };
vec![PropSection {
title: t!("Geometry").into_owned(),
props: vec![
- ro(t!("Current vertex").as_ref(), "f3_current", String::new()),
- edit(t!("Vertex 1 X").as_ref(), "f3_p1x", self.first_corner.x),
- edit(t!("Vertex 1 Y").as_ref(), "f3_p1y", self.first_corner.y),
- edit(t!("Vertex 1 Z").as_ref(), "f3_p1z", self.first_corner.z),
- edit(t!("Vertex 2 X").as_ref(), "f3_p2x", self.second_corner.x),
- edit(t!("Vertex 2 Y").as_ref(), "f3_p2y", self.second_corner.y),
- edit(t!("Vertex 2 Z").as_ref(), "f3_p2z", self.second_corner.z),
- edit(t!("Vertex 3 X").as_ref(), "f3_p3x", self.third_corner.x),
- edit(t!("Vertex 3 Y").as_ref(), "f3_p3y", self.third_corner.y),
- edit(t!("Vertex 3 Z").as_ref(), "f3_p3z", self.third_corner.z),
- edit(t!("Vertex 4 X").as_ref(), "f3_p4x", self.fourth_corner.x),
- edit(t!("Vertex 4 Y").as_ref(), "f3_p4y", self.fourth_corner.y),
- edit(t!("Vertex 4 Z").as_ref(), "f3_p4z", self.fourth_corner.z),
- ro(t!("Edge 1").as_ref(), "f3_edge1", edge(inv.is_first_invisible())),
- ro(t!("Edge 2").as_ref(), "f3_edge2", edge(inv.is_second_invisible())),
- ro(t!("Edge 3").as_ref(), "f3_edge3", edge(inv.is_third_invisible())),
- ro(t!("Edge 4").as_ref(), "f3_edge4", edge(inv.is_fourth_invisible())),
+ stepper(
+ t!("Current vertex").as_ref(),
+ "current_vertex",
+ format!("{}", current + 1),
+ ),
+ edit(t!("Vertex X").as_ref(), "f3_vertex_x", corner.x),
+ edit(t!("Vertex Y").as_ref(), "f3_vertex_y", corner.y),
+ edit(t!("Vertex Z").as_ref(), "f3_vertex_z", corner.z),
+ edge(t!("Edge 1").as_ref(), "f3_edge1", inv.is_first_invisible()),
+ edge(t!("Edge 2").as_ref(), "f3_edge2", inv.is_second_invisible()),
+ edge(t!("Edge 3").as_ref(), "f3_edge3", inv.is_third_invisible()),
+ edge(t!("Edge 4").as_ref(), "f3_edge4", inv.is_fourth_invisible()),
],
}]
}
fn apply_geom_prop(&mut self, field: &str, value: &str) {
- let Ok(v) = value.trim().parse::() else {
+ if field.starts_with("f3_edge") {
+ let invisible = if value == t!("Invisible").as_ref() {
+ true
+ } else if value == t!("Visible").as_ref() {
+ false
+ } else {
+ return;
+ };
+ match field {
+ "f3_edge1" => self.invisible_edges.set_first_invisible(invisible),
+ "f3_edge2" => self.invisible_edges.set_second_invisible(invisible),
+ "f3_edge3" => self.invisible_edges.set_third_invisible(invisible),
+ "f3_edge4" => self.invisible_edges.set_fourth_invisible(invisible),
+ _ => {}
+ }
+ return;
+ }
+ let Some(value) = parse_f64(value).filter(|value| value.is_finite()) else {
return;
};
+ let was_triangle = self.is_triangle();
+ let vertex_count = if was_triangle { 3 } else { 4 };
+ let current = crate::scene::view::dispatch::prop_current_vertex()
+ .min(vertex_count - 1);
+ let corner = match current {
+ 0 => &mut self.first_corner,
+ 1 => &mut self.second_corner,
+ 2 => &mut self.third_corner,
+ _ => &mut self.fourth_corner,
+ };
match field {
- "f3_p1x" => self.first_corner.x = v,
- "f3_p1y" => self.first_corner.y = v,
- "f3_p1z" => self.first_corner.z = v,
- "f3_p2x" => self.second_corner.x = v,
- "f3_p2y" => self.second_corner.y = v,
- "f3_p2z" => self.second_corner.z = v,
- "f3_p3x" => self.third_corner.x = v,
- "f3_p3y" => self.third_corner.y = v,
- "f3_p3z" => self.third_corner.z = v,
- "f3_p4x" => self.fourth_corner.x = v,
- "f3_p4y" => self.fourth_corner.y = v,
- "f3_p4z" => self.fourth_corner.z = v,
+ "f3_vertex_x" => corner.x = value,
+ "f3_vertex_y" => corner.y = value,
+ "f3_vertex_z" => corner.z = value,
_ => {}
}
+ if was_triangle && current == 2 {
+ self.fourth_corner = self.third_corner;
+ }
}
}
@@ -393,16 +317,13 @@ fn polygon_mesh_display(mesh: &PolygonMesh) -> (Vec<[f64; 3]>, usize, usize) {
return (base, m, n);
}
let control_points: Vec> = base.chunks(n).map(|row| row.to_vec()).collect();
- let Some(surface) = NurbsSurface3::new(
+ let Some(surface) = NurbsSurface3::from_control_net(
u_degree,
v_degree,
control_points,
- Vec::new(),
- Vec::new(),
- None,
- )
- .map(|surface| surface.with_periodicity(mesh.is_closed_m(), mesh.is_closed_n()))
- else {
+ mesh.is_closed_m(),
+ mesh.is_closed_n(),
+ ) else {
return (base, m, n);
};
@@ -499,18 +420,17 @@ impl Grippable for PolygonMesh {
fn apply_grip(&mut self, grip_id: usize, apply: GripApply) {
if let Some(v) = self.vertices.get_mut(grip_id) {
- match apply {
- GripApply::Translate(d) => {
- v.location.x += d.x as f64;
- v.location.y += d.y as f64;
- v.location.z += d.z as f64;
- }
- GripApply::Absolute(p) => {
- v.location.x = p.x as f64;
- v.location.y = p.y as f64;
- v.location.z = p.z as f64;
- }
+ let current = glam::DVec3::new(v.location.x, v.location.y, v.location.z);
+ let updated = match apply {
+ GripApply::Translate(delta) => current + delta,
+ GripApply::Absolute(position) => position,
+ };
+ if !updated.is_finite() {
+ return;
}
+ v.location.x = updated.x;
+ v.location.y = updated.y;
+ v.location.z = updated.z;
}
}
}
@@ -537,7 +457,7 @@ impl PropertyEditable for PolygonMesh {
let smoothed = self.smooth_type != SurfaceSmoothType::NoSmooth;
let density_prop = |label: &str, field: &'static str, value: i16| {
if smoothed {
- edit_scalar_prop(label, field, value as f64)
+ edit_scalar(label, field, value as f64)
} else {
ro(label, field, value.to_string())
}
@@ -546,16 +466,16 @@ impl PropertyEditable for PolygonMesh {
PropSection {
title: t!("Geometry").into_owned(),
props: vec![
- stepper_prop(t!("Current Vertex").as_ref(), "pm_current_vertex", display),
- edit_prop(t!("Vertex X").as_ref(),
+ stepper(t!("Current Vertex").as_ref(), "pm_current_vertex", display),
+ edit(t!("Vertex X").as_ref(),
"pm_vx",
vertex.map_or(0.0, |vertex| vertex.location.x),
),
- edit_prop(t!("Vertex Y").as_ref(),
+ edit(t!("Vertex Y").as_ref(),
"pm_vy",
vertex.map_or(0.0, |vertex| vertex.location.y),
),
- edit_prop(t!("Vertex Z").as_ref(),
+ edit(t!("Vertex Z").as_ref(),
"pm_vz",
vertex.map_or(0.0, |vertex| vertex.location.z),
),
@@ -605,48 +525,64 @@ impl PropertyEditable for PolygonMesh {
let bool_value = |current: bool| {
if value == "toggle" {
- !current
+ Some(!current)
+ } else if value.eq_ignore_ascii_case("true")
+ || value.eq_ignore_ascii_case("yes")
+ {
+ Some(true)
+ } else if value.eq_ignore_ascii_case("false")
+ || value.eq_ignore_ascii_case("no")
+ {
+ Some(false)
} else {
- value.eq_ignore_ascii_case("true") || value.eq_ignore_ascii_case("yes")
+ None
}
};
match field {
- "pm_closed_m" => self.flags.set(
- PolygonMeshFlags::CLOSED_M,
- bool_value(self.is_closed_m()),
- ),
- "pm_closed_n" => self.flags.set(
- PolygonMeshFlags::CLOSED_N,
- bool_value(self.is_closed_n()),
- ),
+ "pm_closed_m" => {
+ if let Some(closed) = bool_value(self.is_closed_m()) {
+ self.flags.set(PolygonMeshFlags::CLOSED_M, closed);
+ }
+ }
+ "pm_closed_n" => {
+ if let Some(closed) = bool_value(self.is_closed_n()) {
+ self.flags.set(PolygonMeshFlags::CLOSED_N, closed);
+ }
+ }
"pm_smooth" => {
- self.smooth_type = match value.trim().to_ascii_uppercase().as_str() {
+ let smooth = match value.trim().to_ascii_uppercase().as_str() {
+ "NONE" => SurfaceSmoothType::NoSmooth,
"QUADRATIC" => SurfaceSmoothType::Quadratic,
"CUBIC" => SurfaceSmoothType::Cubic,
"BEZIER" => SurfaceSmoothType::Bezier,
- _ => SurfaceSmoothType::NoSmooth,
+ _ => return,
};
+ self.smooth_type = smooth;
if self.smooth_type != SurfaceSmoothType::NoSmooth {
- if self.m_smooth_density < 2 {
+ if !(2..=256).contains(&self.m_smooth_density) {
self.m_smooth_density = 6;
}
- if self.n_smooth_density < 2 {
+ if !(2..=256).contains(&self.n_smooth_density) {
self.n_smooth_density = 6;
}
}
}
"pm_smooth_m" => {
- if let Ok(density @ 2..=256) = value.trim().parse::() {
- self.m_smooth_density = density;
+ if self.smooth_type != SurfaceSmoothType::NoSmooth {
+ if let Ok(density @ 2..=256) = value.trim().parse::() {
+ self.m_smooth_density = density;
+ }
}
}
"pm_smooth_n" => {
- if let Ok(density @ 2..=256) = value.trim().parse::() {
- self.n_smooth_density = density;
+ if self.smooth_type != SurfaceSmoothType::NoSmooth {
+ if let Ok(density @ 2..=256) = value.trim().parse::() {
+ self.n_smooth_density = density;
+ }
}
}
"pm_vx" | "pm_vy" | "pm_vz" => {
- let Some(number) = parse_f64(value) else {
+ let Some(number) = parse_f64(value).filter(|number| number.is_finite()) else {
return;
};
let index = crate::scene::view::dispatch::prop_current_vertex()
diff --git a/src/entities/multileader.rs b/src/entities/multileader.rs
index 4084a7805..49b4a8127 100644
--- a/src/entities/multileader.rs
+++ b/src/entities/multileader.rs
@@ -1,7 +1,9 @@
use acadrust::entities::{
- FlowDirectionType, LeaderContentType, LineSpacingStyle, MultiLeader, MultiLeaderPathType,
- TextAlignmentType, TextAttachmentDirectionType, TextAttachmentType,
+ BlockContentConnectionType, FlowDirectionType, LeaderContentType, LineSpacingStyle,
+ MultiLeader, MultiLeaderPathType, MultiLeaderPropertyOverrideFlags, TextAlignmentType,
+ TextAttachmentDirectionType, TextAttachmentType,
};
+use cadkernel::geom2d::Transform;
use crate::entities::text_support::{
layout_mtext, resolve_text_style, MTextRenderOpts, MTextVAnchor, ResolvedTextStyle,
@@ -904,9 +906,7 @@ fn properties(ml: &MultiLeader) -> Vec {
// ── Leaders ──────────────────────────────────────────────────────────
// Landing rows are folded in here: the standalone "Leader Structure" group
// is a style-dialog tab, not a palette group.
- let leaders = PropSection {
- title: t!("Leaders").into_owned(),
- props: vec![
+ let mut leader_props = vec![
choice(
t!("Leader type").as_ref(),
"path_type",
@@ -923,20 +923,34 @@ fn properties(ml: &MultiLeader) -> Vec {
Property {
label: t!("Leader lineweight").into_owned(),
field: "line_weight",
- value: PropValue::LwChoice(ml.line_weight),
+ value: PropValue::FieldLwChoice {
+ field: "line_weight",
+ value: ml.line_weight,
+ },
},
// Arrowhead block name resolved by the panel builder (default "Closed filled").
ro(t!("Arrowhead").as_ref(), "arrowhead_handle", "Closed filled"),
- edit(t!("Arrowhead size").as_ref(), "arrowhead_size", ml.arrowhead_size),
+ edit(t!("Arrowhead Size").as_ref(), "arrowhead_size", ml.arrowhead_size),
bool_toggle(t!("Horizontal Landing").as_ref(), "enable_dogleg", ml.enable_dogleg),
num_row(
t!("Landing distance").as_ref(),
"landing_distance",
- ml.dogleg_length,
+ if ml.enable_dogleg { ml.dogleg_length } else { 0.0 },
ml.enable_dogleg,
),
- bool_toggle(t!("Leader extension").as_ref(), "enable_landing", ml.enable_landing),
- ],
+ ];
+ // The extension is an MText-only landing option. It is absent for block
+ // content and whenever the horizontal landing itself is disabled.
+ if ml.content_type == LeaderContentType::MText && ml.enable_dogleg {
+ leader_props.push(bool_toggle(
+ t!("Leader extension").as_ref(),
+ "extend_leader_to_text",
+ ml.extend_leader_to_text,
+ ));
+ }
+ let leaders = PropSection {
+ title: t!("Leaders").into_owned(),
+ props: leader_props,
};
// ── Text (shown only for MText content) ──────────────────────────────
@@ -988,6 +1002,18 @@ fn properties(ml: &MultiLeader) -> Vec {
attach_dir_str(&ml.text_attachment_direction),
&["Horizontal", "Vertical"],
),
+ edit(
+ t!("Landing gap").as_ref(),
+ "landing_gap",
+ ctx.landing_gap / ctx.scale_factor.max(1.0e-12),
+ ),
+ bool_toggle(t!("Text frame").as_ref(), "text_frame", ml.text_frame),
+ ],
+ };
+ let mut text = text;
+ let attachment_insert = text.props.len().saturating_sub(2);
+ let attachment_rows = match ml.text_attachment_direction {
+ TextAttachmentDirectionType::Horizontal => vec![
choice(
t!("Left Attachment").as_ref(),
"text_left_attachment",
@@ -1000,10 +1026,23 @@ fn properties(ml: &MultiLeader) -> Vec {
attachment_str(&ml.text_right_attachment),
&ATTACH_LABELS,
),
- edit(t!("Landing gap").as_ref(), "landing_gap", ctx.landing_gap),
- bool_toggle(t!("Text frame").as_ref(), "text_frame", ml.text_frame),
+ ],
+ TextAttachmentDirectionType::Vertical => vec![
+ choice(
+ t!("Top Attachment").as_ref(),
+ "text_top_attachment",
+ attachment_str(&ml.text_top_attachment),
+ &ATTACH_LABELS,
+ ),
+ choice(
+ t!("Bottom Attachment").as_ref(),
+ "text_bottom_attachment",
+ attachment_str(&ml.text_bottom_attachment),
+ &ATTACH_LABELS,
+ ),
],
};
+ text.props.splice(attachment_insert..attachment_insert, attachment_rows);
// ── Block ────────────────────────────────────────────────────────────
let block = PropSection {
@@ -1014,29 +1053,16 @@ fn properties(ml: &MultiLeader) -> Vec {
"block_content_handle",
hexh(ml.block_content_handle),
),
- ro(
- t!("Block connection").as_ref(),
+ choice(
+ t!("Attachment").as_ref(),
"block_connection_type",
- format!("{:?}", ml.block_connection_type),
- ),
- ro(
- t!("Block color").as_ref(),
- "block_content_color",
- format!("{:?}", ml.block_content_color),
- ),
- ro(
- t!("Block scale").as_ref(),
- "block_scale",
- format!(
- "{:.3} × {:.3} × {:.3}",
- ml.block_scale.x, ml.block_scale.y, ml.block_scale.z
- ),
- ),
- edit(
- t!("Block rotation").as_ref(),
- "block_rotation",
- ml.block_rotation.to_degrees(),
+ match ml.block_connection_type {
+ BlockContentConnectionType::BasePoint => "Insertion point",
+ BlockContentConnectionType::BlockExtents => "Extents",
+ },
+ &["Extents", "Insertion point"],
),
+ edit(t!("Scale").as_ref(), "block_scale", ml.block_scale.x),
],
};
@@ -1067,7 +1093,9 @@ fn apply_geom_prop(ml: &mut MultiLeader, field: &str, value: &str) {
"text_height" => {
if let Some(v) = f64(value) {
ml.text_height = v;
- ml.context.text_height = v;
+ ml.context.text_height = v * ml.context.scale_factor.max(1.0e-12);
+ ml.property_override_flags
+ .insert(MultiLeaderPropertyOverrideFlags::TEXT_HEIGHT);
}
}
"text_x" => {
@@ -1093,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 {
@@ -1101,19 +1131,40 @@ fn apply_geom_prop(ml: &mut MultiLeader, field: &str, value: &str) {
"None" => MultiLeaderPathType::Invisible,
_ => MultiLeaderPathType::StraightLineSegments,
};
+ for root in &mut ml.context.leader_roots {
+ for line in &mut root.lines {
+ line.path_type = ml.path_type;
+ line.override_flags.insert(
+ acadrust::entities::LeaderLinePropertyOverrideFlags::PATH_TYPE,
+ );
+ }
+ }
+ ml.property_override_flags
+ .insert(MultiLeaderPropertyOverrideFlags::PATH_TYPE);
}
"enable_landing" => {
ml.enable_landing = if value == "toggle" {
!ml.enable_landing
} else {
value == "true"
- }
+ };
+ ml.property_override_flags
+ .insert(MultiLeaderPropertyOverrideFlags::ENABLE_LANDING);
}
"enable_dogleg" => {
ml.enable_dogleg = if value == "toggle" {
!ml.enable_dogleg
} else {
value == "true"
+ };
+ ml.property_override_flags
+ .insert(MultiLeaderPropertyOverrideFlags::ENABLE_DOGLEG);
+ }
+ "extend_leader_to_text" => {
+ ml.extend_leader_to_text = if value == "toggle" {
+ !ml.extend_leader_to_text
+ } else {
+ value == "true"
}
}
"enable_annotation_scale" => {
@@ -1130,39 +1181,130 @@ fn apply_geom_prop(ml: &mut MultiLeader, field: &str, value: &str) {
value == "true"
}
}
+ "background_mask_fill_on" => {
+ ml.context.background_mask_fill_on = if value == "toggle" {
+ !ml.context.background_mask_fill_on
+ } else {
+ value == "true"
+ }
+ }
+ "background_scale_factor" => {
+ if let Some(v) = f64(value) {
+ if v > 0.0 {
+ ml.context.background_scale_factor = v;
+ }
+ }
+ }
+ "background_transparency" => {
+ if let Ok(v) = value.trim().parse::() {
+ ml.context.background_transparency = v.clamp(0, 90);
+ }
+ }
"dogleg_length" => {
if let Some(v) = f64(value) {
ml.dogleg_length = v;
+ ml.property_override_flags
+ .insert(MultiLeaderPropertyOverrideFlags::LANDING_DISTANCE);
}
}
"arrowhead_size" => {
if let Some(v) = f64(value) {
ml.arrowhead_size = v;
+ ml.context.arrowhead_size = v * ml.context.scale_factor.max(1.0e-12);
+ for root in &mut ml.context.leader_roots {
+ for line in &mut root.lines {
+ line.arrowhead_size = v;
+ line.override_flags.insert(
+ acadrust::entities::LeaderLinePropertyOverrideFlags::ARROWHEAD_SIZE,
+ );
+ }
+ }
+ ml.property_override_flags
+ .insert(MultiLeaderPropertyOverrideFlags::ARROWHEAD_SIZE);
}
}
"scale_factor" => {
if let Some(v) = f64(value) {
- ml.scale_factor = v;
+ if v > 0.0 {
+ let old = ml.context.scale_factor.max(1.0e-12);
+ let ratio = v / old;
+ ml.scale_factor = v;
+ ml.context.scale_factor = v;
+ ml.context.arrowhead_size *= ratio;
+ ml.context.text_height *= ratio;
+ ml.context.landing_gap *= ratio;
+ ml.block_scale.x *= ratio;
+ ml.block_scale.y *= ratio;
+ ml.block_scale.z *= ratio;
+ ml.context.block_content_scale = ml.block_scale;
+ if let Some(root) = ml.context.leader_roots.first() {
+ let anchor = root.connection_point;
+ for point in [
+ &mut ml.context.content_base_point,
+ &mut ml.context.text_location,
+ &mut ml.context.block_content_location,
+ ] {
+ point.x = anchor.x + (point.x - anchor.x) * ratio;
+ point.y = anchor.y + (point.y - anchor.y) * ratio;
+ point.z = anchor.z + (point.z - anchor.z) * ratio;
+ }
+ }
+ for root in &mut ml.context.leader_roots {
+ root.landing_distance *= ratio;
+ }
+ ml.property_override_flags
+ .insert(MultiLeaderPropertyOverrideFlags::SCALE_FACTOR);
+ }
}
}
"landing_distance" => {
if let Some(v) = f64(value) {
ml.dogleg_length = v;
- if let Some(root) = ml.context.leader_roots.first_mut() {
- root.landing_distance = v;
+ let display = v * ml.context.scale_factor.max(1.0e-12);
+ for root in &mut ml.context.leader_roots {
+ root.landing_distance = display;
}
+ place_content_after_landing(ml);
+ ml.property_override_flags
+ .insert(MultiLeaderPropertyOverrideFlags::LANDING_DISTANCE);
}
}
"landing_gap" => {
if let Some(v) = f64(value) {
- ml.context.landing_gap = v;
+ ml.context.landing_gap = v * ml.context.scale_factor.max(1.0e-12);
+ place_content_after_landing(ml);
+ ml.property_override_flags
+ .insert(MultiLeaderPropertyOverrideFlags::LANDING_GAP);
}
}
"block_rotation" => {
if let Some(v) = f64(value) {
ml.block_rotation = v.to_radians();
+ ml.context.block_rotation = ml.block_rotation;
+ ml.property_override_flags
+ .insert(MultiLeaderPropertyOverrideFlags::BLOCK_CONTENT_ROTATION);
}
}
+ "block_scale" => {
+ if let Some(v) = f64(value) {
+ if v > 0.0 {
+ ml.block_scale = acadrust::types::Vector3::new(v, v, v);
+ ml.context.block_content_scale = ml.block_scale;
+ ml.property_override_flags
+ .insert(MultiLeaderPropertyOverrideFlags::BLOCK_CONTENT_SCALE);
+ }
+ }
+ }
+ "block_connection_type" => {
+ ml.block_connection_type = if value == "Insertion point" {
+ BlockContentConnectionType::BasePoint
+ } else {
+ BlockContentConnectionType::BlockExtents
+ };
+ ml.context.block_connection_type = ml.block_connection_type;
+ ml.property_override_flags
+ .insert(MultiLeaderPropertyOverrideFlags::BLOCK_CONTENT_CONNECTION);
+ }
"conn_x" => {
if let (Some(v), Some(root)) = (f64(value), ml.context.leader_roots.first_mut()) {
root.connection_point.x = v;
@@ -1181,18 +1323,26 @@ fn apply_geom_prop(ml: &mut MultiLeader, field: &str, value: &str) {
"text_left_attachment" => {
ml.text_left_attachment = parse_attachment(value);
ml.context.text_left_attachment = parse_attachment(value);
+ ml.property_override_flags
+ .insert(MultiLeaderPropertyOverrideFlags::TEXT_LEFT_ATTACHMENT);
}
"text_right_attachment" => {
ml.text_right_attachment = parse_attachment(value);
ml.context.text_right_attachment = parse_attachment(value);
+ ml.property_override_flags
+ .insert(MultiLeaderPropertyOverrideFlags::TEXT_RIGHT_ATTACHMENT);
}
"text_top_attachment" => {
ml.text_top_attachment = parse_attachment(value);
ml.context.text_top_attachment = parse_attachment(value);
+ ml.property_override_flags
+ .insert(MultiLeaderPropertyOverrideFlags::TEXT_TOP_ATTACHMENT);
}
"text_bottom_attachment" => {
ml.text_bottom_attachment = parse_attachment(value);
ml.context.text_bottom_attachment = parse_attachment(value);
+ ml.property_override_flags
+ .insert(MultiLeaderPropertyOverrideFlags::TEXT_BOTTOM_ATTACHMENT);
}
"text_alignment" => {
ml.text_alignment = match value {
@@ -1205,6 +1355,8 @@ fn apply_geom_prop(ml: &mut MultiLeader, field: &str, value: &str) {
"Right" => TextAlignmentType::Right,
_ => TextAlignmentType::Left,
};
+ ml.property_override_flags
+ .insert(MultiLeaderPropertyOverrideFlags::TEXT_ALIGNMENT);
}
"text_flow_direction" => {
ml.context.text_flow_direction = match value {
@@ -1218,6 +1370,16 @@ fn apply_geom_prop(ml: &mut MultiLeader, field: &str, value: &str) {
"Vertical" => TextAttachmentDirectionType::Vertical,
_ => TextAttachmentDirectionType::Horizontal,
};
+ if ml.text_attachment_direction == TextAttachmentDirectionType::Vertical {
+ ml.enable_dogleg = false;
+ ml.property_override_flags
+ .insert(MultiLeaderPropertyOverrideFlags::ENABLE_DOGLEG);
+ }
+ for root in &mut ml.context.leader_roots {
+ root.text_attachment_direction = ml.text_attachment_direction;
+ }
+ ml.property_override_flags
+ .insert(MultiLeaderPropertyOverrideFlags::TEXT_ATTACHMENT_DIRECTION);
}
"line_space_style" => {
ml.context.line_spacing_style = match value {
@@ -1233,6 +1395,13 @@ fn apply_geom_prop(ml: &mut MultiLeader, field: &str, value: &str) {
"text_rotation" => {
if let Some(v) = f64(value) {
ml.context.text_rotation = v.to_radians();
+ ml.context.text_direction = acadrust::types::Vector3::new(
+ ml.context.text_rotation.cos(),
+ ml.context.text_rotation.sin(),
+ 0.0,
+ );
+ ml.property_override_flags
+ .insert(MultiLeaderPropertyOverrideFlags::TEXT_ANGLE);
}
}
"line_spacing" => {
@@ -1254,6 +1423,27 @@ fn apply_geom_prop(ml: &mut MultiLeader, field: &str, value: &str) {
}
}
+fn place_content_after_landing(ml: &mut MultiLeader) {
+ let Some(root) = ml.context.leader_roots.first() else {
+ return;
+ };
+ let length = (root.direction.x * root.direction.x + root.direction.y * root.direction.y).sqrt();
+ let (ux, uy) = if length > 1.0e-12 {
+ (root.direction.x / length, root.direction.y / length)
+ } else {
+ (1.0, 0.0)
+ };
+ let distance = root.landing_distance.max(0.0) + ml.context.landing_gap.max(0.0);
+ let location = acadrust::types::Vector3::new(
+ root.connection_point.x + ux * distance,
+ root.connection_point.y + uy * distance,
+ root.connection_point.z,
+ );
+ ml.context.content_base_point = location;
+ ml.context.text_location = location;
+ ml.context.block_content_location = location;
+}
+
fn parse_attachment(s: &str) -> TextAttachmentType {
match s {
"Top of top line" => TextAttachmentType::TopOfTopLine,
@@ -1758,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
@@ -1785,11 +1973,7 @@ impl MultiLeaderTess for MultiLeader {
// for a rotated leader that is the angled baseline, not world
// X. Roots without a usable direction keep the legacy
// horizontal toward the text side.
- let (ux, uy) = ml
- .context
- .leader_roots
- .first()
- .map(|r| (r.direction.x, r.direction.y))
+ let (ux, uy) = Some((root.direction.x, root.direction.y))
.filter(|(x, y)| (x * x + y * y).sqrt() > 1e-9)
.map(|(x, y)| {
let l = (x * x + y * y).sqrt();
@@ -1804,6 +1988,15 @@ impl MultiLeaderTess for MultiLeader {
points.push(nan);
points.push(cp_f);
points.push(landing_end);
+ if ml.extend_leader_to_text {
+ let gap = ml.context.landing_gap.max(0.0)
+ * context_scale_correction as f64;
+ points.push([
+ (text_loc_w.x - ux * gap) as f32,
+ (text_loc_w.y - uy * gap) as f32,
+ text_loc_w.z as f32,
+ ]);
+ }
}
}
@@ -1856,31 +2049,66 @@ impl MultiLeaderTess for MultiLeader {
// synthetic Insert supplies that context to the shared scene graph;
// every child then uses its normal tessellator.
if ml.content_type == LeaderContentType::Block && ml.context.has_block_contents {
- let block_name = match ml.block_content_handle {
+ let block_record = match ml.block_content_handle {
Some(h) if !h.is_null() => document
.block_records
.iter()
.find(|br| br.handle == h)
- .map(|br| br.name.clone()),
+ .cloned(),
_ => None,
};
- if let Some(block_name) = block_name {
+ if let Some(block_record) = block_record {
+ let block_name = block_record.name.clone();
let block_color = if selected {
line_color
} else {
color_or_inherit(&ml.block_content_color, entity_color)
};
- let mut synth_ins =
- acadrust::entities::Insert::new(block_name, ml.context.block_content_location);
+ let mut insertion = ml.context.block_content_location;
+ if ml.block_connection_type == BlockContentConnectionType::BlockExtents {
+ let mut bounds = [f32::MAX, f32::MAX, f32::MIN, f32::MIN];
+ for entity_handle in &block_record.entity_handles {
+ if let Some(entity) = document.get_entity(*entity_handle) {
+ let aabb = crate::scene::convert::tess::entity_aabb(entity);
+ if aabb != WireModel::UNBOUNDED_AABB {
+ bounds[0] = bounds[0].min(aabb[0]);
+ bounds[1] = bounds[1].min(aabb[1]);
+ bounds[2] = bounds[2].max(aabb[2]);
+ bounds[3] = bounds[3].max(aabb[3]);
+ }
+ }
+ }
+ 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| {
+ 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 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);
synth_ins.set_x_scale(ml.block_scale.x);
synth_ins.set_y_scale(ml.block_scale.y);
synth_ins.set_z_scale(ml.block_scale.z);
synth_ins.rotation = ml.block_rotation;
synth_ins.common.layer = ml.common.layer.clone();
- // block_connection_type (BlockExtents vs BasePoint) chooses the
- // anchor when *creating* the multileader; at render time the
- // file's stored leader endpoints already encode that choice.
- let _ = ml.block_connection_type;
let depths = rustc_hash::FxHashMap::default();
let graph = crate::scene::render_graph::RenderSceneGraph::new(
document,
@@ -1956,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)
@@ -2013,14 +2236,14 @@ impl MultiLeaderTess for MultiLeader {
// anchor within the text block (Left/Center/Right) — honour it
// instead of guessing from the leader side.
use acadrust::entities::multileader::{
- TextAttachmentDirectionType, TextAttachmentPointType,
+ TextAttachmentDirectionType,
};
// The context's attachment point exists in every DWG version;
// the entity-level copy only exists from R2010 on.
- let h_anchor: f32 = match ctx.text_attachment_point {
- TextAttachmentPointType::Left => 0.0,
- TextAttachmentPointType::Center => 0.5,
- TextAttachmentPointType::Right => 1.0,
+ let h_anchor: f32 = match ctx.text_alignment {
+ TextAlignmentType::Left => 0.0,
+ TextAlignmentType::Center => 0.5,
+ TextAlignmentType::Right => 1.0,
};
// Pick the vertical-anchor attachment based on text_attachment_direction:
// Horizontal — leader attaches left/right; use ml.text_left_attachment
@@ -2273,14 +2496,9 @@ impl MultiLeaderTess for MultiLeader {
// Text frame / background-fill rectangle in local frame, then rotated to WCS.
if ml.text_frame || ctx.background_fill_enabled {
- // Frame/fill offset from the glyphs: the context's landing gap
- // (AutoCAD spaces the box by exactly that), with the old
- // quarter-height heuristic as the no-gap fallback.
- let pad = if ctx.landing_gap > 0.0 {
- ctx.landing_gap as f32
- } else {
- height * 0.25
- };
+ // Border offset is a scale factor around the laid-out text box.
+ let pad = height
+ * ((ctx.background_scale_factor.max(1.0) as f32 - 1.0) * 0.5);
// Box the glyphs that were actually laid out (valid for
// vertical flow too); the metric-derived box is only the
// no-glyph fallback.
@@ -2311,11 +2529,16 @@ impl MultiLeaderTess for MultiLeader {
// Background fill — emit two triangles; renders under the text strokes.
if ctx.background_fill_enabled {
- let fill_color = if selected {
+ let mut fill_color = if selected {
line_color
+ } else if ctx.background_mask_fill_on {
+ bg_color
} else {
color_or_inherit(&ctx.background_fill_color, entity_color)
};
+ fill_color[3] *=
+ (1.0 - ctx.background_transparency.clamp(0, 90) as f32 / 100.0)
+ .clamp(0.1, 1.0);
let fill_tris: Vec<[f32; 3]> = vec![
wcs_corners[0],
wcs_corners[1],
diff --git a/src/entities/polyline.rs b/src/entities/polyline.rs
index 54d98ab77..d9cd71404 100644
--- a/src/entities/polyline.rs
+++ b/src/entities/polyline.rs
@@ -3,12 +3,14 @@ use crate::t;
use crate::command::EntityTransform;
use crate::entities::common::{
- edit_prop as edit, parse_f64, ro_prop as ro, square_grip, stepper_prop as stepper,
+ edit_prop as edit, format_length, parse_f64, ro_prop as ro, square_grip,
+ stepper_prop as stepper,
};
use crate::entities::traits::{Grippable, PropertyEditable, Transformable, RenderConvertible};
use crate::scene::convert::acad_to_render::{extrusion_wall_tris, RenderEntity, RenderObject};
use crate::scene::model::object::{GripApply, GripDef, PropSection, PropValue, Property};
use crate::scene::model::wire_model::TangentGeom;
+use cadkernel::space::{NurbsCurve3, Vec3};
// ── Polyline (old-style 3D heavy polyline) ────────────────────────────────────
@@ -783,20 +785,191 @@ impl Transformable for Polyline2D {
// ── Polyline3D ────────────────────────────────────────────────────────────────
+const POLY3D_SPLINE_POINT: i32 = 8;
+const POLY3D_SPLINE_CONTROL: i32 = 16;
+const POLY3D_VERTEX: i32 = 32;
+
+fn polyline3d_control_indices(pl: &Polyline3D) -> Vec {
+ if pl
+ .vertices
+ .iter()
+ .any(|vertex| vertex.flags & POLY3D_SPLINE_CONTROL != 0)
+ {
+ return pl
+ .vertices
+ .iter()
+ .enumerate()
+ .filter_map(|(index, vertex)| {
+ (vertex.flags & POLY3D_SPLINE_CONTROL != 0).then_some(index)
+ })
+ .collect();
+ }
+
+ let controls: Vec = pl
+ .vertices
+ .iter()
+ .enumerate()
+ .filter_map(|(index, vertex)| {
+ (vertex.flags & POLY3D_SPLINE_POINT == 0).then_some(index)
+ })
+ .collect();
+ if controls.is_empty() {
+ (0..pl.vertices.len()).collect()
+ } else {
+ controls
+ }
+}
+
+pub(crate) fn polyline3d_control_vertex_count(pl: &Polyline3D) -> usize {
+ polyline3d_control_indices(pl).len()
+}
+
+fn polyline3d_controls(pl: &Polyline3D) -> Vec {
+ polyline3d_control_indices(pl)
+ .into_iter()
+ .filter_map(|index| pl.vertices.get(index).cloned())
+ .collect()
+}
+
+fn polyline3d_wire_points(pl: &Polyline3D) -> Vec<[f64; 3]> {
+ let curve: Vec<[f64; 3]> = pl
+ .vertices
+ .iter()
+ .filter(|vertex| vertex.flags & POLY3D_SPLINE_POINT != 0)
+ .map(|vertex| [vertex.position.x, vertex.position.y, vertex.position.z])
+ .collect();
+ if !curve.is_empty() {
+ curve
+ } else {
+ polyline3d_controls(pl)
+ .iter()
+ .map(|vertex| [vertex.position.x, vertex.position.y, vertex.position.z])
+ .collect()
+ }
+}
+
+fn polyline3d_length(pl: &Polyline3D) -> f64 {
+ let points = polyline3d_wire_points(pl);
+ let mut length = points
+ .windows(2)
+ .map(|pair| Vec3::from(pair[0]).distance(Vec3::from(pair[1])))
+ .sum::();
+ if pl.is_closed() && points.len() >= 2 {
+ length += Vec3::from(points[0])
+ .distance(Vec3::from(points[points.len() - 1]));
+ }
+ length
+}
+
+fn polyline3d_spline_samples(
+ controls: &[[f64; 3]],
+ degree: usize,
+ closed: bool,
+) -> Option> {
+ if !controls.iter().flatten().all(|value| value.is_finite()) {
+ return None;
+ }
+ let curve = if closed {
+ let mut periodic_controls = controls.to_vec();
+ periodic_controls.extend(controls.iter().take(degree).copied());
+ let knots = (0..periodic_controls.len() + degree + 1)
+ .map(|index| index as f64)
+ .collect();
+ NurbsCurve3::new(degree, periodic_controls, knots, None)?.with_periodicity(true)
+ } else {
+ NurbsCurve3::new(degree, controls.to_vec(), Vec::new(), None)?
+ };
+ let mut samples = curve.tessellate_angle(cadkernel::tessellation::DEFAULT_ANGLE);
+ if closed
+ && samples.len() >= 2
+ && Vec3::from(samples[0]).distance(Vec3::from(samples[samples.len() - 1])) <= 1.0e-9
+ {
+ samples.pop();
+ }
+ Some(samples)
+}
+
+fn rebuild_polyline3d_fit(pl: &mut Polyline3D) -> bool {
+ use acadrust::entities::polyline3d::SmoothSurfaceType as SST;
+
+ let mut controls = polyline3d_controls(pl);
+ if pl.smooth_type == SST::None {
+ for vertex in &mut controls {
+ vertex.flags =
+ (vertex.flags & !(POLY3D_SPLINE_POINT | POLY3D_SPLINE_CONTROL)) | POLY3D_VERTEX;
+ }
+ pl.vertices = controls;
+ pl.flags.spline_fit = false;
+ return true;
+ }
+
+ if pl.smooth_type == SST::Bezier {
+ pl.smooth_type = SST::CubicBSpline;
+ }
+ let degree = match pl.smooth_type {
+ SST::QuadraticBSpline => 2,
+ SST::CubicBSpline => 3,
+ SST::Bezier => unreachable!(),
+ SST::None => unreachable!(),
+ };
+ if controls.len() <= degree {
+ return false;
+ }
+ let points: Vec<[f64; 3]> = controls
+ .iter()
+ .map(|vertex| [vertex.position.x, vertex.position.y, vertex.position.z])
+ .collect();
+ let Some(samples) = polyline3d_spline_samples(&points, degree, pl.is_closed()) else {
+ return false;
+ };
+
+ for vertex in &mut controls {
+ vertex.flags = (vertex.flags & !(POLY3D_SPLINE_POINT | POLY3D_SPLINE_CONTROL))
+ | POLY3D_VERTEX
+ | POLY3D_SPLINE_CONTROL;
+ }
+ let vertex_layer = controls
+ .first()
+ .map(|vertex| vertex.layer.clone())
+ .unwrap_or_else(|| "0".to_string());
+ let curve_vertices = samples.into_iter().map(|point| {
+ let mut vertex = acadrust::entities::Vertex3DPolyline::from_xyz(
+ point[0], point[1], point[2],
+ );
+ vertex.layer = vertex_layer.clone();
+ vertex.flags = POLY3D_VERTEX | POLY3D_SPLINE_POINT;
+ vertex
+ });
+ controls.extend(curve_vertices);
+ pl.vertices = controls;
+ pl.flags.spline_fit = true;
+ true
+}
+
fn tessellate_polyline3d(pl: &Polyline3D) -> RenderEntity {
let to_pt = |v: &acadrust::entities::Vertex3DPolyline| -> [f64; 3] {
[v.position.x, v.position.y, v.position.z]
};
- // DXF vertex flags: 8 = spline-fit curve point, 16 = spline frame control point.
- // When spline-fit vertices are present use them for the wire and control points for snap;
- // otherwise treat all vertices uniformly.
- let spline_curve: Vec<_> = pl.vertices.iter().filter(|v| v.flags & 8 != 0).collect();
- let ctrl_pts: Vec<_> = pl.vertices.iter().filter(|v| v.flags & 16 != 0).collect();
+ // Spline samples draw the wire; frame vertices remain snap points.
+ let spline_curve: Vec<_> = pl
+ .vertices
+ .iter()
+ .filter(|v| v.flags & POLY3D_SPLINE_POINT != 0)
+ .collect();
+ let ctrl_pts: Vec<_> = pl
+ .vertices
+ .iter()
+ .filter(|v| v.flags & POLY3D_SPLINE_CONTROL != 0)
+ .collect();
let (wire_pts, key_verts) = if !spline_curve.is_empty() {
let wire: Vec<[f64; 3]> = spline_curve.iter().map(|v| to_pt(v)).collect();
- let ctrl: Vec<[f64; 3]> = ctrl_pts.iter().map(|v| to_pt(v)).collect();
+ let ctrl: Vec<[f64; 3]> = if ctrl_pts.is_empty() {
+ polyline3d_controls(pl).iter().map(to_pt).collect()
+ } else {
+ ctrl_pts.iter().map(|v| to_pt(v)).collect()
+ };
(wire, ctrl)
} else {
let pts: Vec<[f64; 3]> = pl.vertices.iter().map(to_pt).collect();
@@ -826,8 +999,9 @@ impl RenderConvertible for Polyline3D {
impl Grippable for Polyline3D {
fn grips(&self) -> Vec {
- self.vertices
- .iter()
+ polyline3d_control_indices(self)
+ .into_iter()
+ .filter_map(|index| self.vertices.get(index))
.enumerate()
.map(|(i, v)| {
square_grip(
@@ -839,7 +1013,8 @@ impl Grippable for Polyline3D {
}
fn apply_grip(&mut self, grip_id: usize, apply: GripApply) {
- if let Some(v) = self.vertices.get_mut(grip_id) {
+ let raw_index = polyline3d_control_indices(self).get(grip_id).copied();
+ if let Some(v) = raw_index.and_then(|index| self.vertices.get_mut(index)) {
match apply {
GripApply::Translate(d) => {
v.position.x += d.x as f64;
@@ -852,12 +1027,19 @@ impl Grippable for Polyline3D {
v.position.z = p.z as f64;
}
}
+ if self.smooth_type
+ != acadrust::entities::polyline3d::SmoothSurfaceType::None
+ {
+ let _ = rebuild_polyline3d_fit(self);
+ }
}
}
fn grip_menu(&self, _grip_id: usize) -> Vec {
use crate::scene::model::object::{GripMenuAction, GripMenuItem};
- vec![
+ use acadrust::entities::polyline3d::SmoothSurfaceType as SST;
+
+ let mut items = vec![
GripMenuItem {
label: "Stretch",
action: GripMenuAction::Stretch,
@@ -866,40 +1048,75 @@ impl Grippable for Polyline3D {
label: "Add Vertex",
action: GripMenuAction::AddVertex,
},
- GripMenuItem {
+ ];
+ let minimum = match self.smooth_type {
+ SST::None if self.is_closed() => 3,
+ SST::None => 2,
+ SST::QuadraticBSpline => 3,
+ SST::CubicBSpline | SST::Bezier => 4,
+ };
+ if polyline3d_control_vertex_count(self) > minimum {
+ items.push(GripMenuItem {
label: "Remove Vertex",
action: GripMenuAction::RemoveVertex,
- },
- ]
+ });
+ }
+ items
}
fn apply_grip_menu(&mut self, grip_id: usize, action: crate::scene::model::object::GripMenuAction) {
use crate::scene::model::object::GripMenuAction as A;
- let n = self.vertices.len();
+ use acadrust::entities::polyline3d::SmoothSurfaceType as SST;
+
+ let mut controls = polyline3d_controls(self);
+ let n = controls.len();
match action {
A::AddVertex if grip_id < n => {
if grip_id == n - 1 && !self.is_closed() {
- let mut new_v = self.vertices[grip_id].clone();
+ let mut new_v = controls[grip_id].clone();
+ if n >= 2 {
+ let previous = &controls[n - 2].position;
+ let last = &controls[n - 1].position;
+ let next = Vec3::new(previous.x, previous.y, previous.z).lerp(
+ Vec3::new(last.x, last.y, last.z),
+ 2.0,
+ );
+ new_v.position.x = next.x;
+ new_v.position.y = next.y;
+ new_v.position.z = next.z;
+ }
new_v.handle = acadrust::Handle::NULL;
- self.vertices.push(new_v);
- return;
+ controls.push(new_v);
+ } else {
+ let i1 = (grip_id + 1) % n;
+ let v0 = &controls[grip_id];
+ let v1 = &controls[i1];
+ let midpoint = Vec3::new(v0.position.x, v0.position.y, v0.position.z).lerp(
+ Vec3::new(v1.position.x, v1.position.y, v1.position.z),
+ 0.5,
+ );
+ let mut new_v = v0.clone();
+ new_v.position.x = midpoint.x;
+ new_v.position.y = midpoint.y;
+ new_v.position.z = midpoint.z;
+ new_v.handle = acadrust::Handle::NULL;
+ controls.insert(grip_id + 1, new_v);
}
- let i1 = (grip_id + 1) % n;
- let v0 = &self.vertices[grip_id];
- let v1 = &self.vertices[i1];
- let mx = (v0.position.x + v1.position.x) * 0.5;
- let my = (v0.position.y + v1.position.y) * 0.5;
- let mz = (v0.position.z + v1.position.z) * 0.5;
- let mut new_v = v0.clone();
- new_v.position.x = mx;
- new_v.position.y = my;
- new_v.position.z = mz;
- new_v.handle = acadrust::Handle::NULL;
- let insert_at = (grip_id + 1).min(self.vertices.len());
- self.vertices.insert(insert_at, new_v);
+ self.vertices = controls;
+ let _ = rebuild_polyline3d_fit(self);
}
- A::RemoveVertex if grip_id < n && n > 2 => {
- self.vertices.remove(grip_id);
+ A::RemoveVertex if grip_id < n => {
+ let minimum = match self.smooth_type {
+ SST::None if self.is_closed() => 3,
+ SST::None => 2,
+ SST::QuadraticBSpline => 3,
+ SST::CubicBSpline | SST::Bezier => 4,
+ };
+ if n > minimum {
+ controls.remove(grip_id);
+ self.vertices = controls;
+ let _ = rebuild_polyline3d_fit(self);
+ }
}
_ => {}
}
@@ -909,40 +1126,79 @@ impl Grippable for Polyline3D {
impl PropertyEditable for Polyline3D {
fn geometry_properties(&self, _text_style_names: &[String]) -> Vec {
use acadrust::entities::polyline3d::SmoothSurfaceType as SST;
- let n = self.vertices.len();
- let v0 = self.vertices.first();
- let vertex_x = v0.map(|v| v.position.x).unwrap_or_default();
- let vertex_y = v0.map(|v| v.position.y).unwrap_or_default();
- let vertex_z = v0.map(|v| v.position.z).unwrap_or_default();
+ let control_indices = polyline3d_control_indices(self);
+ let n = control_indices.len();
+ let vi = if n == 0 {
+ 0
+ } else {
+ crate::scene::view::dispatch::prop_current_vertex().min(n - 1)
+ };
+ let vertex = control_indices
+ .get(vi)
+ .and_then(|index| self.vertices.get(*index));
+ let vertex_x = vertex.map(|v| v.position.x).unwrap_or_default();
+ let vertex_y = vertex.map(|v| v.position.y).unwrap_or_default();
+ let vertex_z = vertex.map(|v| v.position.z).unwrap_or_default();
+ let vertex_label = if n == 0 {
+ "—".to_string()
+ } else {
+ format!("{} / {}", vi + 1, n)
+ };
let fit_smooth = match self.smooth_type {
SST::None => "None",
SST::QuadraticBSpline => "Quadratic",
- SST::CubicBSpline => "Cubic",
- SST::Bezier => "Bezier",
+ SST::CubicBSpline | SST::Bezier => "Cubic",
};
+ let mut smooth_options = vec!["None".to_string()];
+ if n >= 3 {
+ smooth_options.push("Quadratic".to_string());
+ }
+ if n >= 4 {
+ smooth_options.push("Cubic".to_string());
+ }
vec![
PropSection {
title: t!("Geometry").into_owned(),
props: vec![
- ro(t!("Vertex").as_ref(), "pl3_vertex", if n > 0 { "1" } else { "" }),
+ stepper(
+ t!("Current Vertex").as_ref(),
+ "pl3_current_vertex",
+ vertex_label,
+ ),
edit(t!("Vertex X").as_ref(), "pl3_vertex_x", vertex_x),
edit(t!("Vertex Y").as_ref(), "pl3_vertex_y", vertex_y),
edit(t!("Vertex Z").as_ref(), "pl3_vertex_z", vertex_z),
+ ro(
+ t!("Length").as_ref(),
+ "pl3_length",
+ format_length(polyline3d_length(self)),
+ ),
],
},
PropSection {
title: t!("Misc").into_owned(),
props: vec![
Property {
+ label: t!("Fit/Smooth").into_owned(),
+ field: "pl3_smooth",
+ value: PropValue::Choice {
+ selected: fit_smooth.to_string(),
+ options: smooth_options,
+ },
+ },
+ Property {
label: t!("Closed").into_owned(),
field: "pl3_closed",
- value: PropValue::BoolToggle {
- field: "pl3_closed",
- value: self.is_closed(),
+ value: if n >= 3 {
+ PropValue::BoolToggle {
+ field: "pl3_closed",
+ value: self.is_closed(),
+ }
+ } else {
+ PropValue::ReadOnly("No".to_string())
},
},
- ro(t!("Fit/Smooth").as_ref(), "pl3_smooth", fit_smooth),
],
},
]
@@ -956,25 +1212,65 @@ impl PropertyEditable for Polyline3D {
} else {
value == "true"
};
+ if closed && polyline3d_control_vertex_count(self) < 3 {
+ return;
+ }
if closed {
self.close();
} else {
self.open();
}
+ if self.smooth_type
+ != acadrust::entities::polyline3d::SmoothSurfaceType::None
+ {
+ let _ = rebuild_polyline3d_fit(self);
+ }
+ }
+ "pl3_smooth" => {
+ use acadrust::entities::polyline3d::SmoothSurfaceType as SST;
+ let next = match value {
+ "None" => SST::None,
+ "Quadratic" => SST::QuadraticBSpline,
+ "Cubic" => SST::CubicBSpline,
+ _ => return,
+ };
+ let previous = self.smooth_type;
+ self.smooth_type = next;
+ if !rebuild_polyline3d_fit(self) {
+ self.smooth_type = previous;
+ }
}
"pl3_vertex_x" => {
- if let (Some(v), Some(vert)) = (parse_f64(value), self.vertices.first_mut()) {
+ let vi = crate::scene::view::dispatch::prop_current_vertex();
+ let raw_index = polyline3d_control_indices(self).get(vi).copied();
+ if let (Some(v), Some(vert)) = (
+ parse_f64(value).filter(|value| value.is_finite()),
+ raw_index.and_then(|index| self.vertices.get_mut(index)),
+ ) {
vert.position.x = v;
+ let _ = rebuild_polyline3d_fit(self);
}
}
"pl3_vertex_y" => {
- if let (Some(v), Some(vert)) = (parse_f64(value), self.vertices.first_mut()) {
+ let vi = crate::scene::view::dispatch::prop_current_vertex();
+ let raw_index = polyline3d_control_indices(self).get(vi).copied();
+ if let (Some(v), Some(vert)) = (
+ parse_f64(value).filter(|value| value.is_finite()),
+ raw_index.and_then(|index| self.vertices.get_mut(index)),
+ ) {
vert.position.y = v;
+ let _ = rebuild_polyline3d_fit(self);
}
}
"pl3_vertex_z" => {
- if let (Some(v), Some(vert)) = (parse_f64(value), self.vertices.first_mut()) {
+ let vi = crate::scene::view::dispatch::prop_current_vertex();
+ let raw_index = polyline3d_control_indices(self).get(vi).copied();
+ if let (Some(v), Some(vert)) = (
+ parse_f64(value).filter(|value| value.is_finite()),
+ raw_index.and_then(|index| self.vertices.get_mut(index)),
+ ) {
vert.position.z = v;
+ let _ = rebuild_polyline3d_fit(self);
}
}
_ => {}
diff --git a/src/locale_catalog.rs b/src/locale_catalog.rs
index 92147a70e..f86fcc48e 100644
--- a/src/locale_catalog.rs
+++ b/src/locale_catalog.rs
@@ -325,6 +325,7 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static
"Arrow size (DIMASZ)" => Some(("styles", "arrow-size-dimasz")),
"Arrowhead" => Some(("common", "arrowhead")),
"Arrowhead (DIMBLK)" => Some(("styles", "arrowhead-dimblk")),
+ "Arrowhead Size" => Some(("properties", "arrowhead-size")),
"Arrowhead block:" => Some(("styles", "arrowhead-block")),
"Arrowhead size" => Some(("common", "arrowhead-size")),
"Arrowhead size:" => Some(("styles", "arrowhead-size")),
@@ -332,6 +333,7 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static
"Arrows out first" => Some(("styles", "arrows-out-first")),
"As displayed" => Some(("common", "as-displayed")),
"Associative" => Some(("properties", "associative")),
+ "Attachment" => Some(("properties", "attachment")),
"At first ext" => Some(("styles", "at-first-ext")),
"At least" => Some(("properties", "at-least")),
"At second ext" => Some(("styles", "at-second-ext")),
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]