cad-editor/src/entities/leader.rs

723 lines
25 KiB
Rust
Raw Normal View History

2026-03-22 14:56:27 +03:00
use acadrust::entities::{HooklineDirection, Leader, LeaderCreationType, LeaderPathType};
use acadrust::Entity;
use glam::Vec3;
use crate::command::EntityTransform;
use crate::entities::common::{
center_grip, edit_prop as edit, ro_prop as ro, square_grip, stepper_prop as stepper,
};
use crate::entities::traits::TruckConvertible;
use crate::scene::convert::acad_to_truck::{TruckEntity, TruckObject};
use crate::scene::model::object::{GripApply, GripDef, PropSection, PropValue, Property};
use crate::scene::model::wire_model::TangentGeom;
2026-03-22 14:56:27 +03:00
// ── TruckConvertible (used for snap/grip key-vertices) ─────────────────────
fn to_truck(leader: &Leader) -> TruckEntity {
let verts = &leader.vertices;
let nan = [f64::NAN; 3];
let p3 = |v: &acadrust::types::Vector3| -> [f64; 3] { [v.x, v.y, v.z] };
let p3f = |v: &acadrust::types::Vector3| -> [f32; 3] { [v.x as f32, v.y as f32, v.z as f32] };
2026-03-22 14:56:27 +03:00
let mut points: Vec<[f64; 3]> = Vec::new();
2026-03-22 14:56:27 +03:00
let mut tangents: Vec<TangentGeom> = Vec::new();
let mut key_verts: Vec<[f64; 3]> = Vec::new();
2026-03-22 14:56:27 +03:00
// Main leader path
for v in verts {
points.push(p3(v));
key_verts.push(p3(v));
}
for i in 0..verts.len().saturating_sub(1) {
// TangentGeom uses f32 (UI-only); cast at construction.
2026-05-12 10:36:15 +03:00
tangents.push(TangentGeom::Line {
p1: p3f(&verts[i]),
p2: p3f(&verts[i + 1]),
2026-05-12 10:36:15 +03:00
});
2026-03-22 14:56:27 +03:00
}
// Arrowhead at vertex[0]
if leader.arrow_enabled && verts.len() >= 2 {
let tip = &verts[0];
let next = &verts[1];
let dx = next.x - tip.x;
let dy = next.y - tip.y;
2026-03-22 14:56:27 +03:00
let len = (dx * dx + dy * dy).sqrt().max(1e-9);
let (dx, dy) = (dx / len, dy / len);
// Arrowhead sized to the text height, matching the MLEADER arrowhead.
let sz = (leader.text_height).max(1.0);
let a = std::f64::consts::PI / 6.0;
2026-03-22 14:56:27 +03:00
let (s, c) = a.sin_cos();
let tip_f = p3(tip);
points.push(nan);
2026-05-12 10:36:15 +03:00
points.push([
tip_f[0] + (dx * c - dy * s) * sz,
tip_f[1] + (dx * s + dy * c) * sz,
tip_f[2],
]);
2026-03-22 14:56:27 +03:00
points.push(tip_f);
2026-05-12 10:36:15 +03:00
points.push([
tip_f[0] + (dx * c + dy * s) * sz,
tip_f[1] + (-dx * s + dy * c) * sz,
tip_f[2],
]);
2026-03-22 14:56:27 +03:00
}
// Landing line at last vertex
if leader.hookline_enabled && verts.len() >= 2 {
let last = verts.last().unwrap();
let prev = &verts[verts.len() - 2];
// Landing runs along the leader's horizontal direction (UCS X for
// UCS-placed leaders, world X otherwise), on the side the leader
// approaches from.
let (hx, hy) = {
let h = leader.horizontal_direction;
let l = (h.x * h.x + h.y * h.y).sqrt();
if l > 1e-9 {
(h.x / l, h.y / l)
} else {
(1.0, 0.0)
}
};
let sign = if (last.x - prev.x) * hx + (last.y - prev.y) * hy >= 0.0 {
1.0_f64
} else {
-1.0_f64
};
let len = leader.text_height * 1.5;
2026-03-22 14:56:27 +03:00
let last_f = p3(last);
points.push(nan);
points.push(last_f);
points.push([
last_f[0] + sign * len * hx,
last_f[1] + sign * len * hy,
last_f[2],
]);
2026-03-22 14:56:27 +03:00
}
TruckEntity {
fix(pick): hit-test lineweight width and thickness walls Selection tested a flat 8 px radius around each wire's centreline and nothing else, so two things the user can see were not pickable. A wire renders as a band `line_weight_px` wide, so pick now widens to that rendered half-width via `pick_tolerance_px`, mirroring the wire shader's `select(0.5, half_width, lwdisplay_enable)`. Behaviour is unchanged for every standard weight (the widest, 2.11 mm, renders 7.97 px half-width — just under the 8 px threshold); this keeps the two sides from drifting apart if the display boost in `lineweight_to_px` ever changes. Entities extruded by a DXF thickness draw their wall as four edges with nothing in between, so the interior was a hole the cursor fell through. WireModel gains `pick_tris`: triangles hit-testing treats as solid and the renderer never uploads. Kept apart from `fill_tris` because that channel reaches the GPU, and a wall that rendered shaded would change how every thickness drawing looks. Ranked below `fill_tris` — those are drawn, these are not, so where they overlap the drawn face is what the user means. Walls are dropped when a wire is projected into a floating viewport: `points` are reprojected and clipped into paper coords there, so cloned model-space triangles would hit-test at a model-scale offset. Extruded entities stay selectable by their edges, which do get projected. The wall loop runs on every hover that misses everything else, so it rejects on the wire's AABB first — an extruded circle is ~128 triangles and a drawing full of thickness would otherwise project every wall on each mouse move. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 14:58:55 +03:00
pick_tris: Vec::new(),
2026-03-22 14:56:27 +03:00
object: TruckObject::Lines(points),
snap_pts: vec![],
tangent_geoms: tangents,
key_vertices: key_verts,
fill_tris: vec![],
2026-03-22 14:56:27 +03:00
}
}
// ── Grips ──────────────────────────────────────────────────────────────────
fn grips(leader: &Leader) -> Vec<GripDef> {
let n = leader.vertices.len();
let mut grips: Vec<GripDef> = leader
.vertices
.iter()
.enumerate()
.map(|(i, v)| square_grip(i, glam::DVec3::new(v.x, v.y, v.z)))
2026-03-22 14:56:27 +03:00
.collect();
if n >= 2 {
let sum = leader.vertices.iter().fold(glam::DVec3::ZERO, |acc, v| {
acc + glam::DVec3::new(v.x, v.y, v.z)
2026-03-22 14:56:27 +03:00
});
grips.push(center_grip(n, sum / n as f64));
2026-03-22 14:56:27 +03:00
}
grips
}
fn apply_grip(leader: &mut Leader, grip_id: usize, apply: GripApply) {
let n = leader.vertices.len();
if grip_id < n {
if let Some(v) = leader.vertices.get_mut(grip_id) {
match apply {
GripApply::Absolute(p) => {
v.x = p.x as f64;
v.y = p.y as f64;
v.z = p.z as f64;
}
GripApply::Translate(d) => {
v.x += d.x as f64;
v.y += d.y as f64;
v.z += d.z as f64;
}
}
}
} else if let GripApply::Translate(d) = apply {
leader.translate(acadrust::types::Vector3::new(
2026-05-12 10:36:15 +03:00
d.x as f64, d.y as f64, d.z as f64,
2026-03-22 14:56:27 +03:00
));
}
}
// ── Properties ─────────────────────────────────────────────────────────────
fn choice_prop(label: &str, field: &'static str, selected: &str, options: &[&str]) -> Property {
Property {
label: label.into(),
field,
value: PropValue::Choice {
selected: selected.to_string(),
options: options.iter().map(|s| s.to_string()).collect(),
},
}
}
/// Combined path/arrow "Type" value (path shape × arrowhead flag).
fn leader_type_str(path: &LeaderPathType, arrow: bool) -> &'static str {
match (path, arrow) {
(LeaderPathType::StraightLine, true) => "Line with arrow",
(LeaderPathType::StraightLine, false) => "Line without arrow",
(LeaderPathType::Spline, true) => "Spline with arrow",
(LeaderPathType::Spline, false) => "Spline without arrow",
}
}
fn properties(leader: &Leader) -> Vec<PropSection> {
2026-03-22 14:56:27 +03:00
let n = leader.vertices.len();
// The panel's Current Vertex focus, clamped to this leader's range.
let vi = if n == 0 {
0
} else {
crate::scene::view::dispatch::prop_current_vertex().min(n - 1)
};
let vertex_label = if n == 0 {
"—".to_string()
} else {
format!("{} / {}", vi + 1, n)
};
// Geometry sits right after General — a leader owns editable path vertices,
// navigated one at a time by the Current Vertex spinner.
let mut geometry = vec![stepper("Current Vertex", "current_vertex", vertex_label)];
if let Some(v) = leader.vertices.get(vi) {
geometry.push(edit("Vertex X", "vertex_x", v.x));
geometry.push(edit("Vertex Y", "vertex_y", v.y));
geometry.push(edit("Vertex Z", "vertex_z", v.z));
} else {
geometry.push(ro("Vertex X", "vertex_x", String::new()));
geometry.push(ro("Vertex Y", "vertex_y", String::new()));
geometry.push(ro("Vertex Z", "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).
let misc = vec![
Property {
label: "Dim style".into(),
field: "dimension_style",
value: PropValue::EditText(leader.dimension_style.clone()),
},
choice_prop(
"Type",
"leader_type",
leader_type_str(&leader.path_type, leader.arrow_enabled),
&[
"Line with arrow",
"Line without arrow",
"Spline with arrow",
"Spline without arrow",
],
),
ro("Annotative", "annotative", "No"),
];
// Lines & Arrows / Text / Fit are dimension-style-derived; the panel builder
// resolves leader.dimension_style and fills these values from the DimStyle.
let lines_arrows = vec![
ro("Arrow", "arrow_block", "Closed filled"),
ro("Arrow size", "arrow_size", String::new()),
ro("Dim line lineweight", "dim_line_lw", "ByLayer"),
ro("Dim line color", "dim_line_color", "ByLayer"),
2026-03-22 14:56:27 +03:00
];
let text = vec![
ro("Text offset", "text_offset", String::new()),
ro("Text pos vert", "text_pos_vert", String::new()),
];
let fit = vec![ro("Dim scale overall", "dim_scale_overall", String::new())];
vec![
PropSection {
title: "Geometry".into(),
props: geometry,
},
PropSection {
title: "Misc".into(),
props: misc,
},
PropSection {
title: "Lines & Arrows".into(),
props: lines_arrows,
},
PropSection {
title: "Text".into(),
props: text,
},
PropSection {
title: "Fit".into(),
props: fit,
},
]
2026-03-22 14:56:27 +03:00
}
fn apply_geom_prop(leader: &mut Leader, field: &str, value: &str) {
let f64 = |s: &str| -> Option<f64> { s.trim().parse().ok() };
// Vertex X/Y/Z edit whichever vertex the Current Vertex navigator focuses.
let vi = if leader.vertices.is_empty() {
0
} else {
crate::scene::view::dispatch::prop_current_vertex().min(leader.vertices.len() - 1)
};
2026-03-22 14:56:27 +03:00
match field {
"dimension_style" => leader.dimension_style = value.to_string(),
"leader_type" => {
let (p, a) = match value {
"Line without arrow" => (LeaderPathType::StraightLine, false),
"Spline with arrow" => (LeaderPathType::Spline, true),
"Spline without arrow" => (LeaderPathType::Spline, false),
_ => (LeaderPathType::StraightLine, true),
};
leader.path_type = p;
leader.arrow_enabled = a;
}
2026-03-22 14:56:27 +03:00
"path_type" => {
leader.path_type = match value {
"Spline" => LeaderPathType::Spline,
_ => LeaderPathType::StraightLine,
};
}
"creation_type" => {
leader.creation_type = match value {
"With Tolerance" => LeaderCreationType::WithTolerance,
"With Block" => LeaderCreationType::WithBlock,
"No Annotation" => LeaderCreationType::NoAnnotation,
_ => LeaderCreationType::WithText,
};
}
2026-05-12 10:36:15 +03:00
"arrow_enabled" => {
leader.arrow_enabled = if value == "toggle" {
!leader.arrow_enabled
} else {
value == "true"
}
}
"hookline_enabled" => {
leader.hookline_enabled = if value == "toggle" {
!leader.hookline_enabled
} else {
value == "true"
}
}
2026-03-22 14:56:27 +03:00
"hookline_direction" => {
leader.hookline_direction = match value {
"Same" => HooklineDirection::Same,
_ => HooklineDirection::Opposite,
};
}
"text_height" => {
if let Some(v) = f64(value) {
leader.text_height = v;
}
}
"text_width" => {
if let Some(v) = f64(value) {
leader.text_width = v;
}
}
"normal_x" => {
if let Some(v) = f64(value) {
leader.normal.x = v;
}
}
"normal_y" => {
if let Some(v) = f64(value) {
leader.normal.y = v;
}
}
"normal_z" => {
if let Some(v) = f64(value) {
leader.normal.z = v;
}
}
"h_dir_x" => {
if let Some(v) = f64(value) {
leader.horizontal_direction.x = v;
}
}
"h_dir_y" => {
if let Some(v) = f64(value) {
leader.horizontal_direction.y = v;
}
}
"h_dir_z" => {
if let Some(v) = f64(value) {
leader.horizontal_direction.z = v;
}
}
"block_offset_x" => {
if let Some(v) = f64(value) {
leader.block_offset.x = v;
}
}
"block_offset_y" => {
if let Some(v) = f64(value) {
leader.block_offset.y = v;
}
}
"block_offset_z" => {
if let Some(v) = f64(value) {
leader.block_offset.z = v;
}
}
"ann_offset_x" => {
if let Some(v) = f64(value) {
leader.annotation_offset.x = v;
}
}
"ann_offset_y" => {
if let Some(v) = f64(value) {
leader.annotation_offset.y = v;
}
}
"ann_offset_z" => {
if let Some(v) = f64(value) {
leader.annotation_offset.z = v;
}
}
"vertex_x" => {
if let (Some(v), Some(vert)) = (f64(value), leader.vertices.get_mut(vi)) {
2026-03-22 14:56:27 +03:00
vert.x = v;
}
}
"vertex_y" => {
if let (Some(v), Some(vert)) = (f64(value), leader.vertices.get_mut(vi)) {
2026-03-22 14:56:27 +03:00
vert.y = v;
}
}
"vertex_z" => {
if let (Some(v), Some(vert)) = (f64(value), leader.vertices.get_mut(vi)) {
2026-03-22 14:56:27 +03:00
vert.z = v;
}
}
_ => {}
}
}
// ── Transform ──────────────────────────────────────────────────────────────
fn apply_transform(leader: &mut Leader, t: &EntityTransform) {
crate::scene::view::transform::apply_standard_entity_transform(leader, t, |entity, p1, p2| {
2026-03-22 14:56:27 +03:00
for v in &mut entity.vertices {
crate::scene::view::transform::reflect_xy_point(&mut v.x, &mut v.y, p1, p2);
2026-03-22 14:56:27 +03:00
}
crate::scene::view::transform::reflect_xy_point(
2026-03-22 14:56:27 +03:00
&mut entity.block_offset.x,
&mut entity.block_offset.y,
p1,
p2,
);
crate::scene::view::transform::reflect_xy_point(
2026-03-22 14:56:27 +03:00
&mut entity.annotation_offset.x,
&mut entity.annotation_offset.y,
p1,
p2,
);
});
}
// ── Trait impls ────────────────────────────────────────────────────────────
impl TruckConvertible for Leader {
fn to_truck(&self, _document: &acadrust::CadDocument) -> Option<TruckEntity> {
2026-05-12 10:36:15 +03:00
if self.vertices.is_empty() {
return None;
}
2026-03-22 14:56:27 +03:00
Some(to_truck(self))
}
}
impl crate::entities::traits::Grippable for Leader {
fn grips(&self) -> Vec<GripDef> {
grips(self)
}
fn apply_grip(&mut self, grip_id: usize, apply: GripApply) {
apply_grip(self, grip_id, apply);
}
fn grip_menu(&self, grip_id: usize) -> Vec<crate::scene::model::object::GripMenuItem> {
use crate::scene::model::object::{GripMenuAction, GripMenuItem};
let n = self.vertices.len();
if grip_id == 0 {
// Arrow head — stretch only.
vec![GripMenuItem {
label: "Stretch",
action: GripMenuAction::Stretch,
}]
} else if grip_id < n {
vec![
GripMenuItem {
label: "Stretch",
action: GripMenuAction::Stretch,
},
GripMenuItem {
label: "Add Vertex",
action: GripMenuAction::AddVertex,
},
GripMenuItem {
label: "Remove Vertex",
action: GripMenuAction::RemoveVertex,
},
]
} else {
// Centroid grip — move whole leader.
vec![GripMenuItem {
label: "Stretch",
action: GripMenuAction::Stretch,
}]
}
}
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();
match action {
A::AddVertex if grip_id < n => {
let i1 = (grip_id + 1).min(n - 1);
if i1 == grip_id {
return;
}
let v0 = &self.vertices[grip_id];
let v1 = &self.vertices[i1];
let mid = acadrust::types::Vector3::new(
(v0.x + v1.x) * 0.5,
(v0.y + v1.y) * 0.5,
(v0.z + v1.z) * 0.5,
);
self.vertices.insert(i1, mid);
}
A::RemoveVertex if grip_id < n && n > 2 => {
self.vertices.remove(grip_id);
}
_ => {}
}
}
}
impl crate::entities::traits::PropertyEditable for Leader {
fn geometry_properties(&self, _text_style_names: &[String]) -> Vec<PropSection> {
properties(self)
}
fn apply_geom_prop(&mut self, field: &str, value: &str) {
apply_geom_prop(self, field, value);
}
}
impl crate::entities::traits::Transformable for Leader {
fn apply_transform(&mut self, t: &EntityTransform) {
apply_transform(self, t);
}
}
/// Per-entity tessellation entry for `Leader`. Lives here so all leader
/// tess code stays alongside the entity definition. Cross-entity dim
/// machinery (arrow shapes, `DimGeom`) lives in `scene::convert::tessellate` and
/// is reused via the dim arrow emitter so the leader matches the active
/// DIMSTYLE.
pub trait LeaderTess {
fn tessellate(
&self,
document: &acadrust::CadDocument,
handle: acadrust::Handle,
selected: bool,
entity_color: [f32; 4],
line_weight_px: f32,
anno_scale: f32,
) -> crate::scene::model::wire_model::WireModel;
}
impl LeaderTess for Leader {
fn tessellate(
&self,
document: &acadrust::CadDocument,
handle: acadrust::Handle,
selected: bool,
entity_color: [f32; 4],
line_weight_px: f32,
anno_scale: f32,
) -> crate::scene::model::wire_model::WireModel {
use crate::scene::convert::tessellate::{append_arrow, arrow_from_block, ArrowKind, DimGeom};
feat(properties): editable per-object leader dimension overrides A legacy leader's Lines & Arrows / Text / Fit rows are now editable per-object overrides of its dimension style, stored in the standard ACAD_DSTYLE XDATA record (new src/entities/dim_override.rs codec). Arrow block, arrow size, dim-line lineweight, text offset, vertical text position and overall scale each prefer an override over the style; the renderer honours arrow size / block / overall scale and the dim-line lineweight, so an edited leader redraws at its new arrow and weight. XDATA edits go through a new dispatch::set_entity_xdata, which registers the application in the APPID table (the DWG writer drops records for an unregistered app) and drops that app's stale verbatim EED block (which otherwise wins over the structured record on a DWG save), so edits and clears round-trip. The hyperlink editor moves onto this path too. Dim-line colour stays read-only: it would live in the leader's override_color, which the file layer doesn't serialise, so making it editable would silently lose the pick on save. Picking "Closed filled" writes an explicit null-handle arrow override rather than clearing, so it sticks even when the style's arrow differs; an unparseable numeric entry is ignored instead of wiping the stored override. Note: the DXF entity writer only emits XDATA for hatches, so these overrides (and hyperlinks) currently persist on DWG save but not DXF — an acadrust-side gap to close separately. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 00:28:42 +03:00
use crate::entities::dim_override as dov;
use crate::scene::model::wire_model::WireModel;
feat(properties): editable per-object leader dimension overrides A legacy leader's Lines & Arrows / Text / Fit rows are now editable per-object overrides of its dimension style, stored in the standard ACAD_DSTYLE XDATA record (new src/entities/dim_override.rs codec). Arrow block, arrow size, dim-line lineweight, text offset, vertical text position and overall scale each prefer an override over the style; the renderer honours arrow size / block / overall scale and the dim-line lineweight, so an edited leader redraws at its new arrow and weight. XDATA edits go through a new dispatch::set_entity_xdata, which registers the application in the APPID table (the DWG writer drops records for an unregistered app) and drops that app's stale verbatim EED block (which otherwise wins over the structured record on a DWG save), so edits and clears round-trip. The hyperlink editor moves onto this path too. Dim-line colour stays read-only: it would live in the leader's override_color, which the file layer doesn't serialise, so making it editable would silently lose the pick on save. Picking "Closed filled" writes an explicit null-handle arrow override rather than clearing, so it sticks even when the style's arrow differs; an unparseable numeric entry is ignored instead of wiping the stored override. Note: the DXF entity writer only emits XDATA for hatches, so these overrides (and hyperlinks) currently persist on DWG save but not DXF — an acadrust-side gap to close separately. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 00:28:42 +03:00
let xd = &self.common.extended_data;
// Dim-line colour: a per-object ACAD_DSTYLE override (code 176, an ACI
// index) wins over the assigned dim style's DIMCLRD; ByLayer / ByBlock
// (0 / 256) and no setting fall through to the entity colour.
let color = if selected {
WireModel::SELECTED
} else {
let dim_clr = dov::int(xd, dov::DIMCLRD).or_else(|| {
document
.dim_styles
.iter()
.find(|s| {
s.name.eq_ignore_ascii_case(&self.dimension_style)
|| (self.dimension_style.trim().is_empty()
&& s.name.eq_ignore_ascii_case("Standard"))
})
.map(|s| s.dimclrd)
});
match dim_clr {
Some(idx) if idx != 0 && idx != 256 => crate::scene::convert::tess_util::aci_to_rgba(
&acadrust::types::Color::from_index(idx),
),
_ => entity_color,
}
};
feat(properties): editable per-object leader dimension overrides A legacy leader's Lines & Arrows / Text / Fit rows are now editable per-object overrides of its dimension style, stored in the standard ACAD_DSTYLE XDATA record (new src/entities/dim_override.rs codec). Arrow block, arrow size, dim-line lineweight, text offset, vertical text position and overall scale each prefer an override over the style; the renderer honours arrow size / block / overall scale and the dim-line lineweight, so an edited leader redraws at its new arrow and weight. XDATA edits go through a new dispatch::set_entity_xdata, which registers the application in the APPID table (the DWG writer drops records for an unregistered app) and drops that app's stale verbatim EED block (which otherwise wins over the structured record on a DWG save), so edits and clears round-trip. The hyperlink editor moves onto this path too. Dim-line colour stays read-only: it would live in the leader's override_color, which the file layer doesn't serialise, so making it editable would silently lose the pick on save. Picking "Closed filled" writes an explicit null-handle arrow override rather than clearing, so it sticks even when the style's arrow differs; an unparseable numeric entry is ignored instead of wiping the stored override. Note: the DXF entity writer only emits XDATA for hatches, so these overrides (and hyperlinks) currently persist on DWG save but not DXF — an acadrust-side gap to close separately. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 00:28:42 +03:00
// A concrete DIMLWD override sets the leader line's weight; ByLayer /
// ByBlock / Default and no override keep the resolved weight passed in.
let line_weight_px = match dov::int(xd, dov::DIMLWD) {
Some(lwd) if lwd >= 0 => crate::scene::view::render::lineweight_to_px(
&acadrust::types::LineWeight::from_value(lwd),
),
_ => line_weight_px,
};
let name = handle.value().to_string();
let p3 = |v: &acadrust::types::Vector3| -> [f32; 3] {
[(v.x) as f32, (v.y) as f32, (v.z) as f32]
};
let nan = [f32::NAN; 3];
let verts = &self.vertices;
if verts.len() < 2 {
return WireModel {
fill_is_3d: false,
fix(pick): hit-test lineweight width and thickness walls Selection tested a flat 8 px radius around each wire's centreline and nothing else, so two things the user can see were not pickable. A wire renders as a band `line_weight_px` wide, so pick now widens to that rendered half-width via `pick_tolerance_px`, mirroring the wire shader's `select(0.5, half_width, lwdisplay_enable)`. Behaviour is unchanged for every standard weight (the widest, 2.11 mm, renders 7.97 px half-width — just under the 8 px threshold); this keeps the two sides from drifting apart if the display boost in `lineweight_to_px` ever changes. Entities extruded by a DXF thickness draw their wall as four edges with nothing in between, so the interior was a hole the cursor fell through. WireModel gains `pick_tris`: triangles hit-testing treats as solid and the renderer never uploads. Kept apart from `fill_tris` because that channel reaches the GPU, and a wall that rendered shaded would change how every thickness drawing looks. Ranked below `fill_tris` — those are drawn, these are not, so where they overlap the drawn face is what the user means. Walls are dropped when a wire is projected into a floating viewport: `points` are reprojected and clipped into paper coords there, so cloned model-space triangles would hit-test at a model-scale offset. Extruded entities stay selectable by their edges, which do get projected. The wall loop runs on every hover that misses everything else, so it rejects on the wire's AABB first — an extruded circle is ~128 triangles and a drawing full of thickness would otherwise project every wall on each mouse move. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 14:58:55 +03:00
pick_tris: Vec::new(),
pick_tris_low: Vec::new(),
dash_from_start: false,
feat(mline): GPU-dash multiline elements with shared A-type alignment MLINE dashed elements were fully CPU-expanded (apply_along) — one wire per dash — to keep the parallel lines in phase. Move pure-dash elements onto the GPU dash shader and end-align every element to the multiline's centre-line so they share one interior phase. - wire_distances now accumulates arc-length from the double-single (high+low) points, so the dash phase stays precise at UTM coordinates. f32-high-only deltas quantised ~0.1 there and drifted the parallel lines apart — the original reason MLINE dashes were CPU-side. - New WireModel.dash_align_end carries a shared begin/end dash length. The shader uses it as align_end for every parallel element (the interior phase depends on align_end, not the wire's own length) while align_total stays the element's own length, so each still ends on a dash at its own endpoint. - MLINE tessellation derives that length once from the centre-line, routes pure dash/space/dot elements to the GPU pattern and text-bearing elements to apply_along. - apply_along gained an "A"-type mode (shared reference length): a solid begin/end dash and a phased interior, so its dashes — and embedded text — line up with the GPU-dashed sibling elements instead of tiling from start. Pure-dash MLINEs now cost one WireModel + pattern per element instead of N segments; single (non-MLINE) entities keep the from-start tiling unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 02:59:11 +03:00
dash_align_end: None,
text_verts: Vec::new(),
name,
points: vec![],
points_low: Vec::new(),
color,
selected,
aci: 0,
pattern_length: 0.0,
pattern: [0.0; 8],
line_weight_px,
snap_pts: vec![],
tangent_geoms: vec![],
key_vertices: vec![],
aabb: WireModel::UNBOUNDED_AABB,
plinegen: true,
fill_tris: vec![],
fill_tris_low: Vec::new(),
};
}
let mut points: Vec<[f32; 3]> = verts.iter().map(|v| p3(v)).collect();
let mut tangents: Vec<TangentGeom> = Vec::new();
let key_vertices: Vec<[f64; 3]> = verts.iter().map(|v| [v.x, v.y, v.z]).collect();
let mut fill_tris: Vec<[f32; 3]> = Vec::new();
for i in 0..verts.len().saturating_sub(1) {
tangents.push(TangentGeom::Line {
p1: p3(&verts[i]),
p2: p3(&verts[i + 1]),
});
}
if self.arrow_enabled {
// Resolve the active dim style → DIMLDRBLK to pick the arrow shape.
// DIMASZ × DIMSCALE drives the size when available; otherwise fall
// back to the legacy text-height heuristic.
let style = document.dim_styles.iter().find(|s| {
s.name.eq_ignore_ascii_case(&self.dimension_style)
|| (self.dimension_style.trim().is_empty()
&& s.name.eq_ignore_ascii_case("Standard"))
});
feat(properties): editable per-object leader dimension overrides A legacy leader's Lines & Arrows / Text / Fit rows are now editable per-object overrides of its dimension style, stored in the standard ACAD_DSTYLE XDATA record (new src/entities/dim_override.rs codec). Arrow block, arrow size, dim-line lineweight, text offset, vertical text position and overall scale each prefer an override over the style; the renderer honours arrow size / block / overall scale and the dim-line lineweight, so an edited leader redraws at its new arrow and weight. XDATA edits go through a new dispatch::set_entity_xdata, which registers the application in the APPID table (the DWG writer drops records for an unregistered app) and drops that app's stale verbatim EED block (which otherwise wins over the structured record on a DWG save), so edits and clears round-trip. The hyperlink editor moves onto this path too. Dim-line colour stays read-only: it would live in the leader's override_color, which the file layer doesn't serialise, so making it editable would silently lose the pick on save. Picking "Closed filled" writes an explicit null-handle arrow override rather than clearing, so it sticks even when the style's arrow differs; an unparseable numeric entry is ignored instead of wiping the stored override. Note: the DXF entity writer only emits XDATA for hatches, so these overrides (and hyperlinks) currently persist on DWG save but not DXF — an acadrust-side gap to close separately. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 00:28:42 +03:00
// Each of DIMSCALE / DIMASZ / DIMLDRBLK prefers a per-object override
// over the style, so an edited leader arrow renders at its new size,
// scale and shape.
let dim_scale = dov::real(xd, dov::DIMSCALE)
.filter(|v| *v > 1e-6)
.or_else(|| style.map(|s| s.dimscale).filter(|v| *v > 1e-6))
.unwrap_or(anno_scale as f64);
feat(properties): editable per-object leader dimension overrides A legacy leader's Lines & Arrows / Text / Fit rows are now editable per-object overrides of its dimension style, stored in the standard ACAD_DSTYLE XDATA record (new src/entities/dim_override.rs codec). Arrow block, arrow size, dim-line lineweight, text offset, vertical text position and overall scale each prefer an override over the style; the renderer honours arrow size / block / overall scale and the dim-line lineweight, so an edited leader redraws at its new arrow and weight. XDATA edits go through a new dispatch::set_entity_xdata, which registers the application in the APPID table (the DWG writer drops records for an unregistered app) and drops that app's stale verbatim EED block (which otherwise wins over the structured record on a DWG save), so edits and clears round-trip. The hyperlink editor moves onto this path too. Dim-line colour stays read-only: it would live in the leader's override_color, which the file layer doesn't serialise, so making it editable would silently lose the pick on save. Picking "Closed filled" writes an explicit null-handle arrow override rather than clearing, so it sticks even when the style's arrow differs; an unparseable numeric entry is ignored instead of wiping the stored override. Note: the DXF entity writer only emits XDATA for hatches, so these overrides (and hyperlinks) currently persist on DWG save but not DXF — an acadrust-side gap to close separately. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 00:28:42 +03:00
let ovr_asz = dov::real(xd, dov::DIMASZ);
let arrow_size = match (ovr_asz, style) {
(Some(a), _) => (a * dim_scale) as f32,
(None, Some(s)) => (s.dimasz * dim_scale) as f32,
(None, None) => (self.text_height as f32).max(1.0) * anno_scale,
};
feat(properties): editable per-object leader dimension overrides A legacy leader's Lines & Arrows / Text / Fit rows are now editable per-object overrides of its dimension style, stored in the standard ACAD_DSTYLE XDATA record (new src/entities/dim_override.rs codec). Arrow block, arrow size, dim-line lineweight, text offset, vertical text position and overall scale each prefer an override over the style; the renderer honours arrow size / block / overall scale and the dim-line lineweight, so an edited leader redraws at its new arrow and weight. XDATA edits go through a new dispatch::set_entity_xdata, which registers the application in the APPID table (the DWG writer drops records for an unregistered app) and drops that app's stale verbatim EED block (which otherwise wins over the structured record on a DWG save), so edits and clears round-trip. The hyperlink editor moves onto this path too. Dim-line colour stays read-only: it would live in the leader's override_color, which the file layer doesn't serialise, so making it editable would silently lose the pick on save. Picking "Closed filled" writes an explicit null-handle arrow override rather than clearing, so it sticks even when the style's arrow differs; an unparseable numeric entry is ignored instead of wiping the stored override. Note: the DXF entity writer only emits XDATA for hatches, so these overrides (and hyperlinks) currently persist on DWG save but not DXF — an acadrust-side gap to close separately. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 00:28:42 +03:00
let arrow_blk = dov::handle(xd, dov::DIMLDRBLK).or_else(|| style.map(|s| s.dimldrblk));
let arrow = match arrow_blk {
Some(h) => arrow_from_block(document, h, arrow_size.max(0.001)),
None => ArrowKind::Triangle {
size: arrow_size.max(0.001),
filled: true,
size_mul: 1.0,
},
};
let tip = &verts[0];
let next = &verts[1];
let dx = (next.x - tip.x) as f32;
let dy = (next.y - tip.y) as f32;
let len = (dx * dx + dy * dy).sqrt().max(1e-9);
let dir = Vec3::new(dx / len, dy / len, 0.0);
let tip_f = p3(tip);
let tip_v = Vec3::new(tip_f[0], tip_f[1], tip_f[2]);
// Reuse the dim arrow emitter so the leader shape matches the
// DIMSTYLE in use (Closed Filled by default, Dot, Tick, …).
let mut arrow_pts: Vec<[f32; 3]> = Vec::new();
let mut arrow_geom = DimGeom::new();
append_arrow(&mut arrow_geom, tip_v, dir, &arrow);
if !arrow_geom.dim_lines.is_empty() {
arrow_pts.push(nan);
arrow_pts.extend(arrow_geom.dim_lines);
}
points.extend(arrow_pts);
fill_tris.extend(arrow_geom.arrow_fill);
}
if self.hookline_enabled {
let last = verts.last().unwrap();
let prev = &verts[verts.len() - 2];
let sign = if (last.x - prev.x) >= 0.0 {
1.0_f32
} else {
-1.0_f32
};
let land_len = self.text_height as f32 * 1.5 * anno_scale;
let last_f = p3(last);
points.push(nan);
points.push(last_f);
points.push([last_f[0] + sign * land_len, last_f[1], last_f[2]]);
}
WireModel {
fill_is_3d: false,
fix(pick): hit-test lineweight width and thickness walls Selection tested a flat 8 px radius around each wire's centreline and nothing else, so two things the user can see were not pickable. A wire renders as a band `line_weight_px` wide, so pick now widens to that rendered half-width via `pick_tolerance_px`, mirroring the wire shader's `select(0.5, half_width, lwdisplay_enable)`. Behaviour is unchanged for every standard weight (the widest, 2.11 mm, renders 7.97 px half-width — just under the 8 px threshold); this keeps the two sides from drifting apart if the display boost in `lineweight_to_px` ever changes. Entities extruded by a DXF thickness draw their wall as four edges with nothing in between, so the interior was a hole the cursor fell through. WireModel gains `pick_tris`: triangles hit-testing treats as solid and the renderer never uploads. Kept apart from `fill_tris` because that channel reaches the GPU, and a wall that rendered shaded would change how every thickness drawing looks. Ranked below `fill_tris` — those are drawn, these are not, so where they overlap the drawn face is what the user means. Walls are dropped when a wire is projected into a floating viewport: `points` are reprojected and clipped into paper coords there, so cloned model-space triangles would hit-test at a model-scale offset. Extruded entities stay selectable by their edges, which do get projected. The wall loop runs on every hover that misses everything else, so it rejects on the wire's AABB first — an extruded circle is ~128 triangles and a drawing full of thickness would otherwise project every wall on each mouse move. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 14:58:55 +03:00
pick_tris: Vec::new(),
pick_tris_low: Vec::new(),
dash_from_start: false,
feat(mline): GPU-dash multiline elements with shared A-type alignment MLINE dashed elements were fully CPU-expanded (apply_along) — one wire per dash — to keep the parallel lines in phase. Move pure-dash elements onto the GPU dash shader and end-align every element to the multiline's centre-line so they share one interior phase. - wire_distances now accumulates arc-length from the double-single (high+low) points, so the dash phase stays precise at UTM coordinates. f32-high-only deltas quantised ~0.1 there and drifted the parallel lines apart — the original reason MLINE dashes were CPU-side. - New WireModel.dash_align_end carries a shared begin/end dash length. The shader uses it as align_end for every parallel element (the interior phase depends on align_end, not the wire's own length) while align_total stays the element's own length, so each still ends on a dash at its own endpoint. - MLINE tessellation derives that length once from the centre-line, routes pure dash/space/dot elements to the GPU pattern and text-bearing elements to apply_along. - apply_along gained an "A"-type mode (shared reference length): a solid begin/end dash and a phased interior, so its dashes — and embedded text — line up with the GPU-dashed sibling elements instead of tiling from start. Pure-dash MLINEs now cost one WireModel + pattern per element instead of N segments; single (non-MLINE) entities keep the from-start tiling unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 02:59:11 +03:00
dash_align_end: None,
text_verts: Vec::new(),
name,
points,
points_low: Vec::new(),
color,
selected,
aci: 0,
pattern_length: 0.0,
pattern: [0.0; 8],
line_weight_px,
snap_pts: vec![],
tangent_geoms: tangents,
key_vertices,
aabb: WireModel::UNBOUNDED_AABB,
plinegen: true,
fill_tris,
fill_tris_low: Vec::new(),
}
}
}