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>
498 lines
17 KiB
Rust
498 lines
17 KiB
Rust
//! CPU-side complex linetype tessellation.
|
|
//!
|
|
//! Walks the entity path and applies LT elements (dashes, gaps, dots, shapes),
|
|
//! returning one `WireModel` per continuous stroke.
|
|
//!
|
|
//! Coordinate convention: 2D entities live in the **XZ** plane (Y = elevation).
|
|
//! Shape X → along the linetype direction; Shape Y → perpendicular in XZ.
|
|
|
|
use crate::entities::text_support::resolve_dxf_special_chars;
|
|
use crate::io::linetypes::{ComplexLt, LtSegment};
|
|
use crate::scene::text::lff;
|
|
use crate::scene::model::wire_model::WireModel;
|
|
|
|
// ── Public entry point ────────────────────────────────────────────────────
|
|
|
|
/// Apply `lt` along `path_pts` (the entity's tessellated world-space strip).
|
|
///
|
|
/// Returns one `WireModel` per continuous stroke. Pass the entity's `name`,
|
|
/// `color`, `selected` flag, and `line_weight_px` so the WireModels inherit
|
|
/// the entity's visual properties.
|
|
/// Build the "A"-type segment list for one path: a solid begin/end dash of
|
|
/// length `align_end`, and between them the pattern's interior tiled to fill the
|
|
/// rest — starting at the element AFTER the first dash (so embedded text lands
|
|
/// in the same gaps the GPU dash shader phases to). Length-carrying elements are
|
|
/// truncated to fit exactly; zero-length glyphs (Dot / Text / Shape) pass
|
|
/// through at their interior position. Returns `None` if the interior would
|
|
/// exceed a sane repeat count (caller keeps the from-start tiling).
|
|
fn atype_segments(scaled: &[LtSeg], align_end: f32, total: f32) -> Option<Vec<LtSeg>> {
|
|
let pat_len: f32 = scaled
|
|
.iter()
|
|
.map(|s| match s {
|
|
LtSeg::Dash(l) | LtSeg::Space(l) => *l,
|
|
_ => 0.0,
|
|
})
|
|
.sum();
|
|
if pat_len < 1e-10 || scaled.len() < 2 {
|
|
return None;
|
|
}
|
|
let ae = align_end.clamp(1e-4, total * 0.5);
|
|
let interior = total - 2.0 * ae;
|
|
if interior / pat_len > 50_000.0 {
|
|
return None; // too many repeats to materialise — keep cycling walk
|
|
}
|
|
let mut segs: Vec<LtSeg> = Vec::new();
|
|
segs.push(LtSeg::Dash(ae));
|
|
if interior > 1e-6 {
|
|
let mut filled = 0.0f32;
|
|
let mut idx = 1usize; // element after the leading dash (the gap)
|
|
while filled < interior - 1e-6 {
|
|
match &scaled[idx % scaled.len()] {
|
|
LtSeg::Dash(l) => {
|
|
let take = l.min(interior - filled);
|
|
segs.push(LtSeg::Dash(take));
|
|
filled += take;
|
|
}
|
|
LtSeg::Space(l) => {
|
|
let take = l.min(interior - filled);
|
|
segs.push(LtSeg::Space(take));
|
|
filled += take;
|
|
}
|
|
other => segs.push(other.clone()), // Dot / Text / Shape (0-length)
|
|
}
|
|
idx += 1;
|
|
}
|
|
}
|
|
segs.push(LtSeg::Dash(ae));
|
|
Some(segs)
|
|
}
|
|
|
|
pub fn apply_along(
|
|
name: &str,
|
|
path_pts: &[[f32; 3]],
|
|
lt: &ComplexLt,
|
|
scale: f32,
|
|
color: [f32; 4],
|
|
selected: bool,
|
|
line_weight_px: f32,
|
|
// Shared "A"-type reference length (the MLINE centre-line). `Some` end-aligns
|
|
// the pattern: a solid begin/end dash whose length is derived from `ref_total`
|
|
// (so every parallel element shares one interior phase and its dashes line up)
|
|
// while this element still ends on a dash at its own endpoint. `None` tiles
|
|
// the pattern from the start vertex (legacy phase, unchanged).
|
|
ref_total: Option<f32>,
|
|
) -> Vec<WireModel> {
|
|
if path_pts.len() < 2 || lt.segments.is_empty() {
|
|
return vec![];
|
|
}
|
|
|
|
let scaled: Vec<LtSeg> = scale_segments(<.segments, scale);
|
|
if scaled.is_empty() {
|
|
return vec![];
|
|
}
|
|
|
|
let pattern_len: f32 = scaled
|
|
.iter()
|
|
.map(|s| match s {
|
|
LtSeg::Dash(l) | LtSeg::Space(l) => *l,
|
|
LtSeg::Dot | LtSeg::Shape { .. } | LtSeg::Text { .. } => 0.0,
|
|
})
|
|
.sum();
|
|
if pattern_len < 1e-10 {
|
|
return vec![];
|
|
}
|
|
|
|
// Guard against pattern blow-up. The per-segment walk below emits at least
|
|
// one stroke vertex per pattern element, so the total vertex count scales
|
|
// with `path_len / pattern_len`. On a large-extent drawing (e.g. a city/
|
|
// street map) carrying a finely-scaled complex linetype, that ratio can
|
|
// reach billions — the strokes Vec grows until the process is OOM-killed,
|
|
// single-threaded, before the drawing ever finishes loading. Past a sane
|
|
// repeat count the dashes are sub-pixel anyway, so render the base wire
|
|
// solid: returning empty here makes the caller fall back to the solid base.
|
|
const MAX_PATTERN_REPEATS: f32 = 1_000_000.0;
|
|
let path_len: f32 = path_pts
|
|
.windows(2)
|
|
.map(|w| {
|
|
let d = [w[1][0] - w[0][0], w[1][1] - w[0][1], w[1][2] - w[0][2]];
|
|
(d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt()
|
|
})
|
|
.sum();
|
|
if path_len / pattern_len > MAX_PATTERN_REPEATS {
|
|
return vec![];
|
|
}
|
|
|
|
// "A"-type end alignment (MLINE): with a shared reference length, replace the
|
|
// from-start tiling with a solid begin/end dash and a phased interior so this
|
|
// element lines up with its siblings and ends on a dash. Dash-first patterns
|
|
// only; align_end is derived from `ref_total` (shared) but the trailing dash
|
|
// lands at this element's own end (`path_len`).
|
|
let atype_segs: Option<Vec<LtSeg>> = ref_total.and_then(|rt| {
|
|
let a = match scaled.first() {
|
|
Some(LtSeg::Dash(a)) if *a > 0.0 => *a,
|
|
_ => return None,
|
|
};
|
|
if rt <= pattern_len || path_len <= pattern_len {
|
|
return None;
|
|
}
|
|
let k = ((rt - a) / pattern_len).round().max(1.0);
|
|
let align_end = (rt - k * pattern_len + a) * 0.5;
|
|
atype_segments(&scaled, align_end, path_len)
|
|
});
|
|
let atype_on = atype_segs.is_some();
|
|
let walk_segs: &[LtSeg] = atype_segs.as_deref().unwrap_or(&scaled);
|
|
|
|
let mut strokes: Vec<Vec<[f32; 3]>> = Vec::new();
|
|
let mut cur_stroke: Vec<[f32; 3]> = Vec::new();
|
|
// Embedded linetype text (LtSeg::Text) renders as SDF glyph quads collected
|
|
// here; emitted as one extra text-carrying WireModel at the end.
|
|
let mut text_verts: Vec<crate::scene::pipeline::text_gpu::TextVertex> = Vec::new();
|
|
|
|
let mut elem_idx: usize = 0;
|
|
let mut elem_consumed: f32 = 0.0;
|
|
|
|
'path: for i in 0..path_pts.len() - 1 {
|
|
let ps = path_pts[i];
|
|
let pe = path_pts[i + 1];
|
|
|
|
let dx = pe[0] - ps[0];
|
|
let dy = pe[1] - ps[1];
|
|
let dz = pe[2] - ps[2];
|
|
let seg_len = (dx * dx + dy * dy + dz * dz).sqrt();
|
|
if seg_len < 1e-10 {
|
|
continue;
|
|
}
|
|
|
|
let fwd = [dx / seg_len, dy / seg_len, dz / seg_len];
|
|
let perp = [-fwd[1], fwd[0], fwd[2]];
|
|
|
|
let mut pos = 0.0f32;
|
|
|
|
while pos < seg_len - 1e-6 {
|
|
let idx = if atype_on {
|
|
// A-type walks a finite, path-sized segment list once (no cycling);
|
|
// once consumed the whole path is drawn.
|
|
if elem_idx >= walk_segs.len() {
|
|
break 'path;
|
|
}
|
|
elem_idx
|
|
} else {
|
|
elem_idx % walk_segs.len()
|
|
};
|
|
|
|
match &walk_segs[idx] {
|
|
LtSeg::Dash(dash_len) => {
|
|
let remaining_dash = dash_len - elem_consumed;
|
|
let remaining_seg = seg_len - pos;
|
|
let advance = remaining_dash.min(remaining_seg);
|
|
|
|
let p_start = lerp(ps, pe, pos / seg_len);
|
|
let p_end = lerp(ps, pe, (pos + advance) / seg_len);
|
|
|
|
if cur_stroke.is_empty() {
|
|
cur_stroke.push(p_start);
|
|
}
|
|
cur_stroke.push(p_end);
|
|
|
|
pos += advance;
|
|
elem_consumed += advance;
|
|
if (elem_consumed - dash_len).abs() < 1e-6 {
|
|
elem_idx += 1;
|
|
elem_consumed = 0.0;
|
|
}
|
|
}
|
|
|
|
LtSeg::Space(space_len) => {
|
|
let remaining = space_len - elem_consumed;
|
|
let advance = remaining.min(seg_len - pos);
|
|
|
|
flush(&mut cur_stroke, &mut strokes);
|
|
|
|
pos += advance;
|
|
elem_consumed += advance;
|
|
if (elem_consumed - space_len).abs() < 1e-6 {
|
|
elem_idx += 1;
|
|
elem_consumed = 0.0;
|
|
}
|
|
}
|
|
|
|
LtSeg::Dot => {
|
|
flush(&mut cur_stroke, &mut strokes);
|
|
let p = lerp(ps, pe, pos / seg_len);
|
|
strokes.push(vec![p, p]);
|
|
elem_idx += 1;
|
|
elem_consumed = 0.0;
|
|
if pos < 1e-6 {
|
|
break;
|
|
}
|
|
}
|
|
|
|
LtSeg::Shape {
|
|
name: sh_name,
|
|
x,
|
|
y,
|
|
scale: sh_scale,
|
|
rot_deg,
|
|
} => {
|
|
flush(&mut cur_stroke, &mut strokes);
|
|
|
|
let insert = lerp(ps, pe, pos / seg_len);
|
|
let insert = offset_pt(insert, fwd, perp, *x, *y);
|
|
|
|
let shape_strokes = emit_shape(sh_name, insert, fwd, perp, *sh_scale, *rot_deg);
|
|
strokes.extend(shape_strokes);
|
|
|
|
elem_idx += 1;
|
|
elem_consumed = 0.0;
|
|
if pos < 1e-6 {
|
|
break;
|
|
}
|
|
}
|
|
LtSeg::Text {
|
|
text,
|
|
style,
|
|
x,
|
|
y,
|
|
scale: tx_scale,
|
|
rot_deg,
|
|
} => {
|
|
flush(&mut cur_stroke, &mut strokes);
|
|
|
|
let insert = lerp(ps, pe, pos / seg_len);
|
|
let insert = offset_pt(insert, fwd, perp, *x, *y);
|
|
let fwd_angle = fwd[1].atan2(fwd[0]) + rot_deg.to_radians();
|
|
let resolved = resolve_dxf_special_chars(text);
|
|
// SDF: glyph quads at the insert point (rotation baked in by
|
|
// layout_glyph_quads), collected for the text wire.
|
|
if let Ok(mut atlas) = crate::scene::text::sdf_atlas::text_atlas().lock() {
|
|
let quads = crate::scene::text::glyph_quads::layout_glyph_quads(
|
|
&mut atlas,
|
|
*tx_scale,
|
|
fwd_angle,
|
|
1.0, // width_factor
|
|
0.0, // oblique
|
|
// tracking = 1.0 applies the font's natural
|
|
// letter_spacing; 0.0 zeroes it and cramps the glyphs
|
|
// (every other text path passes 1.0).
|
|
1.0,
|
|
style,
|
|
false,
|
|
&resolved,
|
|
);
|
|
crate::scene::pipeline::text_gpu::push_glyph_vertices(
|
|
&mut text_verts,
|
|
&quads,
|
|
[insert[0] as f64, insert[1] as f64, insert[2] as f64],
|
|
1.0,
|
|
color,
|
|
0.0,
|
|
);
|
|
}
|
|
|
|
elem_idx += 1;
|
|
elem_consumed = 0.0;
|
|
if pos < 1e-6 {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
flush(&mut cur_stroke, &mut strokes);
|
|
|
|
let mut out: Vec<WireModel> = strokes
|
|
.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(),
|
|
name: name.to_string(),
|
|
points: pts,
|
|
points_low: Vec::new(),
|
|
color,
|
|
selected,
|
|
pattern_length: 0.0,
|
|
pattern: [0.0; 8],
|
|
line_weight_px,
|
|
snap_pts: vec![],
|
|
tangent_geoms: vec![],
|
|
aci: 0,
|
|
key_vertices: vec![],
|
|
aabb: WireModel::UNBOUNDED_AABB,
|
|
plinegen: true,
|
|
vp_scissor: None,
|
|
fill_tris: vec![],
|
|
fill_tris_low: Vec::new(),
|
|
})
|
|
.collect();
|
|
|
|
// Embedded linetype text (SDF): one extra wire carrying the glyph quads,
|
|
// with a glyph-bounds AABB (f64 accumulate → f32) so it stays precise at
|
|
// UTM scale. Empty points so it doesn't add stroke geometry.
|
|
if !text_verts.is_empty() {
|
|
let (mut nx, mut ny, mut xx, mut xy) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
|
|
for v in &text_verts {
|
|
let x = v.pos[0] as f64 + v.pos_low[0] as f64;
|
|
let y = v.pos[1] as f64 + v.pos_low[1] as f64;
|
|
nx = nx.min(x);
|
|
xx = xx.max(x);
|
|
ny = ny.min(y);
|
|
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,
|
|
name: name.to_string(),
|
|
points: Vec::new(),
|
|
points_low: Vec::new(),
|
|
color,
|
|
selected,
|
|
pattern_length: 0.0,
|
|
pattern: [0.0; 8],
|
|
line_weight_px,
|
|
snap_pts: vec![],
|
|
tangent_geoms: vec![],
|
|
aci: 0,
|
|
key_vertices: vec![],
|
|
aabb: [nx as f32, ny as f32, xx as f32, xy as f32],
|
|
plinegen: true,
|
|
vp_scissor: None,
|
|
fill_tris: vec![],
|
|
fill_tris_low: Vec::new(),
|
|
});
|
|
}
|
|
out
|
|
}
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
|
|
|
#[derive(Clone)]
|
|
enum LtSeg {
|
|
Dash(f32),
|
|
Space(f32),
|
|
Dot,
|
|
Shape {
|
|
name: String,
|
|
x: f32,
|
|
y: f32,
|
|
scale: f32,
|
|
rot_deg: f32,
|
|
},
|
|
Text {
|
|
text: String,
|
|
style: String,
|
|
x: f32,
|
|
y: f32,
|
|
scale: f32,
|
|
rot_deg: f32,
|
|
},
|
|
}
|
|
|
|
fn scale_segments(segs: &[LtSegment], scale: f32) -> Vec<LtSeg> {
|
|
segs.iter()
|
|
.map(|s| match s {
|
|
LtSegment::Dash(l) => LtSeg::Dash(l * scale),
|
|
LtSegment::Space(l) => LtSeg::Space(l * scale),
|
|
LtSegment::Dot => LtSeg::Dot,
|
|
LtSegment::Shape {
|
|
name,
|
|
x,
|
|
y,
|
|
scale: sh_scale,
|
|
rot_deg,
|
|
} => LtSeg::Shape {
|
|
name: name.clone(),
|
|
x: x * scale,
|
|
y: y * scale,
|
|
scale: *sh_scale * scale,
|
|
rot_deg: *rot_deg,
|
|
},
|
|
LtSegment::Text {
|
|
text,
|
|
style,
|
|
x,
|
|
y,
|
|
scale: tx_scale,
|
|
rot_deg,
|
|
} => LtSeg::Text {
|
|
text: text.clone(),
|
|
style: style.clone(),
|
|
x: x * scale,
|
|
y: y * scale,
|
|
scale: *tx_scale * scale,
|
|
rot_deg: *rot_deg,
|
|
},
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn flush(cur: &mut Vec<[f32; 3]>, strokes: &mut Vec<Vec<[f32; 3]>>) {
|
|
if !cur.is_empty() {
|
|
strokes.push(std::mem::take(cur));
|
|
}
|
|
}
|
|
|
|
fn lerp(a: [f32; 3], b: [f32; 3], t: f32) -> [f32; 3] {
|
|
[
|
|
a[0] + (b[0] - a[0]) * t,
|
|
a[1] + (b[1] - a[1]) * t,
|
|
a[2] + (b[2] - a[2]) * t,
|
|
]
|
|
}
|
|
|
|
fn offset_pt(pt: [f32; 3], fwd: [f32; 3], perp: [f32; 3], dx: f32, dy: f32) -> [f32; 3] {
|
|
[
|
|
pt[0] + fwd[0] * dx + perp[0] * dy,
|
|
pt[1] + fwd[1] * dx + perp[1] * dy,
|
|
pt[2] + fwd[2] * dx + perp[2] * dy,
|
|
]
|
|
}
|
|
|
|
/// Transform a named linetype shape (from the converted `ltypeshp` LFF font)
|
|
/// into world-space strokes at the pen position.
|
|
fn emit_shape(
|
|
name: &str,
|
|
insert: [f32; 3],
|
|
fwd: [f32; 3],
|
|
perp: [f32; 3],
|
|
scale: f32,
|
|
rot_deg: f32,
|
|
) -> Vec<Vec<[f32; 3]>> {
|
|
let shape = match lff::shape(name) {
|
|
Some(s) => s,
|
|
None => return vec![],
|
|
};
|
|
|
|
let rot_r = rot_deg.to_radians();
|
|
let (cos_r, sin_r) = (rot_r.cos(), rot_r.sin());
|
|
|
|
shape
|
|
.strokes
|
|
.iter()
|
|
.map(|stroke| {
|
|
stroke
|
|
.iter()
|
|
.map(|&[lx, ly]| {
|
|
let scaled_x = lx * scale;
|
|
let scaled_y = ly * scale;
|
|
// Rotate in the shape's local frame, then place along the
|
|
// line tangent (fwd) and perpendicular (perp).
|
|
let along_fwd = cos_r * scaled_x - sin_r * scaled_y;
|
|
let along_perp = sin_r * scaled_x + cos_r * scaled_y;
|
|
[
|
|
insert[0] + fwd[0] * along_fwd + perp[0] * along_perp,
|
|
insert[1] + fwd[1] * along_fwd + perp[1] * along_perp,
|
|
insert[2] + fwd[2] * along_fwd + perp[2] * along_perp,
|
|
]
|
|
})
|
|
.collect()
|
|
})
|
|
.collect()
|
|
}
|