fix(linetype): A-type endpoint alignment so dashed lines end on a dash

Dashed/dash-dot lines could end in a gap ("empty space at the end"),
especially inside paperspace viewports. The dash pattern was only centred
on each wire, which makes the two ends symmetric but does not force them
onto a solid dash.

Implement AutoCAD "A" (aligned) linetypes: the line begins AND ends on a
solid dash, interior dashes stay nominal, and the two end dashes stretch
symmetrically to absorb the remainder (so parallel lines share an
identical interior). wire_distances computes the end-dash length D and
total length L for a dash-first pattern (D = (L - k*P + a) / 2); the GPU
in_dash walk force-lights the two end regions and phases the interior to
resume right after the start dash. Lines shorter than one pattern period
draw solid.

Only alternating dash-first patterns are aligned; gap/dot-first,
consecutive-dash, NaN-break (plinegen=false) and solid lines keep the
prior behaviour. Native and web (wasm) shaders kept in sync.

Refs #13.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 4.8 committed 2026-07-13 23:50:25 +03:00
1 parent c0844ad55c
commit 9b1a495549
3 files changed
+162 -50

No files matched your search

+94 -33
View File
@@ -104,8 +104,9 @@ impl WireInstance {
}
/// Per-wire constants shared by every segment of a wire (native only). std430
/// layout: three vec4 then four scalars = 64 B, matching `WireConst` in
/// wire_indexed.wgsl.
/// layout: three vec4 then eight scalars = 80 B, matching `WireConst` in
/// wire_indexed.wgsl. `align_end` / `align_total` carry the "A"-type endpoint
/// alignment (see `wire_distances`); 0.0 total = no alignment.
#[cfg(not(target_arch = "wasm32"))]
#[repr(C)]
#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
@@ -116,7 +117,11 @@ pub struct WireConst {
pub half_width: f32,
pub pattern_length: f32,
pub draw_depth: f32,
pub _pad: f32,
pub align_end: f32,
pub align_total: f32,
pub _pad0: f32,
pub _pad1: f32,
pub _pad2: f32,
}
#[cfg(not(target_arch = "wasm32"))]
@@ -162,6 +167,10 @@ pub struct WireInstance {
/// Normalized draw-order depth in (0,1); applied as a small clip-z bias
/// in the shader so this wire orders against other entity types.
pub draw_depth: f32,
/// "A"-type endpoint alignment (see `wire_distances`): the end-dash length
/// and the total wire length. `align_total == 0.0` = not aligned.
pub align_end: f32,
pub align_total: f32,
}
#[cfg(target_arch = "wasm32")]
@@ -183,6 +192,8 @@ impl WireInstance {
wgpu::VertexAttribute { offset: std::mem::offset_of!(WireInstance, draw_depth) as u64, shader_location: 9, format: wgpu::VertexFormat::Float32 },
wgpu::VertexAttribute { offset: std::mem::offset_of!(WireInstance, pos_a_low) as u64, shader_location: 10, format: wgpu::VertexFormat::Float32x3 },
wgpu::VertexAttribute { offset: std::mem::offset_of!(WireInstance, pos_b_low) as u64, shader_location: 11, format: wgpu::VertexFormat::Float32x3 },
wgpu::VertexAttribute { offset: std::mem::offset_of!(WireInstance, align_end) as u64, shader_location: 12, format: wgpu::VertexFormat::Float32 },
wgpu::VertexAttribute { offset: std::mem::offset_of!(WireInstance, align_total) as u64, shader_location: 13, format: wgpu::VertexFormat::Float32 },
];
wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<WireInstance>() as u64,
@@ -226,9 +237,24 @@ fn pack_color(color: [f32; 4]) -> [u8; 4] {
]
}
/// Cumulative arc-length per point (NaN-break aware), with the dash pattern
/// centred on the wire. Shared by the wasm and native emission paths.
fn wire_distances(wire: &WireModel) -> Vec<f32> {
/// Cumulative arc-length per point (NaN-break aware) plus the `"A"`-type
/// alignment pair `(align_end, align_total)`. Shared by the wasm and native
/// emission paths.
///
/// AutoCAD-style linetypes are implicitly `A` (aligned): a dashed line must
/// begin AND end on a solid dash, keeping the interior dashes at their nominal
/// length and stretching/shrinking only the two end dashes symmetrically to
/// absorb the leftover (so parallel lines share an identical interior). We
/// express that on the GPU by handing the shader the total wire length
/// (`align_total`) and the end-dash length (`align_end`); the pattern walk then
/// forces the two end regions lit and phases the interior to resume right after
/// the first dash. `align_total == 0.0` disables it and the shader falls back to
/// the legacy centred repeating pattern.
///
/// Alignment applies only to a single continuous run (`!has_break`) whose
/// pattern begins with a dash. NaN-separated (plinegen=false) polylines and
/// non-dash-first patterns keep the legacy centred phase.
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;
@@ -251,32 +277,61 @@ fn wire_distances(wire: &WireModel) -> Vec<f32> {
}
}
// Center the dash pattern on the wire instead of starting it at the first
// vertex. The shader reads the pattern phase as `dist % pattern_length`, so
// adding a constant offset to every arc-length shifts that phase. We place
// the wire midpoint at the center of the first dash element, which makes the
// line begin and end with matching partial dashes. Skipped for wires with
// NaN breaks (per-segment dash restarts), where a single offset can't center
// every segment.
let pat_len = wire.pattern_length;
if pat_len > 1e-6 && !has_break && n >= 2 {
let total = dists[n - 1];
if total > 1e-6 {
// First dash element (positive), else fall back to the first element.
let first_dash = wire
.pattern
.iter()
.copied()
.find(|&v| v > 0.0)
.unwrap_or_else(|| wire.pattern[0].abs());
// Phase that puts the wire midpoint at the dash center.
let offset = first_dash * 0.5 + total * 0.5;
for d in dists.iter_mut() {
*d += offset;
}
}
if pat_len <= 1e-6 || has_break || n < 2 {
return (dists, 0.0, 0.0);
}
dists
let total = dists[n - 1];
if total <= 1e-6 {
return (dists, 0.0, 0.0);
}
// 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
// the legacy centred phase: the A-type interior-resume assumes the element
// after the leading dash is a gap, and force-lighting an end dash on a
// non-dash-start would paint over a leading blank.
if wire.pattern[0] > 0.0 && wire.pattern[1] < 0.0 {
let a = wire.pattern[0];
let p = pat_len;
if total <= p {
// Shorter than one full pattern period → drawn as a single solid
// dash spanning the whole line (aligned linetypes can't fit a
// dash-gap-dash in less than one period).
return (dists, total, total);
}
// "A" alignment for a dash-first pattern of period P laid out as
// [D] [gap] ([dash] [gap])*(k-1) [D]
// gives L = 2D + (k-1)a + k(P-a) => D = (L - k*P + a) / 2.
// Pick the interior period count k so the end dash D stays near nominal a.
let mut k = ((total - a) / p).round().max(1.0);
let mut d_end = (total - k * p + a) * 0.5;
if d_end <= 1e-4 {
// End dash underflowed (period ≫ first dash); drop one period so the
// ends stay visible.
k = (k - 1.0).max(0.0);
d_end = (total - k * p + a) * 0.5;
}
let d_end = d_end.clamp(1e-4, total * 0.5);
return (dists, d_end, total);
}
// Legacy centred phase for non-aligned patterns (behaviour unchanged from
// before A-type). The shader reads phase as `dist % pattern_length`, so a
// constant offset shifts it; place the wire midpoint at the first dash's
// centre so the two ends stay symmetric.
let first_dash = wire
.pattern
.iter()
.copied()
.find(|&v| v > 0.0)
.unwrap_or_else(|| wire.pattern[0].abs());
let offset = first_dash * 0.5 + total * 0.5;
for d in dists.iter_mut() {
*d += offset;
}
(dists, 0.0, 0.0)
}
#[inline]
@@ -296,7 +351,7 @@ fn emit_wire_instances(wire: &WireModel, color: [f32; 4], draw_depth: f32) -> Ve
if seg_count == 0 {
return Vec::new();
}
let dists = wire_distances(wire);
let (dists, align_end, align_total) = wire_distances(wire);
let low = |i: usize| -> [f32; 3] { wire.points_low.get(i).copied().unwrap_or([0.0; 3]) };
let mut instances: Vec<WireInstance> = Vec::with_capacity(seg_count);
for i in 0..seg_count {
@@ -318,6 +373,8 @@ fn emit_wire_instances(wire: &WireModel, color: [f32; 4], draw_depth: f32) -> Ve
pat0,
pat1,
draw_depth,
align_end,
align_total,
});
}
instances
@@ -332,6 +389,7 @@ fn emit_wire_native(
color: [f32; 4],
draw_depth: f32,
) -> (Vec<WireInstance>, WireConst) {
let (dists, align_end, align_total) = wire_distances(wire);
let cst = WireConst {
color,
pat0: [wire.pattern[0], wire.pattern[1], wire.pattern[2], wire.pattern[3]],
@@ -339,14 +397,17 @@ fn emit_wire_native(
half_width: wire.line_weight_px * 0.5,
pattern_length: wire.pattern_length,
draw_depth,
_pad: 0.0,
align_end,
align_total,
_pad0: 0.0,
_pad1: 0.0,
_pad2: 0.0,
};
let n = wire.points.len();
let seg_count = n.saturating_sub(1);
if seg_count == 0 {
return (Vec::new(), cst);
}
let dists = wire_distances(wire);
let low = |i: usize| -> [f32; 3] { wire.points_low.get(i).copied().unwrap_or([0.0; 3]) };
let mut instances: Vec<WireInstance> = Vec::with_capacity(seg_count);
for i in 0..seg_count {
+35 -10
View File
@@ -56,6 +56,9 @@ struct InstanceIn {
// Double-single low residuals of the endpoints.
@location(10) pos_a_low: vec3<f32>,
@location(11) pos_b_low: vec3<f32>,
// "A"-type endpoint alignment: end-dash length + total wire length.
@location(12) align_end: f32,
@location(13) align_total: f32,
}
// Draw-order depth bias: shifts clip-space z so 2D entities of different
@@ -77,6 +80,8 @@ struct VertexOut {
// projects below one pixel on screen. See the LOD branch in
// `fs_main`.
@location(5) @interpolate(flat) min_elem: f32,
@location(6) @interpolate(flat) align_end: f32,
@location(7) @interpolate(flat) align_total: f32,
}
@vertex fn vs_main(@builtin(vertex_index) vid: u32, in: InstanceIn) -> VertexOut {
@@ -153,18 +158,13 @@ struct VertexOut {
out.pat0 = in.pat0;
out.pat1 = in.pat1;
out.min_elem = min_elem;
out.align_end = in.align_end;
out.align_total = in.align_total;
return out;
}
// Returns true if arc-length `dist` falls inside a dash or on a dot.
fn in_dash(dist: f32, pat_len: f32, p0: vec4<f32>, p1: vec4<f32>) -> bool {
let d = ((dist % pat_len) + pat_len) % pat_len;
var pos = 0.0f;
// A dot is a zero-length element: render it as a fixed ~1.5 px mark
// (half-width ~0.75 px in world units) so it stays visible at any zoom
// instead of vanishing with its zero world-length. Mirrors the hatch
// shader's pixel-snapped dot. (#149)
let dot_half = u.world_per_pixel * 0.75;
fn in_dash(dist: f32, pat_len: f32, p0: vec4<f32>, p1: vec4<f32>, align_end: f32, align_total: f32) -> bool {
let elems = array<f32, 8>(p0.x, p0.y, p0.z, p0.w, p1.x, p1.y, p1.z, p1.w);
// Real element count = (index of last non-zero) + 1. Trailing 0.0 slots
// are padding; a 0.0 within this range is a real dot.
@@ -172,6 +172,31 @@ fn in_dash(dist: f32, pat_len: f32, p0: vec4<f32>, p1: vec4<f32>) -> bool {
for (var i = 0u; i < 8u; i++) {
if elems[i] != 0.0 { count = i + 1u; }
}
var d: f32;
if align_total > 0.0 {
// "A"-type alignment: the line begins and ends with a solid dash of
// length `align_end`. Force the two end regions lit, then phase the
// interior so the element AFTER the first dash resumes exactly at
// `align_end` (the interior meets each end dash on a gap boundary).
if dist <= align_end || dist >= align_total - align_end {
return true;
}
var first_dash = 0.0;
for (var i = 0u; i < count; i++) {
if elems[i] > 0.0 { first_dash = elems[i]; break; }
}
d = ((dist - align_end + first_dash) % pat_len + pat_len) % pat_len;
} else {
d = ((dist % pat_len) + pat_len) % pat_len;
}
var pos = 0.0f;
// A dot is a zero-length element: render it as a fixed ~1.5 px mark
// (half-width ~0.75 px in world units) so it stays visible at any zoom
// instead of vanishing with its zero world-length. Mirrors the hatch
// shader's pixel-snapped dot. (#149)
let dot_half = u.world_per_pixel * 0.75;
for (var i = 0u; i < count; i++) {
let elem = elems[i];
if elem == 0.0 {
@@ -197,7 +222,7 @@ fn in_dash(dist: f32, pat_len: f32, p0: vec4<f32>, p1: vec4<f32>) -> bool {
// test and return solid colour — also saves the per-fragment
// arc-length math + `discard`.
if in.min_elem >= u.world_per_pixel {
if !in_dash(in.distance, in.pattern_length, in.pat0, in.pat1) {
if !in_dash(in.distance, in.pattern_length, in.pat0, in.pat1, in.align_end, in.align_total) {
discard;
}
}
@@ -213,7 +238,7 @@ fn in_dash(dist: f32, pat_len: f32, p0: vec4<f32>, p1: vec4<f32>) -> bool {
@fragment fn fs_black(in: VertexOut) -> @location(0) vec4<f32> {
if in.pattern_length > 0.0 {
if in.min_elem >= u.world_per_pixel {
if !in_dash(in.distance, in.pattern_length, in.pat0, in.pat1) {
if !in_dash(in.distance, in.pattern_length, in.pat0, in.pat1, in.align_end, in.align_total) {
discard;
}
}
+33 -7
View File
@@ -28,7 +28,11 @@ struct WireConst {
half_width: f32,
pattern_length: f32,
draw_depth: f32,
_pad: f32,
align_end: f32,
align_total: f32,
_pad0: f32,
_pad1: f32,
_pad2: f32,
}
@group(1) @binding(0) var<storage, read> wire_consts: array<WireConst>;
@@ -52,6 +56,8 @@ struct VertexOut {
@location(3) pat0: vec4<f32>,
@location(4) pat1: vec4<f32>,
@location(5) @interpolate(flat) min_elem: f32,
@location(6) @interpolate(flat) align_end: f32,
@location(7) @interpolate(flat) align_total: f32,
}
@vertex fn vs_main(@builtin(vertex_index) vid: u32, in: InstanceIn) -> VertexOut {
@@ -111,18 +117,38 @@ struct VertexOut {
out.pat0 = c.pat0;
out.pat1 = c.pat1;
out.min_elem = min_elem;
out.align_end = c.align_end;
out.align_total = c.align_total;
return out;
}
fn in_dash(dist: f32, pat_len: f32, p0: vec4<f32>, p1: vec4<f32>) -> bool {
let d = ((dist % pat_len) + pat_len) % pat_len;
var pos = 0.0f;
let dot_half = u.world_per_pixel * 0.75;
fn in_dash(dist: f32, pat_len: f32, p0: vec4<f32>, p1: vec4<f32>, align_end: f32, align_total: f32) -> bool {
let elems = array<f32, 8>(p0.x, p0.y, p0.z, p0.w, p1.x, p1.y, p1.z, p1.w);
var count = 0u;
for (var i = 0u; i < 8u; i++) {
if elems[i] != 0.0 { count = i + 1u; }
}
var d: f32;
if align_total > 0.0 {
// "A"-type alignment: the line begins and ends with a solid dash of
// length `align_end`. Force the two end regions lit, then phase the
// interior so the element AFTER the first dash resumes exactly at
// `align_end` (the interior meets each end dash on a gap boundary).
if dist <= align_end || dist >= align_total - align_end {
return true;
}
var first_dash = 0.0;
for (var i = 0u; i < count; i++) {
if elems[i] > 0.0 { first_dash = elems[i]; break; }
}
d = ((dist - align_end + first_dash) % pat_len + pat_len) % pat_len;
} else {
d = ((dist % pat_len) + pat_len) % pat_len;
}
var pos = 0.0f;
let dot_half = u.world_per_pixel * 0.75;
for (var i = 0u; i < count; i++) {
let elem = elems[i];
if elem == 0.0 {
@@ -141,7 +167,7 @@ fn in_dash(dist: f32, pat_len: f32, p0: vec4<f32>, p1: vec4<f32>) -> bool {
@fragment fn fs_main(in: VertexOut) -> @location(0) vec4<f32> {
if in.pattern_length > 0.0 {
if in.min_elem >= u.world_per_pixel {
if !in_dash(in.distance, in.pattern_length, in.pat0, in.pat1) {
if !in_dash(in.distance, in.pattern_length, in.pat0, in.pat1, in.align_end, in.align_total) {
discard;
}
}
@@ -156,7 +182,7 @@ fn in_dash(dist: f32, pat_len: f32, p0: vec4<f32>, p1: vec4<f32>) -> bool {
@fragment fn fs_black(in: VertexOut) -> @location(0) vec4<f32> {
if in.pattern_length > 0.0 {
if in.min_elem >= u.world_per_pixel {
if !in_dash(in.distance, in.pattern_length, in.pat0, in.pat1) {
if !in_dash(in.distance, in.pattern_length, in.pat0, in.pat1, in.align_end, in.align_total) {
discard;
}
}