// Wire shader — renders 1-D CAD entities as screen-aligned quads. // Topology: TriangleList, 6 vertices drawn per INSTANCE. // // One instance = one segment. The six vertex IDs map to the corners of a // two-triangle quad; the vertex shader derives `which_end` (0=A end, 1=B end) // and `side` (±1 perpendicular) from `@builtin(vertex_index)` and expands the // quad by `half_width` pixels perpendicular to the segment direction in // screen space. // // Linetype is applied entirely on the GPU: // • distance = cumulative arc-length, linearly interpolated from // (distance_a, distance_b) by `which_end`. // • pattern_length > 0 enables the dash test; 0 = solid (no discard). // • pat0/pat1 encode up to 8 elements: positive=dash, negative=gap, // exactly 0=dot (rendered as a fixed ~1 px mark). Trailing 0.0 slots are // padding; the real element count is (index of last non-zero) + 1. struct Uniforms { viewport_size: vec2, world_per_pixel: f32, // LWDISPLAY toggle: 0.0 = force 1 px (half_width 0.5), 1.0 = use the // per-instance baked half_width. Lets the LWT button switch without // retessellating. lwdisplay_enable: f32, // Mesh flat-shade flag (unused here; kept so the field offsets match // the shared Uniforms buffer layout). flat_shade: f32, // Transparency-display toggle: 1.0 = honour baked alpha, 0.0 = force // every line opaque. transparency_enable: f32, linetype_scale: f32, lineweight_scale: f32, // ── Relative-to-eye (double-single) ────────────────────────────────── // view_rot is the rotation-only view-projection; vertices subtract the eye // (eye_high + eye_low, two f32 emulating f64) before transforming, so the // large eye translation never enters the f32 matrix → no large-coordinate // jitter on pan / zoom / rotate. view_rot: mat4x4, eye_high: vec3, _pad_eh: f32, eye_low: vec3, _pad_el: f32, } @group(0) @binding(0) var u: Uniforms; // Scalars are packed into vec4 attributes: WebGL2 / WebGPU cap vertex // attributes at 16, and the unpacked layout had grown to 17 — the wire // pipeline failed to build and the web viewport drew no lines at all (#414). struct InstanceIn { @location(0) pos_a: vec3, @location(1) pos_b: vec3, @location(2) color: vec4, // distance_a, distance_b, half_width, pattern_length @location(3) dists: vec4, @location(4) pat0: vec4, @location(5) pat1: vec4, // draw_depth, align_end ("A"-type end-dash length), align_total (total // wire length), world_half_width (wide-polyline band; 0 = normal wire) @location(6) misc: vec4, // Double-single low residuals of the endpoints. @location(7) pos_a_low: vec3, @location(8) pos_b_low: vec3, // Per-endpoint world half-width for a tapered band (0 = use the constant). @location(9) taper: vec2, @location(10) marker_origin_high: vec4, @location(11) marker_origin_low: vec4, @location(12) marker_normal_scale: vec4, } const MODEL_LINEWEIGHT_BOOST: f32 = 2.0; const MODEL_LINEWEIGHT_MAX_PX: f32 = 10.0; struct VertexOut { @builtin(position) clip_pos: vec4, @location(0) color: vec4, @location(1) distance: f32, @location(2) pattern_length: f32, @location(3) pat0: vec4, @location(4) pat1: vec4, // World length of the smallest non-zero dash / gap element of this // instance. Flat-interpolated (constant per instance) so the // fragment stage can short-circuit the dash test when every gap // 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, // Round-cap support: (along, across) of this fragment in screen pixels, // where `along` runs -hw_a … seg_len+hw_b over the extended quad and // `across` is the signed distance from the centreline. @location(8) cap: vec2, // (segment pixel length, end half-width at A, end half-width at B). @location(9) @interpolate(flat) cap_ends: vec3, } // Half-width of one segment end: a tapered band's own end width wins, then a // constant world-unit band, then the screen-pixel lineweight (LWDISPLAY off // collapses to a hairline). fn resolve_hw(taper: f32, world_hw: f32, px_hw: f32) -> f32 { if taper >= 0.0 { return max(taper / u.world_per_pixel, 0.5); } if world_hw > 0.0 { return max(world_hw / u.world_per_pixel, 0.5); } if world_hw < 0.0 { return max(-world_hw, 0.5); } var display_hw = max(px_hw * u.lineweight_scale, 0.5); if u.lineweight_scale < 0.0 { let scale = -u.lineweight_scale; let base_hw = select( min(px_hw * MODEL_LINEWEIGHT_BOOST, MODEL_LINEWEIGHT_MAX_PX * 0.5), 0.5, px_hw <= 0.5, ); display_hw = max(base_hw * scale, 0.5); } return select(0.5, display_hw, u.lwdisplay_enable > 0.5); } fn marker_relative(position_high: vec3, position_low: vec3, instance: InstanceIn) -> vec3 { if instance.marker_normal_scale.w <= 0.0 { return (position_high - u.eye_high) + (position_low - u.eye_low); } let origin_high = instance.marker_origin_high.xyz; let origin_low = instance.marker_origin_low.xyz; let origin_relative = (origin_high - u.eye_high) + (origin_low - u.eye_low); let delta = (position_high - origin_high) + (position_low - origin_low); let normal = normalize(instance.marker_normal_scale.xyz); let axial = normal * dot(delta, normal); let planar = delta - axial; let origin_clip = u.view_rot * vec4(origin_relative, 1.0); let projection_scale = max(length(vec3( u.view_rot[0].y, u.view_rot[1].y, u.view_rot[2].y, )), 1e-12); let view_height = 2.0 * max(abs(origin_clip.w), 1e-6) / projection_scale; let world_size = instance.marker_normal_scale.w * 0.01 * view_height; return origin_relative + axial + planar * world_size; } @vertex fn vs_main(@builtin(vertex_index) vid: u32, in: InstanceIn) -> VertexOut { // Two-triangle quad corner table: // vid 0,1,2 = (A,-1) (B,-1) (B,+1) // vid 3,4,5 = (A,-1) (B,+1) (A,+1) let which_end_arr = array(0.0, 1.0, 1.0, 0.0, 1.0, 0.0); let side_arr = array(-1.0, -1.0, 1.0, -1.0, 1.0, 1.0); let which_end = which_end_arr[vid]; let side = side_arr[vid]; // Double-single relative-to-eye: subtract the eye from each endpoint with // both halves of the f64-emulating pair, then transform by the rotation-only // view-projection. (pos_high − eye_high) is exact in f32 for same-magnitude // operands (Sterbenz); adding (pos_low − eye_low) restores the residual both // the vertex and the eye would otherwise lose — so geometry stays put at // UTM-scale coordinates and after a cross-drawing paste, with no jitter. let rel_a = marker_relative(in.pos_a, in.pos_a_low, in); let rel_b = marker_relative(in.pos_b, in.pos_b_low, in); let is_world = in.misc.w > 0.0 || in.taper.x >= 0.0 || in.taper.y >= 0.0; var final_clip: vec4; var out_dist: f32; var out_cap: vec2; var out_cap_ends: vec3; if is_world { // Wide polylines have physical world-unit width (in.misc.w or in.taper > 0). // Expand the quad in 3D world space on the entity's plane (perpendicular to // the segment and the plane normal), so the rectangle remains hosted rigidly // on the 3D plane when the camera rotates, orbits, or tilts, matching CAD // behavior and circle.wgsl planar arcs. let world_hw_a = select(in.misc.w, in.taper.x, in.taper.x >= 0.0); let world_hw_b = select(in.misc.w, in.taper.y, in.taper.y >= 0.0); let cur_world_hw = mix(world_hw_a, world_hw_b, which_end); let eff_hw = max(cur_world_hw, 0.5 * u.world_per_pixel); let world_delta = rel_b - rel_a; let world_len = length(world_delta); var world_dir = vec3(1.0, 0.0, 0.0); if world_len > 1e-6 { world_dir = world_delta / world_len; } var norm = vec3(0.0, 0.0, 1.0); if length(in.marker_normal_scale.xyz) > 1e-4 { norm = normalize(in.marker_normal_scale.xyz); } var perp_world = cross(norm, world_dir); if length(perp_world) < 1e-4 { perp_world = cross(vec3(0.0, 1.0, 0.0), world_dir); if length(perp_world) < 1e-4 { perp_world = cross(vec3(1.0, 0.0, 0.0), world_dir); } } perp_world = normalize(perp_world); let pos_rel = mix(rel_a, rel_b, which_end); let world_pos = pos_rel + perp_world * (eff_hw * side); var clip_pos = u.view_rot * vec4(world_pos, 1.0); clip_pos = apply_draw_order(clip_pos, in.misc.x); final_clip = clip_pos; out_dist = mix(in.dists.x, in.dists.y, which_end); out_cap = vec2(which_end * world_len, eff_hw * side); out_cap_ends = vec3(world_len, world_hw_a, world_hw_b); } else { // Thin wires expand in screen pixels facing the camera with rounded end caps / joints. let clip_a = u.view_rot * vec4(rel_a, 1.0); let clip_b = u.view_rot * vec4(rel_b, 1.0); let ndc_a = clip_a.xy / clip_a.w; let ndc_b = clip_b.xy / clip_b.w; let screen_a = ndc_a * u.viewport_size * 0.5; let screen_b = ndc_b * u.viewport_size * 0.5; let seg = screen_b - screen_a; let seg_len = length(seg); var dir: vec2; if seg_len > 1e-4 { dir = seg / seg_len; } else { dir = vec2(1.0, 0.0); } let perp = vec2(-dir.y, dir.x); let clip_pos = mix(clip_a, clip_b, which_end); let hw = resolve_hw(-1.0, in.misc.w, in.dists.z); let ext = which_end * 2.0 - 1.0; let offset_px = perp * hw * side + dir * hw * ext; let ndc_offset = offset_px / (u.viewport_size * 0.5); var clip_pos_out = clip_pos + vec4(ndc_offset * clip_pos.w, 0.0, 0.0); clip_pos_out = apply_draw_order(clip_pos_out, in.misc.x); final_clip = clip_pos_out; out_dist = mix(in.dists.x, in.dists.y, which_end) + ext * hw * u.world_per_pixel; out_cap = vec2(which_end * seg_len + ext * hw, hw * side); out_cap_ends = vec3(seg_len, hw, hw); } // Smallest non-zero dash / gap element, in world units. Used by // the fragment stage to decide when the pattern's finest feature // would render below one pixel and should collapse to a solid line. let lt_scale = u.linetype_scale; var min_elem: f32 = in.dists.w * lt_scale; let elems = array( in.pat0.x * lt_scale, in.pat0.y * lt_scale, in.pat0.z * lt_scale, in.pat0.w * lt_scale, in.pat1.x * lt_scale, in.pat1.y * lt_scale, in.pat1.z * lt_scale, in.pat1.w * lt_scale, ); for (var i = 0u; i < 8u; i++) { let e = abs(elems[i]); if e > 0.0 && e < min_elem { min_elem = e; } } var out: VertexOut; out.clip_pos = final_clip; out.color = in.color; out.distance = out_dist; out.cap = out_cap; out.cap_ends = out_cap_ends; out.pattern_length = in.dists.w * lt_scale; out.pat0 = in.pat0 * lt_scale; out.pat1 = in.pat1 * lt_scale; out.min_elem = min_elem; out.align_end = in.misc.y * lt_scale; out.align_total = in.misc.z; 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, 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. 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; // 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 { // Dot centred at `pos` (zero length); light a small mark, wrapped // around the pattern so a dot at 0 also covers the seam at pat_len. let dd = abs(d - pos); if min(dd, pat_len - dd) <= dot_half { return true; } } else if elem > 0.0 { if d >= pos && d < pos + elem { return true; } // inside a dash pos += elem; } else { pos += -elem; // skip a gap } } return false; } // Round the cap overhang off: outside the segment span only pixels within // the end's half-width radius survive, giving round joints and end caps. fn cap_clipped(cap: vec2, cap_ends: vec3) -> bool { if cap.x < 0.0 { return length(cap) > cap_ends.y; } if cap.x > cap_ends.x { return length(vec2(cap.x - cap_ends.x, cap.y)) > cap_ends.z; } return false; } @fragment fn fs_main(in: VertexOut) -> @location(0) vec4 { // Negative pattern length is the persistent-arena tombstone sentinel. // Discard before cap/alpha work so deleted slabs cannot write color/depth. if in.pattern_length < 0.0 { discard; } if cap_clipped(in.cap, in.cap_ends) { discard; } if in.pattern_length > 0.0 { // LOD: once the pattern's smallest feature drops below ~1 px // on screen, dash gaps alias / shimmer (or vanish completely) // and the user reads the line as solid anyway. Skip the dash // 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, in.align_end, in.align_total) { discard; } } } // Transparency display off → force the line opaque. let alpha = select(1.0, in.color.a, u.transparency_enable > 0.5); return vec4(in.color.rgb, alpha); } // Black variant: used for 3D mesh outline edges in filled render modes so the // mesh reads as a shaded surface framed by black edges. Keeps the dash/LOD // logic identical to `fs_main`; only the RGB is forced to black. @fragment fn fs_black(in: VertexOut) -> @location(0) vec4 { if in.pattern_length < 0.0 { discard; } if cap_clipped(in.cap, in.cap_ends) { discard; } 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, in.align_end, in.align_total) { discard; } } } let alpha = select(1.0, in.color.a, u.transparency_enable > 0.5); return vec4(0.0, 0.0, 0.0, alpha); }