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>
This commit is contained in:
Hakan SevenandClaude Opus 4.8 committed 2026-07-17 14:58:55 +03:00
1 parent 72d471c2e7
commit 3f060a570a
63 files changed
+514 -83

No files matched your search

+11 -6
View File
@@ -1172,7 +1172,7 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task<Message> {
p
} else if needs_entity {
let hover_handle =
scene::pick::hit_test::click_hit(p, &all_wires[..], view_rot, eye, bounds)
scene::pick::hit_test::click_hit(p, &all_wires[..], view_rot, eye, bounds, self.tabs[i].scene.document.header.lineweight_display)
.and_then(|s| Scene::handle_from_wire_name(s))
.unwrap_or(acadrust::Handle::NULL);
let mut p = self.tabs[i]
@@ -1235,6 +1235,8 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task<Message> {
let far_pos = base + dir * far;
let far_neg = base - dir * far;
let guide = crate::scene::WireModel {
pick_tris: Vec::new(),
pick_tris_low: Vec::new(),
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
@@ -1271,6 +1273,8 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task<Message> {
let far_pos = h.base + h.dir * far;
let far_neg = h.base - h.dir * far;
previews.push(crate::scene::WireModel {
pick_tris: Vec::new(),
pick_tris_low: Vec::new(),
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
@@ -2017,7 +2021,7 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task<Message> {
.unwrap_or(false)
{
let (view_rot2, eye2, all_wires2) = self.pick_view(i, &edit_cam, bounds);
let hit = scene::pick::hit_test::click_hit(p, &all_wires2[..], view_rot2, eye2, bounds)
let hit = scene::pick::hit_test::click_hit(p, &all_wires2[..], view_rot2, eye2, bounds, self.tabs[i].scene.document.header.lineweight_display)
.and_then(|s| Scene::handle_from_wire_name(s));
if let Some(handle) = hit {
// Some commands (e.g. SS_CATCHMENT) need the entity
@@ -2358,6 +2362,7 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task<Message> {
view_rot,
eye,
bounds,
self.tabs[i].scene.document.header.lineweight_display,
)
.into_iter()
.filter_map(|s| Scene::handle_from_wire_name(s))
@@ -2373,7 +2378,7 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task<Message> {
if !handled_by_cycling {
let hit =
scene::pick::hit_test::click_hit(p, &all_wires[..], view_rot, eye, bounds)
scene::pick::hit_test::click_hit(p, &all_wires[..], view_rot, eye, bounds, self.tabs[i].scene.document.header.lineweight_display)
.and_then(|s| Scene::handle_from_wire_name(s))
.or_else(|| {
scene::pick::hit_test::click_hit_hatch(
@@ -2568,7 +2573,7 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task<Message> {
// Resolve the double-clicked object — its wire, or (for a
// block/solid with no wire under the cursor) its shaded
// body, which maps to the parent INSERT.
let hit = scene::pick::hit_test::click_hit(p, &all_wires[..], view_rot, eye, bounds)
let hit = scene::pick::hit_test::click_hit(p, &all_wires[..], view_rot, eye, bounds, self.tabs[i].scene.document.header.lineweight_display)
.and_then(|s| Scene::handle_from_wire_name(s))
.or_else(|| self.tabs[i].scene.solid_click_hit(p, view_rot, eye, bounds));
if let Some(handle) = hit {
@@ -2657,7 +2662,7 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task<Message> {
let wire_hit: Option<acadrust::Handle> = {
let (view_rot, eye) = { let c = self.tabs[i].scene.camera.borrow(); (c.view_proj_rte(bounds), c.eye()) };
let all_wires = self.tabs[i].scene.hit_test_wires();
scene::pick::hit_test::click_hit(p, &all_wires[..], view_rot, eye, bounds)
scene::pick::hit_test::click_hit(p, &all_wires[..], view_rot, eye, bounds, self.tabs[i].scene.document.header.lineweight_display)
.and_then(|s| Scene::handle_from_wire_name(s))
.and_then(|h| {
if let Some(AcadEntityType::Viewport(vp)) =
@@ -2968,7 +2973,7 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task<Message> {
// Mirror the click-selection pick order so the rollover
// highlights every selectable object: wire → hatch →
// block-internal hatch → shaded 3D solid body.
let hovered = scene::pick::hit_test::click_hit(p, &all_wires[..], view_rot, eye, bounds)
let hovered = scene::pick::hit_test::click_hit(p, &all_wires[..], view_rot, eye, bounds, self.tabs[i].scene.document.header.lineweight_display)
.and_then(|s| Scene::handle_from_wire_name(s))
.or_else(|| {
scene::pick::hit_test::click_hit_hatch(
+12 -10
View File
@@ -6,7 +6,7 @@ use crate::entities::common::{
center_grip, edit_prop as edit, parse_f64, ro_prop as ro, square_grip,
};
use crate::entities::traits::TruckConvertible;
use crate::scene::convert::acad_to_truck::{TruckEntity, TruckObject};
use crate::scene::convert::acad_to_truck::{extrusion_wall_tris, TruckEntity, TruckObject};
use crate::scene::model::object::{GripApply, GripDef, PropSection};
use crate::scene::model::wire_model::{SnapHint, TangentGeom};
@@ -56,17 +56,17 @@ fn to_truck(arc: &Arc) -> TruckEntity {
let n = 32usize;
let ccw_end = if ea >= sa { ea } else { ea + TAU };
let (start_a, end_a) = (sa, ccw_end);
let base: Vec<[f64; 3]> = (0..=n)
.map(|i| {
let p = arc_pt(start_a + (end_a - start_a) * (i as f64 / n as f64));
[p.x, p.y, p.z]
})
.collect();
let mut pts: Vec<[f64; 3]> = Vec::with_capacity((n + 1) * 2 + 8);
for i in 0..=n {
let a = start_a + (end_a - start_a) * (i as f64 / n as f64);
let p = arc_pt(a);
pts.push([p.x, p.y, p.z]);
}
pts.extend_from_slice(&base);
pts.push([f64::NAN; 3]);
for i in 0..=n {
let a = start_a + (end_a - start_a) * (i as f64 / n as f64);
let p = arc_pt(a);
pts.push([p.x + t * nx, p.y + t * ny, p.z + t * nz]);
for &[x, y, z] in &base {
pts.push([x + t * nx, y + t * ny, z + t * nz]);
}
pts.push([f64::NAN; 3]);
let ps = arc_pt(sa);
@@ -77,6 +77,7 @@ fn to_truck(arc: &Arc) -> TruckEntity {
pts.push([pe.x, pe.y, pe.z]);
pts.push([pe.x + t * nx, pe.y + t * ny, pe.z + t * nz]);
return TruckEntity {
pick_tris: extrusion_wall_tris(&base, [t * nx, t * ny, t * nz]),
object: TruckObject::Lines(pts),
snap_pts: vec![(cv, SnapHint::Center), (mv, SnapHint::Midpoint)],
tangent_geoms: vec![tangent],
@@ -92,6 +93,7 @@ fn to_truck(arc: &Arc) -> TruckEntity {
let v_end = builder::vertex(p_end);
let edge = builder::circle_arc(&v_start, &v_end, p_mid);
TruckEntity {
pick_tris: Vec::new(),
object: TruckObject::Curve(edge),
snap_pts: vec![(cv, SnapHint::Center), (mv, SnapHint::Midpoint)],
tangent_geoms: vec![tangent],
+2
View File
@@ -216,6 +216,7 @@ fn build_attr_truck(input: AttrTextInputs<'_>, document: &acadrust::CadDocument)
let _ = input.line_count;
let _ = input.is_multiline;
return TruckEntity {
pick_tris: Vec::new(),
object: TruckObject::Text(layout.strokes),
snap_pts: vec![(snap_pt, SnapHint::Insertion)],
tangent_geoms: vec![],
@@ -313,6 +314,7 @@ fn build_attr_truck(input: AttrTextInputs<'_>, document: &acadrust::CadDocument)
let _ = input.line_count; // round-trip only — recomputed above
TruckEntity {
pick_tris: Vec::new(),
object: TruckObject::Text(strokes_all),
snap_pts: vec![(snap_pt, SnapHint::Insertion)],
tangent_geoms: vec![],
+11 -7
View File
@@ -6,7 +6,7 @@ use crate::entities::common::{
center_grip, edit_prop as edit, parse_f64, ro_prop as ro, square_grip,
};
use crate::entities::traits::TruckConvertible;
use crate::scene::convert::acad_to_truck::{TruckEntity, TruckObject};
use crate::scene::convert::acad_to_truck::{extrusion_wall_tris, TruckEntity, TruckObject};
use crate::scene::model::object::{GripApply, GripDef, PropSection};
use crate::scene::model::wire_model::{SnapHint, TangentGeom};
@@ -64,14 +64,16 @@ fn to_truck(circle: &Circle) -> TruckEntity {
cwz + r * (c * ax.2 + s * ay.2),
)
};
let base: Vec<[f64; 3]> = (0..=n)
.map(|i| {
let (x, y, z) = circ_pt(i as f64 * tau / n as f64);
[x, y, z]
})
.collect();
let mut pts: Vec<[f64; 3]> = Vec::with_capacity((n + 1) * 2 + 4 * 3);
for i in 0..=n {
let (x, y, z) = circ_pt(i as f64 * tau / n as f64);
pts.push([x, y, z]);
}
pts.extend_from_slice(&base);
pts.push([f64::NAN; 3]);
for i in 0..=n {
let (x, y, z) = circ_pt(i as f64 * tau / n as f64);
for &[x, y, z] in &base {
pts.push([x + t * nx, y + t * ny, z + t * nz]);
}
pts.push([f64::NAN; 3]);
@@ -84,6 +86,7 @@ fn to_truck(circle: &Circle) -> TruckEntity {
}
}
return TruckEntity {
pick_tris: extrusion_wall_tris(&base, [t * nx, t * ny, t * nz]),
object: TruckObject::Lines(pts),
snap_pts,
tangent_geoms: vec![tangent],
@@ -98,6 +101,7 @@ fn to_truck(circle: &Circle) -> TruckEntity {
let lower = builder::circle_arc(&left, &right, p_bot);
let wire: Wire = [upper, lower].into_iter().collect();
TruckEntity {
pick_tris: Vec::new(),
object: TruckObject::Contour(wire),
snap_pts,
tangent_geoms: vec![tangent],
+10
View File
@@ -1352,6 +1352,8 @@ fn tessellate_dimension_inner(
let (ext1, ext2) = split_ext_lines(&geom.ext_lines);
if !ext1.is_empty() {
wires.push(WireModel {
pick_tris: Vec::new(),
pick_tris_low: Vec::new(),
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
@@ -1376,6 +1378,8 @@ fn tessellate_dimension_inner(
}
if !ext2.is_empty() {
wires.push(WireModel {
pick_tris: Vec::new(),
pick_tris_low: Vec::new(),
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
@@ -1400,6 +1404,8 @@ fn tessellate_dimension_inner(
}
} else {
wires.push(WireModel {
pick_tris: Vec::new(),
pick_tris_low: Vec::new(),
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
@@ -1425,6 +1431,8 @@ fn tessellate_dimension_inner(
}
wires.push(WireModel {
pick_tris: Vec::new(),
pick_tris_low: Vec::new(),
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
@@ -1466,6 +1474,8 @@ fn tessellate_dimension_inner(
aci_to_rgba(&c)
};
wires.push(WireModel {
pick_tris: Vec::new(),
pick_tris_low: Vec::new(),
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
+2
View File
@@ -96,6 +96,7 @@ fn to_truck(ell: &Ellipse) -> TruckEntity {
(q(0.0, -r_minor), SnapHint::Quadrant),
];
TruckEntity {
pick_tris: Vec::new(),
object: TruckObject::Contour(wire),
snap_pts,
tangent_geoms: vec![],
@@ -128,6 +129,7 @@ fn to_truck(ell: &Ellipse) -> TruckEntity {
[pt_end.x, pt_end.y, pt_end.z],
];
TruckEntity {
pick_tris: Vec::new(),
object: TruckObject::Curve(edge),
snap_pts: vec![(center_v3, SnapHint::Center)],
tangent_geoms: vec![],
+5
View File
@@ -96,6 +96,7 @@ fn to_truck(leader: &Leader) -> TruckEntity {
}
TruckEntity {
pick_tris: Vec::new(),
object: TruckObject::Lines(points),
snap_pts: vec![],
tangent_geoms: tangents,
@@ -589,6 +590,8 @@ impl LeaderTess for Leader {
if verts.len() < 2 {
return WireModel {
pick_tris: Vec::new(),
pick_tris_low: Vec::new(),
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
@@ -693,6 +696,8 @@ impl LeaderTess for Leader {
}
WireModel {
pick_tris: Vec::new(),
pick_tris_low: Vec::new(),
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
+3 -1
View File
@@ -6,7 +6,7 @@ use crate::entities::common::{
center_grip, edit_prop as edit, parse_f64, ro_prop as ro, square_grip,
};
use crate::entities::traits::TruckConvertible;
use crate::scene::convert::acad_to_truck::{TruckEntity, TruckObject};
use crate::scene::convert::acad_to_truck::{extrusion_wall_tris, TruckEntity, TruckObject};
use crate::scene::model::object::{GripApply, GripDef, PropSection};
use crate::scene::model::wire_model::TangentGeom;
@@ -45,6 +45,7 @@ fn to_truck(line: &Line) -> TruckEntity {
p1t,
];
return TruckEntity {
pick_tris: extrusion_wall_tris(&kv, [t * nx, t * ny, t * nz]),
object: TruckObject::Lines(pts),
snap_pts: vec![],
tangent_geoms: vec![tangent],
@@ -57,6 +58,7 @@ fn to_truck(line: &Line) -> TruckEntity {
let v1 = builder::vertex(p1);
let edge = builder::line(&v0, &v1);
TruckEntity {
pick_tris: Vec::new(),
object: TruckObject::Curve(edge),
snap_pts: vec![],
tangent_geoms: vec![tangent],
+5 -1
View File
@@ -7,7 +7,7 @@ use crate::entities::common::{
stepper_prop as stepper,
};
use crate::entities::traits::TruckConvertible;
use crate::scene::convert::acad_to_truck::{TruckEntity, TruckObject};
use crate::scene::convert::acad_to_truck::{extrusion_wall_tris, TruckEntity, TruckObject};
use crate::scene::model::object::{GripApply, GripDef, PropSection, PropValue, Property};
use crate::scene::model::wire_model::TangentGeom;
@@ -91,6 +91,7 @@ fn thick_segments(
}
}
TruckEntity {
pick_tris: extrusion_wall_tris(path_pts, [t * nx, t * ny, t * nz]),
object: TruckObject::Lines(pts),
snap_pts: vec![],
tangent_geoms: tangents,
@@ -103,6 +104,7 @@ fn to_truck(pline: &LwPolyline) -> TruckEntity {
let verts = &pline.vertices;
if verts.is_empty() {
return TruckEntity {
pick_tris: Vec::new(),
object: TruckObject::Point(builder::vertex(Point3::new(0.0, 0.0, 0.0))),
snap_pts: vec![],
tangent_geoms: vec![],
@@ -221,6 +223,7 @@ fn to_truck(pline: &LwPolyline) -> TruckEntity {
}
}
return TruckEntity {
pick_tris: Vec::new(),
object: TruckObject::SegmentedLines(pts),
snap_pts: vec![],
tangent_geoms: tgs,
@@ -269,6 +272,7 @@ fn to_truck(pline: &LwPolyline) -> TruckEntity {
}
TruckEntity {
pick_tris: Vec::new(),
object: TruckObject::Contour(edges.into_iter().collect::<Wire>()),
snap_pts: vec![],
tangent_geoms: tangents,
+4
View File
@@ -195,6 +195,7 @@ impl TruckConvertible for Face3D {
}
Some(TruckEntity {
pick_tris: Vec::new(),
object: TruckObject::Lines(pts),
snap_pts: vec![
(Vec3::from(p0f).as_dvec3(), SnapHint::Node),
@@ -369,6 +370,7 @@ impl TruckConvertible for PolygonMesh {
}
Some(TruckEntity {
pick_tris: Vec::new(),
object: TruckObject::Lines(pts),
snap_pts: vec![],
tangent_geoms: vec![],
@@ -524,6 +526,7 @@ impl TruckConvertible for PolyfaceMesh {
}
Some(TruckEntity {
pick_tris: Vec::new(),
object: TruckObject::Lines(pts),
snap_pts: vec![],
tangent_geoms: vec![],
@@ -690,6 +693,7 @@ impl TruckConvertible for Mesh {
let key_vertices: Vec<[f64; 3]> = self.vertices.iter().map(|v| [v.x, v.y, v.z]).collect();
Some(TruckEntity {
pick_tris: Vec::new(),
object: TruckObject::Lines(pts),
snap_pts,
tangent_geoms: vec![],
+1
View File
@@ -162,6 +162,7 @@ impl TruckConvertible for MLine {
.collect();
Some(TruckEntity {
pick_tris: Vec::new(),
object: TruckObject::Lines(pts),
snap_pts,
tangent_geoms: vec![],
+1
View File
@@ -150,6 +150,7 @@ fn to_truck(t: &MText, document: &acadrust::CadDocument) -> TruckEntity {
t.insertion_point.z,
);
TruckEntity {
pick_tris: Vec::new(),
object: TruckObject::Text(layout.strokes),
snap_pts: vec![(insertion, SnapHint::Insertion)],
tangent_geoms: vec![],
+9
View File
@@ -298,6 +298,7 @@ fn to_truck(ml: &MultiLeader, document: &acadrust::CadDocument) -> Option<TruckE
}
Some(TruckEntity {
pick_tris: Vec::new(),
object: TruckObject::Lines(points),
snap_pts,
tangent_geoms: tangents,
@@ -1399,6 +1400,8 @@ impl MultiLeaderTess for MultiLeader {
// WireModels so the renderer respects per-piece coloring.
let mut wires: Vec<WireModel> = Vec::new();
wires.push(WireModel {
pick_tris: Vec::new(),
pick_tris_low: Vec::new(),
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
@@ -1684,6 +1687,8 @@ impl MultiLeaderTess for MultiLeader {
xy = xy.max(y);
}
wires.push(WireModel {
pick_tris: Vec::new(),
pick_tris_low: Vec::new(),
dash_from_start: false,
dash_align_end: None,
text_verts: sdf_verts,
@@ -1749,6 +1754,8 @@ impl MultiLeaderTess for MultiLeader {
wcs_corners[3],
];
wires.push(WireModel {
pick_tris: Vec::new(),
pick_tris_low: Vec::new(),
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
@@ -1782,6 +1789,8 @@ impl MultiLeaderTess for MultiLeader {
wcs_corners[0],
];
wires.push(WireModel {
pick_tris: Vec::new(),
pick_tris_low: Vec::new(),
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
+2
View File
@@ -17,6 +17,7 @@ fn to_truck(ole: &Ole2Frame) -> TruckEntity {
if (x1 - x0).abs() < 1e-6 && (y1 - y0).abs() < 1e-6 {
let s = 0.5_f64;
return TruckEntity {
pick_tris: Vec::new(),
object: TruckObject::Lines(vec![[-s, 0.0, z], [s, 0.0, z]]),
snap_pts: vec![],
tangent_geoms: vec![],
@@ -46,6 +47,7 @@ fn to_truck(ole: &Ole2Frame) -> TruckEntity {
];
let center = glam::DVec3::new(cx, cy, z);
TruckEntity {
pick_tris: Vec::new(),
object: TruckObject::Lines(pts),
snap_pts: vec![(center, SnapHint::Center)],
tangent_geoms: vec![],
+3
View File
@@ -40,6 +40,7 @@ fn point_truck(pt: &Point, pdmode: i16, s: f64) -> TruckEntity {
// Default: a single vertex (driver handles the dot pixel).
let p = Point3::new(wx, wy, wz);
return TruckEntity {
pick_tris: Vec::new(),
object: TruckObject::Point(builder::vertex(p)),
snap_pts: vec![(snap, SnapHint::Node)],
tangent_geoms: vec![],
@@ -51,6 +52,7 @@ fn point_truck(pt: &Point, pdmode: i16, s: f64) -> TruckEntity {
if pts.is_empty() {
// PDMODE 1 = nothing — emit an empty Lines wire so picking still works.
return TruckEntity {
pick_tris: Vec::new(),
object: TruckObject::Lines(vec![]),
snap_pts: vec![(snap, SnapHint::Node)],
tangent_geoms: vec![],
@@ -59,6 +61,7 @@ fn point_truck(pt: &Point, pdmode: i16, s: f64) -> TruckEntity {
};
}
TruckEntity {
pick_tris: Vec::new(),
object: TruckObject::Lines(pts),
snap_pts: vec![(snap, SnapHint::Node)],
tangent_geoms: vec![],
+6 -1
View File
@@ -6,7 +6,7 @@ use crate::entities::common::{
edit_prop as edit, parse_f64, ro_prop as ro, square_grip, stepper_prop as stepper,
};
use crate::entities::traits::{Grippable, PropertyEditable, Transformable, TruckConvertible};
use crate::scene::convert::acad_to_truck::{TruckEntity, TruckObject};
use crate::scene::convert::acad_to_truck::{extrusion_wall_tris, TruckEntity, TruckObject};
use crate::scene::model::object::{GripApply, GripDef, PropSection, PropValue, Property};
use crate::scene::model::wire_model::TangentGeom;
@@ -26,6 +26,7 @@ fn tessellate_polyline(pl: &Polyline) -> TruckEntity {
let key_verts = pts.clone();
TruckEntity {
pick_tris: Vec::new(),
object: TruckObject::Lines(points),
snap_pts: vec![],
tangent_geoms: vec![],
@@ -169,6 +170,7 @@ fn tessellate_polyline2d(pl: &Polyline2D) -> TruckEntity {
let verts = &pl.vertices;
if verts.is_empty() {
return TruckEntity {
pick_tris: Vec::new(),
object: TruckObject::Lines(vec![]),
snap_pts: vec![],
tangent_geoms: vec![],
@@ -253,6 +255,7 @@ fn tessellate_polyline2d(pl: &Polyline2D) -> TruckEntity {
}
}
return TruckEntity {
pick_tris: extrusion_wall_tris(&path, [t * nx, t * ny, t * nz]),
object: TruckObject::Lines(pts),
snap_pts: vec![],
tangent_geoms: tgs,
@@ -301,6 +304,7 @@ fn tessellate_polyline2d(pl: &Polyline2D) -> TruckEntity {
}
TruckEntity {
pick_tris: Vec::new(),
object: TruckObject::Contour(edges.into_iter().collect::<Wire>()),
snap_pts: vec![],
tangent_geoms: tangents,
@@ -578,6 +582,7 @@ fn tessellate_polyline3d(pl: &Polyline3D) -> TruckEntity {
}
TruckEntity {
pick_tris: Vec::new(),
object: TruckObject::Lines(points),
snap_pts: vec![],
tangent_geoms: vec![],
+2
View File
@@ -115,6 +115,7 @@ impl TruckConvertible for RasterImage {
};
Some(TruckEntity {
pick_tris: Vec::new(),
object: TruckObject::Lines(pts),
snap_pts: vec![],
tangent_geoms: vec![],
@@ -348,6 +349,7 @@ impl TruckConvertible for Wipeout {
};
Some(TruckEntity {
pick_tris: Vec::new(),
object: TruckObject::Lines(pts),
snap_pts: vec![],
tangent_geoms: vec![],
+2
View File
@@ -29,6 +29,7 @@ impl TruckConvertible for Ray {
];
let start: [f64; 3] = [bp.x, bp.y, bp.z];
Some(TruckEntity {
pick_tris: Vec::new(),
object: TruckObject::Lines(vec![start, far]),
snap_pts: vec![],
tangent_geoms: vec![],
@@ -169,6 +170,7 @@ impl TruckConvertible for XLine {
bp.z - nz * DISPLAY_EXTENT,
];
Some(TruckEntity {
pick_tris: Vec::new(),
object: TruckObject::Lines(vec![far_neg, far_pos]),
snap_pts: vec![],
tangent_geoms: vec![],
+1
View File
@@ -74,6 +74,7 @@ impl TruckConvertible for Shape {
);
Some(TruckEntity {
pick_tris: Vec::new(),
object: TruckObject::Lines(pts),
snap_pts: vec![(snap_pt, SnapHint::Insertion)],
tangent_geoms: vec![],
+1
View File
@@ -54,6 +54,7 @@ impl TruckConvertible for Solid {
];
Some(TruckEntity {
pick_tris: Vec::new(),
object: TruckObject::Lines(pts),
snap_pts: snap,
tangent_geoms: vec![],
+3
View File
@@ -23,6 +23,7 @@ fn to_truck(spl: &Spline) -> TruckEntity {
let key_vertices: Vec<[f64; 3]> =
spl.fit_points.iter().map(|p| [p.x, p.y, p.z]).collect();
return TruckEntity {
pick_tris: Vec::new(),
object: TruckObject::Lines(pts),
snap_pts: vec![],
tangent_geoms: vec![],
@@ -31,6 +32,7 @@ fn to_truck(spl: &Spline) -> TruckEntity {
};
}
return TruckEntity {
pick_tris: Vec::new(),
object: TruckObject::Point(builder::vertex(Point3::new(0.0, 0.0, 0.0))),
snap_pts: vec![],
tangent_geoms: vec![],
@@ -106,6 +108,7 @@ fn to_truck(spl: &Spline) -> TruckEntity {
};
TruckEntity {
pick_tris: Vec::new(),
object,
snap_pts: vec![],
tangent_geoms: vec![],
+3
View File
@@ -345,6 +345,7 @@ impl TruckConvertible for Table {
})
.collect();
Some(TruckEntity {
pick_tris: Vec::new(),
object: TruckObject::Lines(pts_f64),
snap_pts: vec![(glam::DVec3::new(self.insertion_point.x, self.insertion_point.y, self.insertion_point.z), SnapHint::Insertion)],
tangent_geoms: vec![],
@@ -748,6 +749,8 @@ pub fn tessellate_table(
let mk =
|color: [f32; 4], points: Vec<[f32; 3]>, fill_tris: Vec<[f32; 3]>, lw: f32| -> WireModel {
WireModel {
pick_tris: Vec::new(),
pick_tris_low: Vec::new(),
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
+1
View File
@@ -113,6 +113,7 @@ fn to_truck(t: &Text, document: &acadrust::CadDocument) -> TruckEntity {
&value,
);
TruckEntity {
pick_tris: Vec::new(),
object: TruckObject::Text(vec![TextStroke {
strokes,
origin: p.origin,
+1
View File
@@ -493,6 +493,7 @@ impl TruckConvertible for Tolerance {
}
Some(TruckEntity {
pick_tris: Vec::new(),
object: TruckObject::Text(groups),
snap_pts: vec![(snap_pt, SnapHint::Insertion)],
tangent_geoms: vec![],
+2
View File
@@ -53,6 +53,7 @@ impl TruckConvertible for Underlay {
// Insertion grip.
let key: Vec<[f64; 3]> = pts.clone();
Some(TruckEntity {
pick_tris: Vec::new(),
object: TruckObject::Lines(pts),
snap_pts: vec![(glam::DVec3::new(self.insertion_point.x, self.insertion_point.y, self.insertion_point.z), SnapHint::Node)],
tangent_geoms: vec![],
@@ -63,6 +64,7 @@ impl TruckConvertible for Underlay {
// No clip boundary: draw a cross at insertion point.
let pts = cross_wire(origin, 1.0);
Some(TruckEntity {
pick_tris: Vec::new(),
object: TruckObject::Lines(pts),
snap_pts: vec![(glam::DVec3::new(self.insertion_point.x, self.insertion_point.y, self.insertion_point.z), SnapHint::Node)],
tangent_geoms: vec![],
+4
View File
@@ -186,6 +186,8 @@ impl CadCommand for AlignedDimensionCommand {
let p1 = p1.as_vec3();
let p2 = p2.as_vec3();
Some(WireModel {
pick_tris: Vec::new(),
pick_tris_low: Vec::new(),
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
@@ -235,6 +237,8 @@ fn preview_aligned(p1: DVec3, p2: DVec3, dim_pt: DVec3) -> WireModel {
let d1 = d1.as_vec3();
let d2 = d2.as_vec3();
WireModel {
pick_tris: Vec::new(),
pick_tris_low: Vec::new(),
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
+2
View File
@@ -130,6 +130,8 @@ fn v3(pt: DVec3) -> Vector3 {
fn preview_wire(points: Vec<Vec3>) -> WireModel {
WireModel {
pick_tris: Vec::new(),
pick_tris_low: Vec::new(),
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
+2
View File
@@ -180,6 +180,8 @@ fn v3(p: DVec3) -> Vector3 {
fn preview_line(a: Vec3, b: Vec3) -> WireModel {
WireModel {
pick_tris: Vec::new(),
pick_tris_low: Vec::new(),
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
+2
View File
@@ -179,6 +179,8 @@ impl CadCommand for DimBaselineCommand {
let dim_line_pt2 = dim_line_pt2.as_vec3();
let pt = pt.as_vec3();
Some(WireModel {
pick_tris: Vec::new(),
pick_tris_low: Vec::new(),
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
+2
View File
@@ -159,6 +159,8 @@ impl CadCommand for DimContinueCommand {
let dim_line_pt = p1 + perp * (dim_line_perp - p1.dot(perp));
let dim_line_pt2 = pt + perp * (dim_line_perp - pt.dot(perp));
Some(WireModel {
pick_tris: Vec::new(),
pick_tris_low: Vec::new(),
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
+2
View File
@@ -85,6 +85,8 @@ impl CadCommand for DimJogLineCommand {
}
let d = 0.3_f32;
Some(WireModel {
pick_tris: Vec::new(),
pick_tris_low: Vec::new(),
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
+2
View File
@@ -100,6 +100,8 @@ impl CadCommand for DimTeditCommand {
}
let d = 0.2_f32;
Some(WireModel {
pick_tris: Vec::new(),
pick_tris_low: Vec::new(),
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
+2
View File
@@ -163,6 +163,8 @@ fn preview_wire(pts: &[Vec3]) -> WireModel {
points.push([w2.x, w2.y, w2.z]);
}
WireModel {
pick_tris: Vec::new(),
pick_tris_low: Vec::new(),
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
+2
View File
@@ -218,6 +218,8 @@ fn v3(pt: DVec3) -> Vector3 {
fn preview_wire(points: Vec<DVec3>) -> WireModel {
WireModel {
pick_tris: Vec::new(),
pick_tris_low: Vec::new(),
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
+2
View File
@@ -150,6 +150,8 @@ fn preview_wire(pts: &[Vec3]) -> WireModel {
points.push([w2.x, w2.y, w2.z]);
}
WireModel {
pick_tris: Vec::new(),
pick_tris_low: Vec::new(),
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
+2
View File
@@ -458,6 +458,8 @@ impl CadCommand for MLeaderCollectCommand {
fn preview_wire(pts: &[Vec3]) -> WireModel {
WireModel {
pick_tris: Vec::new(),
pick_tris_low: Vec::new(),
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
+2
View File
@@ -121,6 +121,8 @@ fn ordinate_elbow(feature: DVec3, leader: DVec3, is_x: bool) -> DVec3 {
fn preview_wire(points: Vec<Vec3>) -> WireModel {
WireModel {
pick_tris: Vec::new(),
pick_tris_low: Vec::new(),
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
+2
View File
@@ -90,6 +90,8 @@ impl CadCommand for QdimCommand {
}
let d = 0.5_f32;
Some(WireModel {
pick_tris: Vec::new(),
pick_tris_low: Vec::new(),
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
+2
View File
@@ -185,6 +185,8 @@ fn v3(pt: DVec3) -> Vector3 {
fn preview_wire(points: Vec<Vec3>) -> WireModel {
WireModel {
pick_tris: Vec::new(),
pick_tris_low: Vec::new(),
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
+2
View File
@@ -139,6 +139,8 @@ impl CadCommand for TableCommand {
let y = pt.y;
let z = pt.z;
Some(WireModel {
pick_tris: Vec::new(),
pick_tris_low: Vec::new(),
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
+2
View File
@@ -84,6 +84,8 @@ impl CadCommand for ToleranceCommand {
}
let d = 0.15_f32;
Some(WireModel {
pick_tris: Vec::new(),
pick_tris_low: Vec::new(),
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
+2
View File
@@ -160,6 +160,8 @@ impl CadCommand for AttdefCommand {
// Show a small cross at the insertion point.
let d = 0.15_f32;
Some(WireModel {
pick_tris: Vec::new(),
pick_tris_low: Vec::new(),
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
+2
View File
@@ -136,6 +136,8 @@ impl CadCommand for MlineCommand {
.collect();
pts.push([pt.x, pt.y, pt.z]);
Some(WireModel {
pick_tris: Vec::new(),
pick_tris_low: Vec::new(),
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
+2
View File
@@ -108,6 +108,8 @@ impl CadCommand for ImageCommand {
let p3 = [origin.x, origin.y + world_height, origin.z];
Some(WireModel {
pick_tris: Vec::new(),
pick_tris_low: Vec::new(),
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
+4
View File
@@ -84,6 +84,8 @@ impl CadCommand for RayCommand {
let dir = (pt - base).normalize_or_zero();
let far = base + dir * DISPLAY_EXTENT;
Some(WireModel {
pick_tris: Vec::new(),
pick_tris_low: Vec::new(),
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
@@ -177,6 +179,8 @@ impl CadCommand for XLineCommand {
let far_pos = base + dir * DISPLAY_EXTENT;
let far_neg = base - dir * DISPLAY_EXTENT;
Some(WireModel {
pick_tris: Vec::new(),
pick_tris_low: Vec::new(),
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
+2
View File
@@ -89,6 +89,8 @@ impl CadCommand for RevCloudCommand {
self.points[0].z as f32,
]);
Some(WireModel {
pick_tris: Vec::new(),
pick_tris_low: Vec::new(),
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
+4
View File
@@ -114,6 +114,8 @@ impl CadCommand for WipeoutCommand {
let min = p1.min(pt);
let max = p1.max(pt);
Some(WireModel {
pick_tris: Vec::new(),
pick_tris_low: Vec::new(),
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
@@ -158,6 +160,8 @@ impl CadCommand for WipeoutCommand {
self.poly_pts[0].z as f32,
]);
Some(WireModel {
pick_tris: Vec::new(),
pick_tris_low: Vec::new(),
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
+2
View File
@@ -69,6 +69,8 @@ impl CadCommand for AreaCommand {
pts.push(f(pt));
pts.push(f(self.points[0]));
Some(WireModel {
pick_tris: Vec::new(),
pick_tris_low: Vec::new(),
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
+2
View File
@@ -63,6 +63,8 @@ impl CadCommand for DistCommand {
let p1 = self.first?;
// The preview wire is purely visual, so f32 vertices are fine here.
Some(WireModel {
pick_tris: Vec::new(),
pick_tris_low: Vec::new(),
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
+2
View File
@@ -67,6 +67,8 @@ impl MeasureGeomCommand {
/// Build a cyan preview wire connecting the picked points and the cursor.
fn preview_wire(name: &str, pts: Vec<[f32; 3]>) -> WireModel {
WireModel {
pick_tris: Vec::new(),
pick_tris_low: Vec::new(),
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
+2
View File
@@ -3059,6 +3059,8 @@ fn collect_runs(pts: &[[f64; 2]], take: &dyn Fn([f64; 2]) -> bool, out: &mut Vec
/// Build a preview wire from a NaN-break point list.
fn preview_wire(points: Vec<[f32; 3]>, color: [f32; 4], name: &str) -> WireModel {
WireModel {
pick_tris: Vec::new(),
pick_tris_low: Vec::new(),
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
+2
View File
@@ -81,6 +81,8 @@ impl CadCommand for MviewCommand {
let pt = pt.as_vec3();
let c1 = self.corner1?;
Some(WireModel {
pick_tris: Vec::new(),
pick_tris_low: Vec::new(),
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
+2
View File
@@ -305,6 +305,8 @@ inventory::submit!(crate::command::CommandRegistration {
fn wire(name: &str, points: Vec<[f32; 3]>) -> WireModel {
WireModel {
pick_tris: Vec::new(),
pick_tris_low: Vec::new(),
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
+2
View File
@@ -48,6 +48,8 @@ impl CadCommand for PlotWindowCommand {
let p1 = self.p1?.as_vec3();
// Draw the selection rectangle.
Some(WireModel {
pick_tris: Vec::new(),
pick_tris_low: Vec::new(),
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
+2
View File
@@ -65,6 +65,8 @@ impl CadCommand for ZoomWindowCommand {
let max = p1.max(pt);
// Draw a rectangle preview
Some(WireModel {
pick_tris: Vec::new(),
pick_tris_low: Vec::new(),
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
+39 -1
View File
@@ -48,6 +48,11 @@ pub struct LocalWire {
pub tangent_geoms: Vec<TangentGeom>,
pub fill_tris: Vec<[f32; 3]>,
pub fill_tris_low: Vec<[f32; 3]>,
/// Thickness-wall pick geometry, transformed by the insert like `points`
/// so a block child's extruded wall stays selectable at the instance's
/// place and scale. Never reaches the GPU.
pub pick_tris: Vec<[f32; 3]>,
pub pick_tris_low: Vec<[f32; 3]>,
/// Per-wire colour from the tessellator output. For most entities this
/// equals the sub-entity's resolved colour. For colour-split MTEXT
/// (`\C`/`\c` inline overrides) each wire carries its own override colour.
@@ -464,6 +469,8 @@ fn tessellate_sub_local(
tangent_geoms: wire.tangent_geoms,
fill_tris: wire.fill_tris,
fill_tris_low: wire.fill_tris_low,
pick_tris: wire.pick_tris,
pick_tris_low: wire.pick_tris_low,
color: wire.color,
aci,
pattern_length: pat_len,
@@ -748,6 +755,10 @@ struct BatchEntry {
/// reconstructs `high + low`). Without it absolute f32 fills quantize to
/// ~0.5 m and the greek-text rectangles shear.
fill_tris_low: Vec<[f32; 3]>,
/// Accumulated thickness-wall pick geometry, paired high/low like
/// `fill_tris`. Pick-only — no GPU batch reads this.
pick_tris: Vec<[f32; 3]>,
pick_tris_low: Vec<[f32; 3]>,
/// Accumulated SDF glyph quads (world space) for block-instance text.
text_verts: Vec<crate::scene::pipeline::text_gpu::TextVertex>,
min_x: f32,
@@ -826,6 +837,8 @@ impl Batches {
// the cached defn.
let color = crate::scene::view::render::adapt_to_bg(b.color, bg_color);
WireModel {
pick_tris: b.pick_tris,
pick_tris_low: b.pick_tris_low,
dash_from_start: false,
dash_align_end: None,
text_verts: b.text_verts,
@@ -1086,7 +1099,11 @@ fn emit_wire(
ctx: &ExpandCtx,
out: &mut Batches,
) {
if lw.points.is_empty() && lw.fill_tris.is_empty() && lw.text_verts.is_empty() {
if lw.points.is_empty()
&& lw.fill_tris.is_empty()
&& lw.text_verts.is_empty()
&& lw.pick_tris.is_empty()
{
return;
}
@@ -1246,6 +1263,27 @@ fn emit_wire(
entry.fill_tris.push([hx, hy, hz]);
entry.fill_tris_low.push([lx, ly, lz]);
}
// Thickness walls: same reconstruct → transform → re-split as the fills
// above, so a block child's wall tracks the insert's placement and scale.
debug_assert!(
lw.pick_tris_low.is_empty() || lw.pick_tris.len() == lw.pick_tris_low.len(),
"pick_tris_low must be empty or the same length as pick_tris (got {} vs {})",
lw.pick_tris.len(),
lw.pick_tris_low.len(),
);
for (idx, p) in lw.pick_tris.iter().enumerate() {
let pl = lw.pick_tris_low.get(idx).copied().unwrap_or([0.0; 3]);
let v = accum_xform.apply(Vector3::new(
p[0] as f64 + pl[0] as f64,
p[1] as f64 + pl[1] as f64,
p[2] as f64 + pl[2] as f64,
));
let (hx, lx) = WireModel::split_ds(v.x);
let (hy, ly) = WireModel::split_ds(v.y);
let (hz, lz) = WireModel::split_ds(v.z);
entry.pick_tris.push([hx, hy, hz]);
entry.pick_tris_low.push([lx, ly, lz]);
}
// SDF glyph quads: reconstruct each block-local f64 position, apply the
// insert transform, re-split — same path as points/fills so block-instance
// text lands at the right world place and scale. Colour resolves to the
+36
View File
@@ -70,8 +70,44 @@ pub struct TruckEntity {
/// triangle. Non-empty for mesh-like entities (PolyfaceMesh, PolygonMesh)
/// that need solid fill.
pub fill_tris: Vec<[f64; 3]>,
/// Pre-triangulated pick-only geometry: flat list of WCS f64 vertices, 3
/// per triangle. Non-empty for entities carrying a DXF thickness (code 39)
/// — the swept wall between each base segment and its extruded copy. Kept
/// apart from `fill_tris` because only that one reaches the GPU; see
/// [`WireModel::pick_tris`](crate::scene::model::wire_model::WireModel::pick_tris).
pub pick_tris: Vec<[f64; 3]>,
}
pub fn convert(entity: &EntityType, document: &CadDocument) -> Option<TruckEntity> {
entity.to_truck_entity(document)
}
/// Triangulate the wall swept by extruding `base` along `extrusion` — the
/// geometry a DXF thickness (code 39) adds to an entity. Two triangles per
/// base segment, in `TruckEntity::pick_tris` order (flat WCS f64, 3 per tri).
///
/// `base` is a point chain in the same form a wire uses, so a NaN entry breaks
/// the sweep exactly as it breaks a segment chain — a polyline with disjoint
/// runs extrudes into one wall per run, not a wall bridging the gap.
pub fn extrusion_wall_tris(base: &[[f64; 3]], extrusion: [f64; 3]) -> Vec<[f64; 3]> {
let top = |p: [f64; 3]| {
[
p[0] + extrusion[0],
p[1] + extrusion[1],
p[2] + extrusion[2],
]
};
let mut out = Vec::new();
let mut prev: Option<[f64; 3]> = None;
for &p in base {
if p[0].is_nan() {
prev = None;
continue;
}
if let Some(a) = prev {
out.extend_from_slice(&[a, p, top(p), a, top(p), top(a)]);
}
prev = Some(p);
}
out
}
+6
View File
@@ -502,6 +502,8 @@ pub(crate) fn tessellate_entity(
(ins.insert_point.z) as f32,
);
let marker = WireModel {
pick_tris: Vec::new(),
pick_tris_low: Vec::new(),
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
@@ -880,6 +882,8 @@ fn lod_stub_wire(
// LOD boundary. #19.
let stored_color = if selected { WireModel::SELECTED } else { color };
WireModel {
pick_tris: Vec::new(),
pick_tris_low: Vec::new(),
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
@@ -945,6 +949,8 @@ fn lod_stub_wire_3d(
}
let stored_color = if selected { WireModel::SELECTED } else { color };
WireModel {
pick_tris: Vec::new(),
pick_tris_low: Vec::new(),
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
+39
View File
@@ -370,6 +370,8 @@ pub fn tessellate(
None
};
elem_wires.push(WireModel {
pick_tris: Vec::new(),
pick_tris_low: Vec::new(),
// MLINE dashes: A-type aligned, but the end-dash length is
// shared across all parallel elements (`dash_align_end`) so
// their interiors stay in phase (perpendicular dashes line up)
@@ -650,6 +652,8 @@ pub fn tessellate(
ftl.push(lo);
}
wires.push(WireModel {
pick_tris: Vec::new(),
pick_tris_low: Vec::new(),
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
@@ -685,6 +689,8 @@ pub fn tessellate(
fpl.push(lo);
}
wires.push(WireModel {
pick_tris: Vec::new(),
pick_tris_low: Vec::new(),
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
@@ -727,6 +733,8 @@ pub fn tessellate(
low.push(ll);
}
wires.push(WireModel {
pick_tris: Vec::new(),
pick_tris_low: Vec::new(),
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
@@ -750,6 +758,8 @@ pub fn tessellate(
});
}
wires.push(WireModel {
pick_tris: Vec::new(),
pick_tris_low: Vec::new(),
dash_from_start: false,
dash_align_end: None,
text_verts: sdf_verts,
@@ -803,6 +813,8 @@ pub fn tessellate(
(Vec::new(), Vec::new(), Vec::new())
};
out.push(WireModel {
pick_tris: Vec::new(),
pick_tris_low: Vec::new(),
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
@@ -838,6 +850,8 @@ pub fn tessellate(
(Vec::new(), Vec::new(), Vec::new())
};
out.push(WireModel {
pick_tris: Vec::new(),
pick_tris_low: Vec::new(),
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
@@ -870,6 +884,8 @@ pub fn tessellate(
// are empty → the early-return path above).
if !sdf_verts.is_empty() {
out.push(WireModel {
pick_tris: Vec::new(),
pick_tris_low: Vec::new(),
dash_from_start: false,
dash_align_end: None,
text_verts: sdf_verts,
@@ -895,6 +911,8 @@ pub fn tessellate(
if out.is_empty() {
out.push(WireModel {
pick_tris: Vec::new(),
pick_tris_low: Vec::new(),
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
@@ -942,6 +960,8 @@ pub fn tessellate(
.map(|[kx, ky, kz]| [kx, ky, kz])
.collect();
return vec![WireModel {
pick_tris: Vec::new(),
pick_tris_low: Vec::new(),
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
@@ -988,6 +1008,8 @@ pub fn tessellate(
.map(|[x, y, z]| [x, y, z])
.collect();
return vec![WireModel {
pick_tris: Vec::new(),
pick_tris_low: Vec::new(),
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
@@ -1023,6 +1045,8 @@ pub fn tessellate(
.map(|[x, y, z]| [x, y, z])
.collect();
return vec![WireModel {
pick_tris: Vec::new(),
pick_tris_low: Vec::new(),
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
@@ -1062,6 +1086,11 @@ pub fn tessellate(
.map(|[x, y, z]| [x, y, z])
.collect();
let (fill_tris, fill_tris_low) = points_to_ds(te.fill_tris);
// Thickness walls ride on the wire that carries their edges, not
// on a wire of their own: they are pick geometry for that entity,
// and `fill_tris` below deliberately splits off into a fill-only
// wire (`is_fill_only`) which has no `points` to hang them from.
let (pick_tris, pick_tris_low) = points_to_ds(te.pick_tris);
let mut out = Vec::new();
let mut is_first = true;
@@ -1077,6 +1106,8 @@ pub fn tessellate(
(Vec::new(), Vec::new(), Vec::new())
};
out.push(WireModel {
pick_tris,
pick_tris_low,
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
@@ -1111,6 +1142,8 @@ pub fn tessellate(
(Vec::new(), Vec::new(), Vec::new())
};
out.push(WireModel {
pick_tris: Vec::new(),
pick_tris_low: Vec::new(),
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
@@ -1136,6 +1169,8 @@ pub fn tessellate(
if out.is_empty() {
out.push(WireModel {
pick_tris: Vec::new(),
pick_tris_low: Vec::new(),
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
@@ -1171,6 +1206,8 @@ pub fn tessellate(
.map(|[x, y, z]| [x, y, z])
.collect();
return vec![WireModel {
pick_tris: Vec::new(),
pick_tris_low: Vec::new(),
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
@@ -1237,6 +1274,8 @@ pub fn tessellate(
let snap_pts: Vec<(glam::DVec3, SnapHint)> =
snap_pts.into_iter().map(|(p, h)| (p.as_dvec3(), h)).collect();
vec![WireModel {
pick_tris: Vec::new(),
pick_tris_low: Vec::new(),
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
+17
View File
@@ -95,6 +95,19 @@ pub struct WireModel {
/// Low residual paired with [`fill_tris`] (double-single). Empty = all-zero;
/// tessellation from CAD f64 fills it so fills stay precise at UTM scale.
pub fill_tris_low: Vec<[f32; 3]>,
/// Pre-triangulated pick-only geometry: flat vertex list, 3 per triangle.
/// Hit-testing treats these as solid, the renderer never uploads them.
///
/// Non-empty only for entities extruded by a DXF thickness (code 39): the
/// swept wall between each base segment and its extruded copy. The wall is
/// drawn as its four wireframe edges (which live in [`points`]), so it has
/// no fill geometry to pick — without this the interior is a hole the
/// cursor falls through. Separate from [`fill_tris`] because that channel
/// reaches the GPU, and a wall that renders shaded would change how every
/// thickness drawing looks.
pub pick_tris: Vec<[f32; 3]>,
/// Low residual paired with [`pick_tris`] (double-single). Empty = all-zero.
pub pick_tris_low: Vec<[f32; 3]>,
/// SDF glyph quads for this entity's text (TEXT / MTEXT / dimension text /
/// block-internal text). Non-empty only when SDF text is enabled and this
/// wire carries a text run. Rides with the wire so it is cached by the
@@ -149,6 +162,8 @@ impl WireModel {
/// Create a solid wire (no dash pattern, 1px weight).
pub fn solid(name: String, points: Vec<[f32; 3]>, color: [f32; 4], selected: bool) -> Self {
Self {
pick_tris: Vec::new(),
pick_tris_low: Vec::new(),
text_verts: Vec::new(),
name,
points,
@@ -379,6 +394,8 @@ impl Default for WireModel {
vp_scissor: None,
fill_tris: Vec::new(),
fill_tris_low: Vec::new(),
pick_tris: Vec::new(),
pick_tris_low: Vec::new(),
}
}
}
+196 -56
View File
@@ -15,12 +15,115 @@ use crate::scene::model::mesh_model::MeshModel;
use crate::scene::model::wire_model::WireModel;
/// Pixel radius used for single-click wire detection.
const CLICK_THRESHOLD_PX: f32 = 8.0;
pub const CLICK_THRESHOLD_PX: f32 = 8.0;
/// Pick radius for one wire, in screen pixels.
///
/// A wire renders as a band `line_weight_px` wide, so testing every wire at the
/// bare [`CLICK_THRESHOLD_PX`] would leave the outer part of a heavy line
/// unselectable — the cursor would sit on solid ink and miss. Widening to the
/// rendered half-width keeps "looks like I'm on it" and "picks it" the same
/// thing at any zoom: both quantities are screen-space, so the relation holds
/// however far in the view is.
///
/// `lw_display` mirrors the wire shader's `select(0.5, half_width, ...)`
/// (`wire.wgsl`) — with lineweight display off the line collapses to 1 px, so
/// the pick band must collapse with it rather than stay secretly fat.
///
/// The standard weights all land under the threshold today (the widest, 2.11 mm,
/// renders 7.97 px half-width), so this only bites for out-of-range weights —
/// and it keeps the two sides from silently drifting apart if the display boost
/// in `view::render::lineweight_to_px` ever changes.
pub fn pick_tolerance_px(wire: &WireModel, lw_display: bool) -> f32 {
let half_width = if lw_display {
wire.line_weight_px * 0.5
} else {
0.5
};
CLICK_THRESHOLD_PX.max(half_width)
}
/// Is `aabb` — a wire's world-space XY box — further than `tol` pixels from
/// `cursor` once projected, so the wire can be skipped without touching its
/// geometry?
///
/// Only sound in a flat (untilted) view, where a point's screen x/y depends on
/// its world x/y alone and the box therefore projects exactly. Callers must
/// check that themselves, and must skip the unbounded sentinel.
fn aabb_rejects(
aabb: [f32; 4],
cursor: Point,
tol: f32,
view_rot: Mat4,
eye: glam::DVec3,
bounds: Rectangle,
) -> bool {
let [minx, miny, maxx, maxy] = aabb;
// Project all four corners — a plan view can be rotated about Z, so the
// screen footprint isn't axis-aligned and the two diagonal corners alone
// wouldn't bound it.
let (mut sx0, mut sy0, mut sx1, mut sy1) = (f32::MAX, f32::MAX, f32::MIN, f32::MIN);
for (cx, cy) in [(minx, miny), (maxx, miny), (maxx, maxy), (minx, maxy)] {
let s = world_to_screen(
glam::DVec3::new(cx as f64, cy as f64, 0.0),
view_rot,
eye,
bounds,
);
sx0 = sx0.min(s.x);
sx1 = sx1.max(s.x);
sy0 = sy0.min(s.y);
sy1 = sy1.max(s.y);
}
cursor.x < sx0 - tol || cursor.x > sx1 + tol || cursor.y < sy0 - tol || cursor.y > sy1 + tol
}
/// Depth of the first triangle in `tris` whose screen projection contains
/// `cursor`, as the mean NDC z of its corners; `None` when none do.
///
/// `tris` is a flat vertex list, 3 per triangle, and `tris_low` its
/// double-single residual — empty meaning an all-zero low half, per the
/// [`WireModel`] contract.
fn tris_hit_depth(
cursor: Point,
tris: &[[f32; 3]],
tris_low: &[[f32; 3]],
view_rot: Mat4,
eye: glam::DVec3,
bounds: Rectangle,
) -> Option<f32> {
let mut t = 0;
while t + 2 < tris.len() {
let mut sp = [Point::ORIGIN; 3];
let mut depth = 0.0f32;
for j in 0..3 {
let k = t + j;
let hi = tris[k];
let lo = tris_low.get(k).copied().unwrap_or([0.0; 3]);
let world = glam::DVec3::new(
hi[0] as f64 + lo[0] as f64,
hi[1] as f64 + lo[1] as f64,
hi[2] as f64 + lo[2] as f64,
);
let ndc = view_rot.project_point3((world - eye).as_vec3());
sp[j] = Point::new(
(ndc.x + 1.0) * 0.5 * bounds.width,
(1.0 - ndc.y) * 0.5 * bounds.height,
);
depth += ndc.z;
}
t += 3;
if point_in_polygon(cursor, &sp) {
return Some(depth / 3.0);
}
}
None
}
// ── Single-click hit test ─────────────────────────────────────────────────
/// Return the `name` of the closest wire whose screen-space segments pass
/// within `CLICK_THRESHOLD_PX` pixels of `cursor`.
/// within that wire's [`pick_tolerance_px`] of `cursor`.
///
/// Returns `None` when no wire is close enough.
pub fn click_hit<'a>(
@@ -29,13 +132,16 @@ pub fn click_hit<'a>(
view_rot: Mat4,
eye: glam::DVec3,
bounds: Rectangle,
lw_display: bool,
) -> Option<&'a str> {
// A click outside the pane rectangle (e.g. on the paper around a floating
// viewport) must not reach geometry scissored out of the viewport.
if cursor.x < 0.0 || cursor.x > bounds.width || cursor.y < 0.0 || cursor.y > bounds.height {
return None;
}
let mut best_dist = CLICK_THRESHOLD_PX;
// Each wire brings its own threshold now (a heavy line catches over its full
// rendered width), so the running best can't double as the cut-off.
let mut best_dist = f32::MAX;
let mut best: Option<&str> = None;
// World z only shifts the *screen* x/y when the view is tilted (orbit /
@@ -48,29 +154,14 @@ pub fn click_hit<'a>(
// Q: lazy projection — no Vec allocation per wire; NaN resets the segment chain.
for wire in wires {
let tol = pick_tolerance_px(wire, lw_display);
// Cheap AABB pre-reject (flat view only; never for the unbounded
// sentinel used by previews / greeked text).
if z_flat && wire.aabb != WireModel::UNBOUNDED_AABB {
let [minx, miny, maxx, maxy] = wire.aabb;
// Project all four corners — a plan view can be rotated about Z, so
// the screen footprint isn't axis-aligned and the two diagonal
// corners alone wouldn't bound it.
let mut sx0 = f32::MAX;
let mut sy0 = f32::MAX;
let mut sx1 = f32::MIN;
let mut sy1 = f32::MIN;
for (cx, cy) in [(minx, miny), (maxx, miny), (maxx, maxy), (minx, maxy)] {
let s = world_to_screen(glam::DVec3::new(cx as f64, cy as f64, 0.0), view_rot, eye, bounds);
sx0 = sx0.min(s.x);
sx1 = sx1.max(s.x);
sy0 = sy0.min(s.y);
sy1 = sy1.max(s.y);
}
let t = CLICK_THRESHOLD_PX;
if cursor.x < sx0 - t || cursor.x > sx1 + t || cursor.y < sy0 - t || cursor.y > sy1 + t
{
continue;
}
if z_flat
&& wire.aabb != WireModel::UNBOUNDED_AABB
&& aabb_rejects(wire.aabb, cursor, tol, view_rot, eye, bounds)
{
continue;
}
let mut prev: Option<Point> = None;
for (i, &[px, py, pz]) in wire.points.iter().enumerate() {
@@ -81,7 +172,7 @@ pub fn click_hit<'a>(
let cur = world_to_screen(wp64([px, py, pz], &wire.points_low, i), view_rot, eye, bounds);
if let Some(p0) = prev {
let d = dist_point_to_segment(cursor, p0, cur);
if d < best_dist {
if d < tol && d < best_dist {
best_dist = d;
best = Some(&wire.name);
}
@@ -104,33 +195,16 @@ pub fn click_hit<'a>(
if wire.fill_tris.is_empty() {
continue;
}
let mut t = 0;
while t + 2 < wire.fill_tris.len() {
let mut sp = [Point::ORIGIN; 3];
let mut depth = 0.0f32;
for j in 0..3 {
let k = t + j;
let hi = wire.fill_tris[k];
let lo = wire.fill_tris_low.get(k).copied().unwrap_or([0.0; 3]);
let world = glam::DVec3::new(
hi[0] as f64 + lo[0] as f64,
hi[1] as f64 + lo[1] as f64,
hi[2] as f64 + lo[2] as f64,
);
let ndc = view_rot.project_point3((world - eye).as_vec3());
sp[j] = Point::new(
(ndc.x + 1.0) * 0.5 * bounds.width,
(1.0 - ndc.y) * 0.5 * bounds.height,
);
depth += ndc.z;
}
t += 3;
if point_in_polygon(cursor, &sp) {
let d = depth / 3.0;
if best_fill.map_or(true, |(bd, _)| d < bd) {
best_fill = Some((d, wire.name.as_str()));
}
break;
if let Some(d) = tris_hit_depth(
cursor,
&wire.fill_tris,
&wire.fill_tris_low,
view_rot,
eye,
bounds,
) {
if best_fill.map_or(true, |(bd, _)| d < bd) {
best_fill = Some((d, wire.name.as_str()));
}
}
}
@@ -138,6 +212,47 @@ pub fn click_hit<'a>(
return Some(n);
}
// No drawn surface either. Entities extruded by a thickness draw their wall
// as four edges and nothing in between, so the interior is a hole the cursor
// falls straight through even though the wall reads as a face. Test the
// pick-only wall geometry to close it, front-most first.
//
// Ranked below `fill_tris` on purpose: those triangles are actually
// rendered, these are invisible, and where an opaque face overlaps a wall
// the user is looking at the face — so the face is what they mean to click.
let mut best_wall: Option<(f32, &str)> = None;
for wire in wires {
if wire.pick_tris.is_empty() {
continue;
}
// This runs on every hover that misses everything else — the common case
// over empty space — and a wall is two triangles per base segment, so an
// extruded circle alone is ~128 of them. Reject on the box first or a
// drawing full of thickness turns every mouse move into a projection of
// its every wall.
if z_flat
&& wire.aabb != WireModel::UNBOUNDED_AABB
&& aabb_rejects(wire.aabb, cursor, 0.0, view_rot, eye, bounds)
{
continue;
}
if let Some(d) = tris_hit_depth(
cursor,
&wire.pick_tris,
&wire.pick_tris_low,
view_rot,
eye,
bounds,
) {
if best_wall.map_or(true, |(bd, _)| d < bd) {
best_wall = Some((d, wire.name.as_str()));
}
}
}
if let Some((_, n)) = best_wall {
return Some(n);
}
// SDF text renders as glyph quads, not strokes — its only pick target is
// the empty wire that carries the quads (`text_verts`) and the text's own
// tight AABB. Fall back to AABB containment so such text stays click-
@@ -184,14 +299,16 @@ pub fn click_hits_all<'a>(
view_rot: Mat4,
eye: glam::DVec3,
bounds: Rectangle,
lw_display: bool,
) -> Vec<&'a str> {
if cursor.x < 0.0 || cursor.x > bounds.width || cursor.y < 0.0 || cursor.y > bounds.height {
return Vec::new();
}
let mut hits: Vec<(f32, &str)> = Vec::new();
for wire in wires {
let tol = pick_tolerance_px(wire, lw_display);
let mut prev: Option<Point> = None;
let mut best_for_wire = CLICK_THRESHOLD_PX;
let mut best_for_wire = tol;
let mut hit = false;
for (i, &[px, py, pz]) in wire.points.iter().enumerate() {
if px.is_nan() {
@@ -212,6 +329,29 @@ pub fn click_hits_all<'a>(
hits.push((best_for_wire, &wire.name));
}
}
// Thickness walls join the cycle so an extruded entity picked on its wall
// can be stepped past to whatever sits behind it. Ranked at the threshold,
// below every proximity hit — same convention the text boxes below use.
//
// A wall's own edges live on the same wire, so skip any wire the loop above
// already caught: cycling must not offer one entity twice.
for wire in wires {
if wire.pick_tris.is_empty() || hits.iter().any(|&(_, n)| n == wire.name) {
continue;
}
if tris_hit_depth(
cursor,
&wire.pick_tris,
&wire.pick_tris_low,
view_rot,
eye,
bounds,
)
.is_some()
{
hits.push((CLICK_THRESHOLD_PX, &wire.name));
}
}
// SDF text: include a text whose box contains the cursor (an empty-stroke
// wire carrying glyph quads) so selection cycling steps through text too —
// the same discriminator/fallback `click_hit` uses. Ranked after real
@@ -974,9 +1114,9 @@ mod aabb_reject_tests {
let far = wire("9", vec![[0.9, 0.9, 0.0], [0.95, 0.9, 0.0]], [0.9, 0.9, 0.95, 0.9]);
let eye = glam::DVec3::ZERO;
assert_eq!(click_hit(cursor, std::slice::from_ref(&near), vp, eye, bounds), Some("5"));
assert_eq!(click_hit(cursor, std::slice::from_ref(&far), vp, eye, bounds), None);
assert_eq!(click_hit(cursor, std::slice::from_ref(&near), vp, eye, bounds, true), Some("5"));
assert_eq!(click_hit(cursor, std::slice::from_ref(&far), vp, eye, bounds, true), None);
// The far wire must be rejected without hiding the near one.
assert_eq!(click_hit(cursor, &[far, near], vp, eye, bounds), Some("5"));
assert_eq!(click_hit(cursor, &[far, near], vp, eye, bounds, true), Some("5"));
}
}
+8
View File
@@ -313,6 +313,14 @@ impl Scene {
// re-expressed in paper coords — drop it (no tangent snap on
// projected viewport content) rather than snap to UTM.
out.tangent_geoms = Vec::new();
// Same for thickness walls: `points` above were projected and
// clipped into paper coords, and the clone's wall triangles are
// still model-space. Left in place they would hit-test at a
// model-scale offset — a stray catch somewhere out on the sheet.
// Drop them: an extruded entity inside a floating viewport stays
// selectable by its edges, which do get projected.
out.pick_tris = Vec::new();
out.pick_tris_low = Vec::new();
out.aabb = if pmnx.is_finite() {
[pmnx, pmny, pmxx, pmxy]
} else {
+4
View File
@@ -305,6 +305,8 @@ pub fn apply_along(
.into_iter()
.filter(|s| s.len() >= 2)
.map(|pts| WireModel {
pick_tris: Vec::new(),
pick_tris_low: Vec::new(),
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
@@ -342,6 +344,8 @@ pub fn apply_along(
xy = xy.max(y);
}
out.push(WireModel {
pick_tris: Vec::new(),
pick_tris_low: Vec::new(),
dash_from_start: false,
dash_align_end: None,
text_verts,