diff --git a/src/scene/pipeline/wire_gpu.rs b/src/scene/pipeline/wire_gpu.rs index 708398e77..ad5034242 100644 --- a/src/scene/pipeline/wire_gpu.rs +++ b/src/scene/pipeline/wire_gpu.rs @@ -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::() 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 { +/// 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) { 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 { } } - // 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 = 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, 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 = Vec::with_capacity(seg_count); for i in 0..seg_count { diff --git a/src/shaders/wire.wgsl b/src/shaders/wire.wgsl index 81fcf2d30..c9efdbf23 100644 --- a/src/shaders/wire.wgsl +++ b/src/shaders/wire.wgsl @@ -56,6 +56,9 @@ struct InstanceIn { // Double-single low residuals of the endpoints. @location(10) pos_a_low: vec3, @location(11) pos_b_low: vec3, + // "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, p1: vec4) -> 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, p1: vec4, align_end: f32, align_total: f32) -> bool { let elems = array(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, p1: vec4) -> 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, p1: vec4) -> 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, p1: vec4) -> bool { @fragment fn fs_black(in: VertexOut) -> @location(0) vec4 { 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; } } diff --git a/src/shaders/wire_indexed.wgsl b/src/shaders/wire_indexed.wgsl index c702ffeec..e08a230d8 100644 --- a/src/shaders/wire_indexed.wgsl +++ b/src/shaders/wire_indexed.wgsl @@ -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 wire_consts: array; @@ -52,6 +56,8 @@ struct VertexOut { @location(3) pat0: vec4, @location(4) pat1: vec4, @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, p1: vec4) -> 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, p1: vec4, align_end: f32, align_total: f32) -> bool { let elems = array(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, p1: vec4) -> bool { @fragment fn fs_main(in: VertexOut) -> @location(0) vec4 { 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, p1: vec4) -> bool { @fragment fn fs_black(in: VertexOut) -> @location(0) vec4 { 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; } }