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>
This commit is contained in:
Hakan Seven 2026-07-17 02:59:11 +03:00
commit 5bd683cf4e
41 changed files with 263 additions and 14 deletions

View file

@ -1236,6 +1236,7 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task<Message> {
let far_neg = base - dir * far;
let guide = crate::scene::WireModel {
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
name: "__polar_guide__".into(),
points: vec![
@ -1271,6 +1272,7 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task<Message> {
let far_neg = h.base - h.dir * far;
previews.push(crate::scene::WireModel {
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
name: "__otrack_guide__".into(),
points: vec![

View file

@ -1353,6 +1353,7 @@ fn tessellate_dimension_inner(
if !ext1.is_empty() {
wires.push(WireModel {
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
name: name.clone(),
points: ext1,
@ -1376,6 +1377,7 @@ fn tessellate_dimension_inner(
if !ext2.is_empty() {
wires.push(WireModel {
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
name: name.clone(),
points: ext2,
@ -1399,6 +1401,7 @@ fn tessellate_dimension_inner(
} else {
wires.push(WireModel {
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
name: name.clone(),
points: geom.ext_lines,
@ -1423,6 +1426,7 @@ fn tessellate_dimension_inner(
wires.push(WireModel {
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
name: name.clone(),
points: geom.dim_lines,
@ -1463,6 +1467,7 @@ fn tessellate_dimension_inner(
};
wires.push(WireModel {
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
name: name.clone(),
points: vec![],

View file

@ -590,6 +590,7 @@ impl LeaderTess for Leader {
if verts.len() < 2 {
return WireModel {
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
name,
points: vec![],
@ -693,6 +694,7 @@ impl LeaderTess for Leader {
WireModel {
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
name,
points,

View file

@ -1400,6 +1400,7 @@ impl MultiLeaderTess for MultiLeader {
let mut wires: Vec<WireModel> = Vec::new();
wires.push(WireModel {
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
name: name.clone(),
points,
@ -1684,6 +1685,7 @@ impl MultiLeaderTess for MultiLeader {
}
wires.push(WireModel {
dash_from_start: false,
dash_align_end: None,
text_verts: sdf_verts,
name: name.clone(),
points: vec![],
@ -1748,6 +1750,7 @@ impl MultiLeaderTess for MultiLeader {
];
wires.push(WireModel {
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
name: name.clone(),
points: vec![],
@ -1780,6 +1783,7 @@ impl MultiLeaderTess for MultiLeader {
];
wires.push(WireModel {
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
name,
points: frame_points,

View file

@ -749,6 +749,7 @@ pub fn tessellate_table(
|color: [f32; 4], points: Vec<[f32; 3]>, fill_tris: Vec<[f32; 3]>, lw: f32| -> WireModel {
WireModel {
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
name: name.clone(),
points,

View file

@ -187,6 +187,7 @@ impl CadCommand for AlignedDimensionCommand {
let p2 = p2.as_vec3();
Some(WireModel {
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
name: "dimaligned_preview".into(),
points: vec![[p1.x, p1.y, p1.z], [p2.x, p2.y, p2.z]],
@ -235,6 +236,7 @@ fn preview_aligned(p1: DVec3, p2: DVec3, dim_pt: DVec3) -> WireModel {
let d2 = d2.as_vec3();
WireModel {
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
name: "dimaligned_preview".into(),
points: vec![

View file

@ -131,6 +131,7 @@ fn v3(pt: DVec3) -> Vector3 {
fn preview_wire(points: Vec<Vec3>) -> WireModel {
WireModel {
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
name: "dimangular_preview".to_string(),
points: points.into_iter().map(|p| [p.x, p.y, p.z]).collect(),

View file

@ -181,6 +181,7 @@ fn v3(p: DVec3) -> Vector3 {
fn preview_line(a: Vec3, b: Vec3) -> WireModel {
WireModel {
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
name: "dimdia_preview".into(),
points: vec![[a.x, a.y, a.z], [b.x, b.y, b.z]],

View file

@ -180,6 +180,7 @@ impl CadCommand for DimBaselineCommand {
let pt = pt.as_vec3();
Some(WireModel {
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
name: "dimbase_preview".into(),
points: vec![

View file

@ -160,6 +160,7 @@ impl CadCommand for DimContinueCommand {
let dim_line_pt2 = pt + perp * (dim_line_perp - pt.dot(perp));
Some(WireModel {
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
name: "dimcont_preview".into(),
points: vec![

View file

@ -86,6 +86,7 @@ impl CadCommand for DimJogLineCommand {
let d = 0.3_f32;
Some(WireModel {
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
name: "dimjog_preview".into(),
// Jog zigzag in the XY drawing plane (Z is elevation, ~0). The old

View file

@ -101,6 +101,7 @@ impl CadCommand for DimTeditCommand {
let d = 0.2_f32;
Some(WireModel {
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
name: "dimtedit_preview".into(),
// Marker box in the XY drawing plane (Z is elevation, ~0). The old

View file

@ -164,6 +164,7 @@ fn preview_wire(pts: &[Vec3]) -> WireModel {
}
WireModel {
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
name: "leader_preview".into(),
points,

View file

@ -219,6 +219,7 @@ fn v3(pt: DVec3) -> Vector3 {
fn preview_wire(points: Vec<DVec3>) -> WireModel {
WireModel {
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
name: "dimlinear_preview".to_string(),
points: points

View file

@ -151,6 +151,7 @@ fn preview_wire(pts: &[Vec3]) -> WireModel {
}
WireModel {
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
name: "mleader_preview".into(),
points,

View file

@ -459,6 +459,7 @@ impl CadCommand for MLeaderCollectCommand {
fn preview_wire(pts: &[Vec3]) -> WireModel {
WireModel {
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
name: "mleader_edit_preview".into(),
points: pts.iter().map(|p| [p.x, p.y, p.z]).collect(),

View file

@ -122,6 +122,7 @@ fn ordinate_elbow(feature: DVec3, leader: DVec3, is_x: bool) -> DVec3 {
fn preview_wire(points: Vec<Vec3>) -> WireModel {
WireModel {
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
name: "dimordinate_preview".into(),
points: points.into_iter().map(|p| [p.x, p.y, p.z]).collect(),

View file

@ -91,6 +91,7 @@ impl CadCommand for QdimCommand {
let d = 0.5_f32;
Some(WireModel {
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
name: "qdim_preview".into(),
points: vec![[pt.x - d * 3.0, pt.y, pt.z], [pt.x + d * 3.0, pt.y, pt.z]],

View file

@ -186,6 +186,7 @@ fn v3(pt: DVec3) -> Vector3 {
fn preview_wire(points: Vec<Vec3>) -> WireModel {
WireModel {
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
name: "dimradius_preview".to_string(),
points: points.into_iter().map(|p| [p.x, p.y, p.z]).collect(),

View file

@ -140,6 +140,7 @@ impl CadCommand for TableCommand {
let z = pt.z;
Some(WireModel {
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
name: "table_preview".into(),
points: vec![

View file

@ -85,6 +85,7 @@ impl CadCommand for ToleranceCommand {
let d = 0.15_f32;
Some(WireModel {
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
name: "tolerance_preview".into(),
points: vec![

View file

@ -161,6 +161,7 @@ impl CadCommand for AttdefCommand {
let d = 0.15_f32;
Some(WireModel {
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
name: "attdef_preview".into(),
points: vec![

View file

@ -137,6 +137,7 @@ impl CadCommand for MlineCommand {
pts.push([pt.x, pt.y, pt.z]);
Some(WireModel {
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
name: "mline_preview".into(),
points: pts,

View file

@ -109,6 +109,7 @@ impl CadCommand for ImageCommand {
Some(WireModel {
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
name: "image_preview".into(),
points: vec![p0, p1, p2, p3, p0],

View file

@ -85,6 +85,7 @@ impl CadCommand for RayCommand {
let far = base + dir * DISPLAY_EXTENT;
Some(WireModel {
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
name: "ray_preview".into(),
points: vec![[base.x, base.y, base.z], [far.x, far.y, far.z]],
@ -177,6 +178,7 @@ impl CadCommand for XLineCommand {
let far_neg = base - dir * DISPLAY_EXTENT;
Some(WireModel {
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
name: "xline_preview".into(),
points: vec![

View file

@ -90,6 +90,7 @@ impl CadCommand for RevCloudCommand {
]);
Some(WireModel {
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
name: "revcloud_preview".into(),
points: preview_pts,

View file

@ -115,6 +115,7 @@ impl CadCommand for WipeoutCommand {
let max = p1.max(pt);
Some(WireModel {
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
name: "wipeout_preview".into(),
points: vec![
@ -158,6 +159,7 @@ impl CadCommand for WipeoutCommand {
]);
Some(WireModel {
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
name: "wipeout_poly_preview".into(),
points: pts,

View file

@ -70,6 +70,7 @@ impl CadCommand for AreaCommand {
pts.push(f(self.points[0]));
Some(WireModel {
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
name: "area_preview".into(),
points: pts,

View file

@ -64,6 +64,7 @@ impl CadCommand for DistCommand {
// The preview wire is purely visual, so f32 vertices are fine here.
Some(WireModel {
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
name: "dist_preview".into(),
points: vec![

View file

@ -68,6 +68,7 @@ impl MeasureGeomCommand {
fn preview_wire(name: &str, pts: Vec<[f32; 3]>) -> WireModel {
WireModel {
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
name: name.to_string(),
points: pts,

View file

@ -3060,6 +3060,7 @@ fn collect_runs(pts: &[[f64; 2]], take: &dyn Fn([f64; 2]) -> bool, out: &mut Vec
fn preview_wire(points: Vec<[f32; 3]>, color: [f32; 4], name: &str) -> WireModel {
WireModel {
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
name: name.into(),
points,

View file

@ -82,6 +82,7 @@ impl CadCommand for MviewCommand {
let c1 = self.corner1?;
Some(WireModel {
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
name: "mview_preview".to_string(),
points: vec![

View file

@ -306,6 +306,7 @@ inventory::submit!(crate::command::CommandRegistration {
fn wire(name: &str, points: Vec<[f32; 3]>) -> WireModel {
WireModel {
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
name: name.into(),
points,

View file

@ -49,6 +49,7 @@ impl CadCommand for PlotWindowCommand {
// Draw the selection rectangle.
Some(WireModel {
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
name: "plotwindow_preview".into(),
points: vec![

View file

@ -66,6 +66,7 @@ impl CadCommand for ZoomWindowCommand {
// Draw a rectangle preview
Some(WireModel {
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
name: "zoom_window_preview".into(),
points: vec![

View file

@ -827,6 +827,7 @@ impl Batches {
let color = crate::scene::view::render::adapt_to_bg(b.color, bg_color);
WireModel {
dash_from_start: false,
dash_align_end: None,
text_verts: b.text_verts,
name: name.to_string(),
points: b.points,

View file

@ -482,6 +482,7 @@ pub(crate) fn tessellate_entity(
);
let marker = WireModel {
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
name: h.value().to_string(),
points: vec![],
@ -693,6 +694,9 @@ pub(crate) fn tessellate_entity(
entity_color,
sel,
base.line_weight_px,
// Single (non-MLINE) entity: keep the from-start tiling (no shared
// A-type reference — there are no sibling elements to align with).
None,
);
if !wires.is_empty() {
for w in &mut wires {
@ -856,6 +860,7 @@ fn lod_stub_wire(
let stored_color = if selected { WireModel::SELECTED } else { color };
WireModel {
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
name,
// Diagonal of the entity's 3D AABB so depth tests against
@ -920,6 +925,7 @@ fn lod_stub_wire_3d(
let stored_color = if selected { WireModel::SELECTED } else { color };
WireModel {
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
name,
points,

View file

@ -226,6 +226,20 @@ pub fn tessellate(
m.vertices[0].position.y,
m.vertices[0].position.z,
];
// Centre-line (vertex path) length — the shared "A"-type reference so
// every parallel element uses the same end-dash length and thus the same
// interior phase (perpendicular dashes line up). f64 deltas so it stays
// precise at UTM coordinates.
let ref_total: f32 = {
let mut acc = 0.0_f64;
for w in m.vertices.windows(2) {
let dx = w[1].position.x - w[0].position.x;
let dy = w[1].position.y - w[0].position.y;
let dz = w[1].position.z - w[0].position.z;
acc += (dx * dx + dy * dy + dz * dz).sqrt();
}
acc as f32
};
let mut out: Vec<WireModel> = Vec::with_capacity(lines.len());
let mut snap_attached = false;
for l in lines {
@ -261,9 +275,29 @@ pub fn tessellate(
// Selection forces a plain solid highlight, so skip dashing then.
let clt = if selected {
None
} else {
} else if let Some(doc_seg) =
crate::io::linetypes::document_lt_segments(document, &l.linetype)
.or_else(|| crate::io::linetypes::complex_lt(&l.linetype).cloned())
{
// In-document linetype: CPU-expand (apply_along) only when it
// embeds TEXT glyphs that must be laid out along the curve. Pure
// dash / space / dot (and undrawn shape) elements fall through to
// the GPU dash shader below — cheaper (one WireModel + pattern
// instead of N CPU segments) and now UTM-precise, so they stay in
// phase with any glyph-bearing sibling element.
if doc_seg
.segments
.iter()
.any(|s| matches!(s, crate::io::linetypes::LtSegment::Text { .. }))
{
Some(doc_seg)
} else {
None
}
} else {
// Not in the document: bundled-catalog linetype (may embed
// text / shape) → keep the CPU path; `resolve_pattern` below can't
// see it, so GPU-dashing would drop the pattern to solid.
crate::io::linetypes::complex_lt(&l.linetype).cloned()
};
let mut elem_wires: Vec<WireModel> = if let Some(clt) = clt {
@ -288,6 +322,10 @@ pub fn tessellate(
wcolor,
selected,
line_weight_px,
// Shared "A"-type reference: this text-bearing element aligns
// with the GPU-dashed sibling elements (same centre-line
// reference) instead of tiling independently from the start.
Some(ref_total),
);
for wm in &mut w {
wm.aci = aci;
@ -312,8 +350,32 @@ pub fn tessellate(
lt_scale,
)
};
// Shared "A"-type: derive the begin/end solid-dash length ONCE
// from the multiline centre-line (`ref_total`) so every parallel
// element runs the same interior phase and its dashes line up
// perpendicular; `align_total` stays each element's own length in
// the shader so each still ends on a dash. Dash-first patterns
// only (`+dash, -gap, …`); shorter-than-a-period lines fall back
// to the per-wire path (solid).
let dash_align_end = if pattern_length > 1e-6
&& pattern[0] > 0.0
&& pattern[1] < 0.0
&& ref_total > pattern_length
{
let a = pattern[0];
let p = pattern_length;
let k = ((ref_total - a) / p).round().max(1.0);
Some(((ref_total - k * p + a) * 0.5).max(1e-4))
} else {
None
};
elem_wires.push(WireModel {
// 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)
// while each still ends on a dash at its own endpoint.
dash_from_start: false,
dash_align_end,
text_verts: Vec::new(),
name: name.clone(),
points: pts,
@ -589,6 +651,7 @@ pub fn tessellate(
}
wires.push(WireModel {
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
name: name.clone(),
points: vec![],
@ -623,6 +686,7 @@ pub fn tessellate(
}
wires.push(WireModel {
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
name: name.clone(),
points: fp,
@ -664,6 +728,7 @@ pub fn tessellate(
}
wires.push(WireModel {
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
name: name.clone(),
points: pts,
@ -686,6 +751,7 @@ pub fn tessellate(
}
wires.push(WireModel {
dash_from_start: false,
dash_align_end: None,
text_verts: sdf_verts,
name,
points: Vec::new(),
@ -738,6 +804,7 @@ pub fn tessellate(
};
out.push(WireModel {
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
name: name.clone(),
points: bin.pts,
@ -772,6 +839,7 @@ pub fn tessellate(
};
out.push(WireModel {
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
name: name.clone(),
points: Vec::new(),
@ -803,6 +871,7 @@ pub fn tessellate(
if !sdf_verts.is_empty() {
out.push(WireModel {
dash_from_start: false,
dash_align_end: None,
text_verts: sdf_verts,
name: name.clone(),
points: Vec::new(),
@ -827,6 +896,7 @@ pub fn tessellate(
if out.is_empty() {
out.push(WireModel {
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
name,
points: Vec::new(),
@ -873,6 +943,7 @@ pub fn tessellate(
.collect();
return vec![WireModel {
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
name,
points: vec![
@ -918,6 +989,7 @@ pub fn tessellate(
.collect();
return vec![WireModel {
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
name,
points,
@ -952,6 +1024,7 @@ pub fn tessellate(
.collect();
return vec![WireModel {
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
name,
points,
@ -1005,6 +1078,7 @@ pub fn tessellate(
};
out.push(WireModel {
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
name: name.clone(),
points: local_pts,
@ -1038,6 +1112,7 @@ pub fn tessellate(
};
out.push(WireModel {
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
name: name.clone(),
points: Vec::new(),
@ -1062,6 +1137,7 @@ pub fn tessellate(
if out.is_empty() {
out.push(WireModel {
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
name,
points: Vec::new(),
@ -1096,6 +1172,7 @@ pub fn tessellate(
.collect();
return vec![WireModel {
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
name,
points: local_pts,
@ -1161,6 +1238,7 @@ pub fn tessellate(
snap_pts.into_iter().map(|(p, h)| (p.as_dvec3(), h)).collect();
vec![WireModel {
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
name,
points,

View file

@ -79,6 +79,13 @@ pub struct WireModel {
/// from the START vertex with continuous phase and no A-type end forcing —
/// DGN line styles are not end-aligned.
pub dash_from_start: bool,
/// Shared "A"-type end-dash length for MLINE elements. `Some(len)` makes the
/// dash shader use `len` as the begin/end solid-dash length for EVERY
/// parallel element (derived once from the multiline's centre-line length),
/// while `align_total` stays each element's own length — so all elements
/// share one interior phase (perpendicular dashes line up) yet each still
/// ends on a dash. `None` (the default) = per-wire A-type / from-start.
pub dash_align_end: Option<f32>,
/// Paper-space bounding box [x0, y0, x1, y1] for GPU scissor clipping.
/// Set only for viewport-projected wires in paper-space layouts.
pub vp_scissor: Option<[f32; 4]>,
@ -158,6 +165,7 @@ impl WireModel {
aabb: Self::UNBOUNDED_AABB,
plinegen: true,
dash_from_start: false,
dash_align_end: None,
vp_scissor: None,
fill_tris: vec![],
fill_tris_low: Vec::new(),
@ -367,6 +375,7 @@ impl Default for WireModel {
aabb: Self::UNBOUNDED_AABB,
plinegen: true,
dash_from_start: false,
dash_align_end: None,
vp_scissor: None,
fill_tris: Vec::new(),
fill_tris_low: Vec::new(),

View file

@ -258,22 +258,30 @@ fn wire_distances(wire: &WireModel) -> (Vec<f32>, f32, f32) {
let n = wire.points.len();
let mut dists = vec![0.0_f32; n];
let mut has_break = false;
// Accumulate arc-length in f64 from double-single deltas (high + low). An
// f32-high-only delta `q[0] - p[0]` quantises ~0.1 at UTM coordinates
// (1.2M), which shifts the dash phase and drifts parallel lines out of sync
// — the reason MLINE elements were CPU-dashed. `points_low` may be empty for
// non-RTE wires → treat the low half as zero (same as the old behaviour).
let mut acc = 0.0_f64;
for i in 1..n {
let p = wire.points[i - 1];
let q = wire.points[i];
if !p[0].is_finite() || !q[0].is_finite() {
has_break = true;
// plinegen=false: reset to 0 at the first real point after a NaN separator.
dists[i] = if !wire.plinegen && !p[0].is_finite() && q[0].is_finite() {
0.0
} else {
dists[i - 1]
};
if !wire.plinegen && !p[0].is_finite() && q[0].is_finite() {
acc = 0.0;
}
dists[i] = acc as f32;
} else {
let dx = q[0] - p[0];
let dy = q[1] - p[1];
let dz = q[2] - p[2];
dists[i] = dists[i - 1] + (dx * dx + dy * dy + dz * dz).sqrt();
let pl = wire.points_low.get(i - 1).copied().unwrap_or([0.0; 3]);
let ql = wire.points_low.get(i).copied().unwrap_or([0.0; 3]);
let dx = (q[0] as f64 - p[0] as f64) + (ql[0] as f64 - pl[0] as f64);
let dy = (q[1] as f64 - p[1] as f64) + (ql[1] as f64 - pl[1] as f64);
let dz = (q[2] as f64 - p[2] as f64) + (ql[2] as f64 - pl[2] as f64);
acc += (dx * dx + dy * dy + dz * dz).sqrt();
dists[i] = acc as f32;
}
}
@ -295,6 +303,20 @@ fn wire_distances(wire: &WireModel) -> (Vec<f32>, f32, f32) {
return (dists, 0.0, 0.0);
}
// Shared "A"-type for MLINE elements: the caller fixes the begin/end
// solid-dash length (`dash_align_end`, derived once from the multiline's
// centre-line length) so every parallel element runs the SAME interior phase
// — the shader's interior walk depends on `align_end`, not on the wire's own
// length — while `align_total` stays this wire's own length, so each element
// still ends on a dash at its own endpoint.
if let Some(ae) = wire.dash_align_end {
if total <= pat_len {
// Shorter than one full period → solid (matches the per-wire path).
return (dists, total, total);
}
return (dists, ae.clamp(1e-4, total * 0.5), total);
}
// Align only a proper alternating pattern that BEGINS with a dash followed
// by a gap — every standard linetype does (DASHED/DASHDOT/CENTER/HIDDEN/…).
// Gap-first, dot-first, single-element, or consecutive-dash patterns keep

View file

@ -18,6 +18,55 @@ use crate::scene::model::wire_model::WireModel;
/// 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]],
@ -26,6 +75,12 @@ pub fn apply_along(
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![];
@ -67,6 +122,26 @@ pub fn apply_along(
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
@ -76,7 +151,7 @@ pub fn apply_along(
let mut elem_idx: usize = 0;
let mut elem_consumed: f32 = 0.0;
for i in 0..path_pts.len() - 1 {
'path: for i in 0..path_pts.len() - 1 {
let ps = path_pts[i];
let pe = path_pts[i + 1];
@ -94,9 +169,18 @@ pub fn apply_along(
let mut pos = 0.0f32;
while pos < seg_len - 1e-6 {
let idx = elem_idx % scaled.len();
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 &scaled[idx] {
match &walk_segs[idx] {
LtSeg::Dash(dash_len) => {
let remaining_dash = dash_len - elem_consumed;
let remaining_seg = seg_len - pos;
@ -222,6 +306,7 @@ pub fn apply_along(
.filter(|s| s.len() >= 2)
.map(|pts| WireModel {
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
name: name.to_string(),
points: pts,
@ -258,6 +343,7 @@ pub fn apply_along(
}
out.push(WireModel {
dash_from_start: false,
dash_align_end: None,
text_verts,
name: name.to_string(),
points: Vec::new(),