cad-editor/src/entities/leader.rs

826 lines
30 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,
};
refactor: drop truck, and evaluate every curve through the kernel The last four truck crates are gone from the manifest. What they were doing falls into three parts, and each now has one answer instead of two. NURBS evaluation. truck was carrying B-spline curves and surfaces for the SPLINE entity, hatch boundaries, BLEND's endpoint frames, the spline preview and ACIS spline-surface faces. The kernel now has both, over one de Boor written across however many coordinates a control point holds — so a plane curve and a space curve cannot drift apart, and a surface is the same algorithm applied twice. The rational and the polynomial cases stop being separate types: weights absent means polynomial. The entity conversion. Every LINE, ARC, ELLIPSE, SPLINE and polyline was built into truck topology purely so it could be sampled back into points. They are sampled through `entities::curve` now, which is where each one's geometry is already defined once and what EXTRUDE and REVOLVE read — so a circle drawn on screen and a circle handed to the Model tab come from the same definition. That retires four of TruckObject's variants and the module is renamed for what it does. SWEEP and LOFT. Both only ever produced a mesh, so both are built from point lists: a band of quads per span, and a lid where a profile closes. Lofting profiles of different densities resamples them by distance rather than by index, so a circle lofted to a square no longer twists. Two things worth noting for anyone reading the old comments. The tolerance globals that lived in the tessellation module were never about truck at all — they are the per-frame chord height, and they move to `curve_tol`. And the rule that a profile handed over as `Lines` silently broke EXTRUDE and REVOLVE no longer holds: those read the curve directly and never look at this channel. The two `automation` test failures are unchanged from before this and are not caused by it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 01:08:50 +03:00
use crate::entities::traits::RenderConvertible;
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::TangentGeom;
2026-08-02 22:09:25 +03:00
use crate::t;
2026-03-22 14:56:27 +03:00
refactor: drop truck, and evaluate every curve through the kernel The last four truck crates are gone from the manifest. What they were doing falls into three parts, and each now has one answer instead of two. NURBS evaluation. truck was carrying B-spline curves and surfaces for the SPLINE entity, hatch boundaries, BLEND's endpoint frames, the spline preview and ACIS spline-surface faces. The kernel now has both, over one de Boor written across however many coordinates a control point holds — so a plane curve and a space curve cannot drift apart, and a surface is the same algorithm applied twice. The rational and the polynomial cases stop being separate types: weights absent means polynomial. The entity conversion. Every LINE, ARC, ELLIPSE, SPLINE and polyline was built into truck topology purely so it could be sampled back into points. They are sampled through `entities::curve` now, which is where each one's geometry is already defined once and what EXTRUDE and REVOLVE read — so a circle drawn on screen and a circle handed to the Model tab come from the same definition. That retires four of TruckObject's variants and the module is renamed for what it does. SWEEP and LOFT. Both only ever produced a mesh, so both are built from point lists: a band of quads per span, and a lid where a profile closes. Lofting profiles of different densities resamples them by distance rather than by index, so a circle lofted to a square no longer twists. Two things worth noting for anyone reading the old comments. The tolerance globals that lived in the tessellation module were never about truck at all — they are the per-frame chord height, and they move to `curve_tol`. And the rule that a profile handed over as `Lines` silently broke EXTRUDE and REVOLVE no longer holds: those read the curve directly and never look at this channel. The two `automation` test failures are unchanged from before this and are not caused by it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 01:08:50 +03:00
// ── RenderConvertible (used for snap/grip key-vertices) ─────────────────────
2026-03-22 14:56:27 +03:00
/// Whether a leader draws a horizontal hookline. DWG stores no "hookline
/// exists" flag (only the direction bit), so the stored flag alone would drop
/// every hookline on DWG-loaded leaders: a text-annotated leader gets a
/// horizontal hook whenever its final segment leaves the horizontal by more
/// than 15°.
fn draws_hookline(leader: &Leader) -> bool {
if leader.hookline_enabled {
return true;
}
let verts = &leader.vertices;
if leader.creation_type != LeaderCreationType::WithText
|| leader.annotation_handle.is_null()
|| verts.len() < 2
{
return false;
}
let last = &verts[verts.len() - 1];
let prev = &verts[verts.len() - 2];
let (dx, dy) = (last.x - prev.x, last.y - prev.y);
let len = (dx * dx + dy * dy).sqrt();
let h = leader.horizontal_direction;
let hl = (h.x * h.x + h.y * h.y).sqrt();
let cos = if len > 1e-9 && hl > 1e-9 {
((dx * h.x + dy * h.y) / (len * hl)).abs()
} else {
1.0
};
cos < (15.0f64).to_radians().cos()
}
refactor: drop truck, and evaluate every curve through the kernel The last four truck crates are gone from the manifest. What they were doing falls into three parts, and each now has one answer instead of two. NURBS evaluation. truck was carrying B-spline curves and surfaces for the SPLINE entity, hatch boundaries, BLEND's endpoint frames, the spline preview and ACIS spline-surface faces. The kernel now has both, over one de Boor written across however many coordinates a control point holds — so a plane curve and a space curve cannot drift apart, and a surface is the same algorithm applied twice. The rational and the polynomial cases stop being separate types: weights absent means polynomial. The entity conversion. Every LINE, ARC, ELLIPSE, SPLINE and polyline was built into truck topology purely so it could be sampled back into points. They are sampled through `entities::curve` now, which is where each one's geometry is already defined once and what EXTRUDE and REVOLVE read — so a circle drawn on screen and a circle handed to the Model tab come from the same definition. That retires four of TruckObject's variants and the module is renamed for what it does. SWEEP and LOFT. Both only ever produced a mesh, so both are built from point lists: a band of quads per span, and a lid where a profile closes. Lofting profiles of different densities resamples them by distance rather than by index, so a circle lofted to a square no longer twists. Two things worth noting for anyone reading the old comments. The tolerance globals that lived in the tessellation module were never about truck at all — they are the per-frame chord height, and they move to `curve_tol`. And the rule that a profile handed over as `Lines` silently broke EXTRUDE and REVOLVE no longer holds: those read the curve directly and never look at this channel. The two `automation` test failures are unchanged from before this and are not caused by it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 01:08:50 +03:00
fn to_render(leader: &Leader) -> RenderEntity {
2026-03-22 14:56:27 +03:00
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 draws_hookline(leader) && verts.len() >= 2 {
2026-03-22 14:56:27 +03:00
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
}
refactor: drop truck, and evaluate every curve through the kernel The last four truck crates are gone from the manifest. What they were doing falls into three parts, and each now has one answer instead of two. NURBS evaluation. truck was carrying B-spline curves and surfaces for the SPLINE entity, hatch boundaries, BLEND's endpoint frames, the spline preview and ACIS spline-surface faces. The kernel now has both, over one de Boor written across however many coordinates a control point holds — so a plane curve and a space curve cannot drift apart, and a surface is the same algorithm applied twice. The rational and the polynomial cases stop being separate types: weights absent means polynomial. The entity conversion. Every LINE, ARC, ELLIPSE, SPLINE and polyline was built into truck topology purely so it could be sampled back into points. They are sampled through `entities::curve` now, which is where each one's geometry is already defined once and what EXTRUDE and REVOLVE read — so a circle drawn on screen and a circle handed to the Model tab come from the same definition. That retires four of TruckObject's variants and the module is renamed for what it does. SWEEP and LOFT. Both only ever produced a mesh, so both are built from point lists: a band of quads per span, and a lid where a profile closes. Lofting profiles of different densities resamples them by distance rather than by index, so a circle lofted to a square no longer twists. Two things worth noting for anyone reading the old comments. The tolerance globals that lived in the tessellation module were never about truck at all — they are the per-frame chord height, and they move to `curve_tol`. And the rule that a profile handed over as `Lines` silently broke EXTRUDE and REVOLVE no longer holds: those read the curve directly and never look at this channel. The two `automation` test failures are unchanged from before this and are not caused by it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 01:08:50 +03:00
RenderEntity {
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(),
refactor: drop truck, and evaluate every curve through the kernel The last four truck crates are gone from the manifest. What they were doing falls into three parts, and each now has one answer instead of two. NURBS evaluation. truck was carrying B-spline curves and surfaces for the SPLINE entity, hatch boundaries, BLEND's endpoint frames, the spline preview and ACIS spline-surface faces. The kernel now has both, over one de Boor written across however many coordinates a control point holds — so a plane curve and a space curve cannot drift apart, and a surface is the same algorithm applied twice. The rational and the polynomial cases stop being separate types: weights absent means polynomial. The entity conversion. Every LINE, ARC, ELLIPSE, SPLINE and polyline was built into truck topology purely so it could be sampled back into points. They are sampled through `entities::curve` now, which is where each one's geometry is already defined once and what EXTRUDE and REVOLVE read — so a circle drawn on screen and a circle handed to the Model tab come from the same definition. That retires four of TruckObject's variants and the module is renamed for what it does. SWEEP and LOFT. Both only ever produced a mesh, so both are built from point lists: a band of quads per span, and a lid where a profile closes. Lofting profiles of different densities resamples them by distance rather than by index, so a circle lofted to a square no longer twists. Two things worth noting for anyone reading the old comments. The tolerance globals that lived in the tessellation module were never about truck at all — they are the per-frame chord height, and they move to `curve_tol`. And the rule that a profile handed over as `Lines` silently broke EXTRUDE and REVOLVE no longer holds: those read the curve directly and never look at this channel. The two `automation` test failures are unchanged from before this and are not caused by it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 01:08:50 +03:00
object: RenderObject::Lines(points),
2026-03-22 14:56:27 +03:00
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.
2026-08-02 22:09:25 +03:00
let mut geometry = vec![stepper(t!("Current Vertex").as_ref(), "current_vertex", vertex_label)];
if let Some(v) = leader.vertices.get(vi) {
2026-08-02 22:09:25 +03:00
geometry.push(edit(t!("Vertex X").as_ref(), "vertex_x", v.x));
geometry.push(edit(t!("Vertex Y").as_ref(), "vertex_y", v.y));
geometry.push(edit(t!("Vertex Z").as_ref(), "vertex_z", v.z));
} else {
2026-08-02 22:09:25 +03:00
geometry.push(ro(t!("Vertex X").as_ref(), "vertex_x", String::new()));
geometry.push(ro(t!("Vertex Y").as_ref(), "vertex_y", String::new()));
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).
let misc = vec![
Property {
2026-08-02 22:09:25 +03:00
label: t!("Dim style").into_owned(),
field: "dimension_style",
value: PropValue::EditText(leader.dimension_style.clone()),
},
choice_prop(
2026-08-02 22:09:25 +03:00
t!("Type").as_ref(),
"leader_type",
leader_type_str(&leader.path_type, leader.arrow_enabled),
&[
"Line with arrow",
"Line without arrow",
"Spline with arrow",
"Spline without arrow",
],
),
2026-08-02 22:09:25 +03:00
ro(t!("Annotative").as_ref(), "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![
2026-08-02 22:09:25 +03:00
ro(t!("Arrow").as_ref(), "arrow_block", "Closed filled"),
ro(t!("Arrow size").as_ref(), "arrow_size", String::new()),
ro(t!("Dim line lineweight").as_ref(), "dim_line_lw", "ByLayer"),
ro(t!("Dim line color").as_ref(), "dim_line_color", "ByLayer"),
2026-03-22 14:56:27 +03:00
];
let text = vec![
2026-08-02 22:09:25 +03:00
ro(t!("Text offset").as_ref(), "text_offset", String::new()),
ro(t!("Text pos vert").as_ref(), "text_pos_vert", String::new()),
];
2026-08-02 22:09:25 +03:00
let fit = vec![ro(t!("Dim scale overall").as_ref(), "dim_scale_overall", String::new())];
vec![
PropSection {
2026-08-02 22:09:25 +03:00
title: t!("Geometry").into_owned(),
props: geometry,
},
PropSection {
2026-08-02 22:09:25 +03:00
title: t!("Misc").into_owned(),
props: misc,
},
PropSection {
2026-08-02 22:09:25 +03:00
title: t!("Lines & Arrows").into_owned(),
props: lines_arrows,
},
PropSection {
2026-08-02 22:09:25 +03:00
title: t!("Text").into_owned(),
props: text,
},
PropSection {
2026-08-02 22:09:25 +03:00
title: t!("Fit").into_owned(),
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 ────────────────────────────────────────────────────────────
refactor: drop truck, and evaluate every curve through the kernel The last four truck crates are gone from the manifest. What they were doing falls into three parts, and each now has one answer instead of two. NURBS evaluation. truck was carrying B-spline curves and surfaces for the SPLINE entity, hatch boundaries, BLEND's endpoint frames, the spline preview and ACIS spline-surface faces. The kernel now has both, over one de Boor written across however many coordinates a control point holds — so a plane curve and a space curve cannot drift apart, and a surface is the same algorithm applied twice. The rational and the polynomial cases stop being separate types: weights absent means polynomial. The entity conversion. Every LINE, ARC, ELLIPSE, SPLINE and polyline was built into truck topology purely so it could be sampled back into points. They are sampled through `entities::curve` now, which is where each one's geometry is already defined once and what EXTRUDE and REVOLVE read — so a circle drawn on screen and a circle handed to the Model tab come from the same definition. That retires four of TruckObject's variants and the module is renamed for what it does. SWEEP and LOFT. Both only ever produced a mesh, so both are built from point lists: a band of quads per span, and a lid where a profile closes. Lofting profiles of different densities resamples them by distance rather than by index, so a circle lofted to a square no longer twists. Two things worth noting for anyone reading the old comments. The tolerance globals that lived in the tessellation module were never about truck at all — they are the per-frame chord height, and they move to `curve_tol`. And the rule that a profile handed over as `Lines` silently broke EXTRUDE and REVOLVE no longer holds: those read the curve directly and never look at this channel. The two `automation` test failures are unchanged from before this and are not caused by it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 01:08:50 +03:00
impl RenderConvertible for Leader {
fn to_render(&self, _document: &acadrust::CadDocument) -> Option<RenderEntity> {
2026-05-12 10:36:15 +03:00
if self.vertices.is_empty() {
return None;
}
refactor: drop truck, and evaluate every curve through the kernel The last four truck crates are gone from the manifest. What they were doing falls into three parts, and each now has one answer instead of two. NURBS evaluation. truck was carrying B-spline curves and surfaces for the SPLINE entity, hatch boundaries, BLEND's endpoint frames, the spline preview and ACIS spline-surface faces. The kernel now has both, over one de Boor written across however many coordinates a control point holds — so a plane curve and a space curve cannot drift apart, and a surface is the same algorithm applied twice. The rational and the polynomial cases stop being separate types: weights absent means polynomial. The entity conversion. Every LINE, ARC, ELLIPSE, SPLINE and polyline was built into truck topology purely so it could be sampled back into points. They are sampled through `entities::curve` now, which is where each one's geometry is already defined once and what EXTRUDE and REVOLVE read — so a circle drawn on screen and a circle handed to the Model tab come from the same definition. That retires four of TruckObject's variants and the module is renamed for what it does. SWEEP and LOFT. Both only ever produced a mesh, so both are built from point lists: a band of quads per span, and a lid where a profile closes. Lofting profiles of different densities resamples them by distance rather than by index, so a circle lofted to a square no longer twists. Two things worth noting for anyone reading the old comments. The tolerance globals that lived in the tessellation module were never about truck at all — they are the per-frame chord height, and they move to `curve_tol`. And the rule that a profile handed over as `Lines` silently broke EXTRUDE and REVOLVE no longer holds: those read the curve directly and never look at this channel. The two `automation` test failures are unchanged from before this and are not caused by it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 01:08:50 +03:00
Some(to_render(self))
2026-03-22 14:56:27 +03:00
}
}
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 verts = &self.vertices;
if verts.len() < 2 {
return WireModel {
taper_widths: Vec::new(),
world_width: 0.0,
depth_override: None,
fill_is_3d: false,
fill_is_2d_solid: 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(),
};
}
// Build all geometry in absolute f64 and RTE-split at the end. At UTM
// coordinates a raw `v.x as f32` quantises to ~0.5 units, which flattens
// the oblique arrow tick into a horizontal line and jitters the path.
let origin = [verts[0].x, verts[0].y, verts[0].z];
let mut points: Vec<[f64; 3]> = verts.iter().map(|v| [v.x, v.y, v.z]).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<[f64; 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);
// Omit the arrowhead when it is larger than the first leader segment
// it would sit on: an oversized arrow on a short pointer is
// suppressed, not drawn overshooting the segment. Without this an
// annotation-scaled arrow (DIMASZ sized for a bigger plot) paints a
// stray stroke longer than the leader itself.
if arrow_size <= len {
// Emit the arrow around a local origin (the tip) so
// `append_arrow`'s f32 math stays precise, then lift each point
// back to absolute f64. Reuse the dim arrow emitter so the
// leader shape matches the DIMSTYLE in use (Closed Filled by
// default, Dot, Tick, …).
let mut arrow_geom = DimGeom::new();
append_arrow(&mut arrow_geom, Vec3::ZERO, dir, &arrow);
let lift = |p: &[f32; 3]| -> [f64; 3] {
[
p[0] as f64 + origin[0],
p[1] as f64 + origin[1],
p[2] as f64 + origin[2],
]
};
if !arrow_geom.dim_lines.is_empty() {
points.push([f64::NAN; 3]);
points.extend(arrow_geom.dim_lines.iter().map(&lift));
}
fill_tris.extend(arrow_geom.arrow_fill.iter().map(&lift));
}
}
if draws_hookline(self) {
let last = verts.last().unwrap();
let prev = &verts[verts.len() - 2];
let sign = if (last.x - prev.x) >= 0.0 {
1.0_f64
} else {
-1.0_f64
};
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"))
});
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);
// Hook length: DIMASZ, like the arrowhead, when a style resolves;
// the legacy text-height heuristic otherwise.
let mut land_len = match dov::real(xd, dov::DIMASZ)
.or_else(|| style.map(|s| s.dimasz))
{
Some(a) => a * dim_scale,
None => self.text_height * 1.5 * anno_scale as f64,
};
// With DIMTAD "text above", the annotation sits on top of the hook
// and the hook underlines it: extend to the far edge of the MTEXT's
// laid-out extents.
let dimtad = dov::int(xd, dov::DIMTAD)
.or_else(|| style.map(|s| s.dimtad))
.unwrap_or(0);
if dimtad != 0 {
let mt = match document.get_entity(self.annotation_handle) {
Some(acadrust::entities::EntityType::MText(mt)) => {
Some((mt.extents_width, mt.insertion_point.x))
}
_ => None,
};
// Underline width: the annotation MTEXT's laid-out extents when
// known, otherwise the leader's own stored annotation width
// (DXF code 41) — a DXF-loaded MTEXT often has no computed
// extents (extents_width == 0), which left the hook too short.
let width = mt
.map(|(w, _)| w)
.filter(|w| *w > 0.0)
.unwrap_or(self.text_width);
if width > 0.0 {
let ins_x = mt.map(|(_, x)| x).unwrap_or(last.x);
let to_far_edge = sign * (ins_x - last.x) + width;
land_len = land_len.max(to_far_edge);
}
}
points.push([f64::NAN; 3]);
points.push([last.x, last.y, last.z]);
points.push([last.x + sign * land_len, last.y, last.z]);
}
// Split absolute f64 into the render/eye double-single so the GPU keeps
// full precision at UTM coordinates (empty low bufs = absolute f32).
let (points, points_low) = crate::scene::convert::tessellate::points_to_ds(points);
let (fill_tris, fill_tris_low) =
crate::scene::convert::tessellate::points_to_ds(fill_tris);
WireModel {
taper_widths: Vec::new(),
world_width: 0.0,
depth_override: None,
fill_is_3d: false,
fill_is_2d_solid: 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,
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,
}
}
}