diff --git a/src/scene/convert/tessellate.rs b/src/scene/convert/tessellate.rs index 143dde897..829b20cf1 100644 --- a/src/scene/convert/tessellate.rs +++ b/src/scene/convert/tessellate.rs @@ -282,7 +282,53 @@ fn split_mixed_polyline( end_angle, } = *tg { + let (sw, ew) = if let Some(&(w0, w1)) = seg_widths.get(i) { + (w0, w1) + } else if global_world_width > 1e-9 { + (global_world_width, global_world_width) + } else { + (0.0, 0.0) + }; + + // Check if the CCW start_angle of this TangentGeom::Arc corresponds + // to the segment's end vertex (clockwise bulge arc in CAD where + // bulge_arc_to_tangent inverted start_angle and end_angle to enforce CCW). + let (p_seg_start, p_seg_end) = if i + 1 < key_vertices.len() { + (key_vertices[i], key_vertices[i + 1]) + } else if !key_vertices.is_empty() { + ( + key_vertices[i % key_vertices.len()], + key_vertices[(i + 1) % key_vertices.len()], + ) + } else { + ([0.0; 3], [0.0; 3]) + }; + + let p_sa = [ + center[0] + radius * (start_angle.cos() * axis_x[0] + start_angle.sin() * axis_y[0]), + center[1] + radius * (start_angle.cos() * axis_x[1] + start_angle.sin() * axis_y[1]), + center[2] + radius * (start_angle.cos() * axis_x[2] + start_angle.sin() * axis_y[2]), + ]; + let is_reversed = if !key_vertices.is_empty() { + let d_start_sq = (p_sa[0] - p_seg_start[0]).powi(2) + + (p_sa[1] - p_seg_start[1]).powi(2) + + (p_sa[2] - p_seg_start[2]).powi(2); + let d_end_sq = (p_sa[0] - p_seg_end[0]).powi(2) + + (p_sa[1] - p_seg_end[1]).powi(2) + + (p_sa[2] - p_seg_end[2]).powi(2); + d_end_sq < d_start_sq + } else { + false + }; + + let (w_at_sa, w_at_ea) = if is_reversed { + (ew, sw) + } else { + (sw, ew) + }; + let mut arc_pts = Vec::with_capacity(17); + let mut arc_widths = Vec::with_capacity(17); let n = 16; let sweep = if end_angle >= start_angle { end_angle - start_angle @@ -298,25 +344,12 @@ fn split_mixed_polyline( center[2] + radius * (ang.cos() * axis_x[2] + ang.sin() * axis_y[2]), ]; arc_pts.push(p); + arc_widths.push(w_at_sa + (w_at_ea - w_at_sa) * frac as f32); } let (points, points_low) = points_to_ds(arc_pts); - let (sw, ew) = if let Some(&(w0, w1)) = seg_widths.get(i) { - if w0 > 1e-9 || w1 > 1e-9 { - (w0, w1) - } else if global_world_width > 1e-9 { - (global_world_width, global_world_width) - } else { - (0.0, 0.0) - } - } else if global_world_width > 1e-9 { - (global_world_width, global_world_width) - } else { - (0.0, 0.0) - }; - - let taper_widths = if (sw - ew).abs() > 1e-6 { - vec![sw, ew] + let taper_widths = if (w_at_sa - w_at_ea).abs() > 1e-6 { + arc_widths } else { Vec::new() }; @@ -367,43 +400,77 @@ fn split_mixed_polyline( // Collect straight lines into a line wire let mut straight_pts: Vec<[f64; 3]> = Vec::new(); + let mut straight_widths: Vec = Vec::new(); let mut straight_tangents: Vec = Vec::new(); let mut last_end: Option<[f64; 3]> = None; + let mut last_end_w: f32 = 0.0; for (i, tg) in tangent_geoms.iter().enumerate() { if let TangentGeom::Line { p1, p2 } = tg { straight_tangents.push(tg.clone()); - let (p_start, p_end) = if i + 1 < key_vertices.len() { - (key_vertices[i], key_vertices[i + 1]) + let p_start = if i + 1 < key_vertices.len() + && (key_vertices[i][0] as f32 - p1[0]).abs() < 1e-3 + && (key_vertices[i][1] as f32 - p1[1]).abs() < 1e-3 + && (key_vertices[i][2] as f32 - p1[2]).abs() < 1e-3 + { + key_vertices[i] } else { - ( - [p1[0] as f64, p1[1] as f64, p1[2] as f64], - [p2[0] as f64, p2[1] as f64, p2[2] as f64], - ) + [p1[0] as f64, p1[1] as f64, p1[2] as f64] + }; + let p_end = if i + 1 < key_vertices.len() + && (key_vertices[i + 1][0] as f32 - p2[0]).abs() < 1e-3 + && (key_vertices[i + 1][1] as f32 - p2[1]).abs() < 1e-3 + && (key_vertices[i + 1][2] as f32 - p2[2]).abs() < 1e-3 + { + key_vertices[i + 1] + } else { + [p2[0] as f64, p2[1] as f64, p2[2] as f64] + }; + let (sw, ew) = if let Some(&(w0, w1)) = seg_widths.get(i) { + (w0, w1) + } else if global_world_width > 1e-9 { + (global_world_width, global_world_width) + } else { + (0.0, 0.0) }; if !plinegen { if !straight_pts.is_empty() { straight_pts.push([f64::NAN; 3]); + straight_widths.push(0.0); } straight_pts.push(p_start); + straight_widths.push(sw); straight_pts.push(p_end); + straight_widths.push(ew); } else { if let Some(prev) = last_end { if (prev[0] - p_start[0]).abs() < 1e-7 && (prev[1] - p_start[1]).abs() < 1e-7 && (prev[2] - p_start[2]).abs() < 1e-7 + && (last_end_w - sw).abs() < 1e-6 { straight_pts.push(p_end); + straight_widths.push(ew); } else { straight_pts.push([f64::NAN; 3]); + straight_widths.push(0.0); straight_pts.push(p_start); + straight_widths.push(sw); straight_pts.push(p_end); + straight_widths.push(ew); } } else { + if !straight_pts.is_empty() { + straight_pts.push([f64::NAN; 3]); + straight_widths.push(0.0); + } straight_pts.push(p_start); + straight_widths.push(sw); straight_pts.push(p_end); + straight_widths.push(ew); } last_end = Some(p_end); + last_end_w = ew; } } else { last_end = None; @@ -417,12 +484,24 @@ fn split_mixed_polyline( } else { (Vec::new(), Vec::new()) }; + let line_world_width = straight_widths + .iter() + .copied() + .fold(0.0f32, f32::max); + let has_line_taper = straight_widths + .first() + .map_or(false, |&w0| straight_widths.iter().any(|&w| (w - w0).abs() > 1e-6)); + let taper_widths = if has_line_taper || straight_widths.iter().any(|&w| w > 1e-6) { + straight_widths + } else { + Vec::new() + }; out.push(WireModel { bg_adapt: None, point_marker, - taper_widths: Vec::new(), + taper_widths, pattern_stations: Vec::new(), - world_width: global_world_width, + world_width: line_world_width, depth_override: None, display_visible: true, plot_visible: true, @@ -2979,3 +3058,176 @@ pub(crate) fn normalized_or(v: Vec3, fallback: Vec3) -> Vec3 { v.normalize() } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_split_mixed_polyline_straight_lines_have_taper_widths() { + let tg_line = TangentGeom::Line { + p1: [0.0, 0.0, 0.0], + p2: [10.0, 0.0, 0.0], + }; + let tg_arc = TangentGeom::Arc { + center: [10.0, 5.0, 0.0], + axis_x: [1.0, 0.0, 0.0], + axis_y: [0.0, 1.0, 0.0], + radius: 5.0, + start_angle: -std::f64::consts::FRAC_PI_2, + end_angle: 0.0, + }; + let tgs = vec![tg_line, tg_arc]; + let kv = vec![[0.0, 0.0, 0.0], [10.0, 0.0, 0.0], [15.0, 5.0, 0.0]]; + let seg_widths = vec![(0.0f32, 10.0f32), (10.0f32, 20.0f32)]; + + let wires = split_mixed_polyline( + &tgs, + &kv, + "mixed", + [1.0, 1.0, 1.0, 1.0], + false, + 0.0, + [0.0; 8], + 1.0, + Vec::new(), + None, + true, + &seg_widths, + 20.0, + Vec::new(), + Vec::new(), + ); + + // Arc wire + Line wire + assert_eq!(wires.len(), 2); + let arc_wire = &wires[0]; + assert_eq!(arc_wire.taper_widths.first().copied(), Some(10.0)); + assert_eq!(arc_wire.taper_widths.last().copied(), Some(20.0)); + + let line_wire = &wires[1]; + assert_eq!(line_wire.taper_widths, vec![0.0, 10.0]); + assert_eq!(line_wire.world_width, 10.0); + } + + #[test] + fn test_split_mixed_polyline_zero_width_segments_after_tapered_segment() { + let tg_line = TangentGeom::Line { + p1: [0.0, 0.0, 0.0], + p2: [10.0, 0.0, 0.0], + }; + let tg_arc1 = TangentGeom::Arc { + center: [10.0, 5.0, 0.0], + axis_x: [1.0, 0.0, 0.0], + axis_y: [0.0, 1.0, 0.0], + radius: 5.0, + start_angle: -std::f64::consts::FRAC_PI_2, + end_angle: 0.0, + }; + let tg_arc2 = TangentGeom::Arc { + center: [15.0, 10.0, 0.0], + axis_x: [1.0, 0.0, 0.0], + axis_y: [0.0, 1.0, 0.0], + radius: 5.0, + start_angle: -std::f64::consts::FRAC_PI_2, + end_angle: 0.0, + }; + let tgs = vec![tg_line, tg_arc1, tg_arc2]; + let kv = vec![ + [0.0, 0.0, 0.0], + [10.0, 0.0, 0.0], + [15.0, 5.0, 0.0], + [20.0, 10.0, 0.0], + ]; + // Segment 0 has width (2.0, 0.0), segment 1 and 2 have width (0.0, 0.0) + let seg_widths = vec![(2.0f32, 0.0f32), (0.0f32, 0.0f32), (0.0f32, 0.0f32)]; + + let wires = split_mixed_polyline( + &tgs, + &kv, + "taper_then_zero", + [1.0, 1.0, 1.0, 1.0], + false, + 0.0, + [0.0; 8], + 1.0, + Vec::new(), + None, + true, + &seg_widths, + 2.0, + Vec::new(), + Vec::new(), + ); + + // 2 arc wires + 1 line wire = 3 wires + assert_eq!(wires.len(), 3); + let arc1 = &wires[0]; + let arc2 = &wires[1]; + let line = &wires[2]; + + // Arcs must have world_width = 0.0 and empty taper_widths + assert_eq!(arc1.world_width, 0.0); + assert!(arc1.taper_widths.is_empty()); + assert_eq!(arc2.world_width, 0.0); + assert!(arc2.taper_widths.is_empty()); + + let inst1 = crate::scene::pipeline::circle_gpu::extract_circle_instance(arc1, 0.0) + .expect("arc1 instance"); + assert_eq!(inst1.start_width, 0.0); + assert_eq!(inst1.params[3], 0.0); + + // Straight line must have world_width = 2.0 and taper_widths = [2.0, 0.0] + assert_eq!(line.world_width, 2.0); + assert_eq!(line.taper_widths, vec![2.0, 0.0]); + } + + #[test] + fn test_split_mixed_polyline_reversed_arc_aligns_endpoints() { + // Clockwise arc from (10.0, 0.0) to (15.0, 5.0). + // bulge_arc_to_tangent inverts angles so start_angle is at (15.0, 5.0) and end_angle at (10.0, 0.0). + let tg_arc_reversed = TangentGeom::Arc { + center: [10.0, 5.0, 0.0], + axis_x: [1.0, 0.0, 0.0], + axis_y: [0.0, 1.0, 0.0], + radius: 5.0, + start_angle: 0.0, // at (15.0, 5.0) - physical end! + end_angle: -std::f64::consts::FRAC_PI_2, // at (10.0, 0.0) - physical start! + }; + let tgs = vec![tg_arc_reversed]; + let kv = vec![[10.0, 0.0, 0.0], [15.0, 5.0, 0.0]]; + let seg_widths = vec![(5.0f32, 25.0f32)]; // sw=5.0 at start, ew=25.0 at end + + let wires = split_mixed_polyline( + &tgs, + &kv, + "arc_rev", + [1.0, 1.0, 1.0, 1.0], + false, + 0.0, + [0.0; 8], + 1.0, + Vec::new(), + None, + true, + &seg_widths, + 25.0, + Vec::new(), + Vec::new(), + ); + + assert_eq!(wires.len(), 1); + let arc_wire = &wires[0]; + // In CCW shader angles, start_angle is at (15.0, 5.0) which is physical end (ew = 25.0). + // end_angle is at (10.0, 0.0) which is physical start (sw = 5.0). + assert_eq!(arc_wire.taper_widths.first().copied(), Some(25.0)); + assert_eq!(arc_wire.taper_widths.last().copied(), Some(5.0)); + + let inst = crate::scene::pipeline::circle_gpu::extract_circle_instance(arc_wire, 0.0) + .expect("arc instance extracted"); + // start_width is at start_angle (physical end, 25.0) + assert_eq!(inst.start_width, 25.0); + // params.w (end_width) is at end_angle (physical start, 5.0) + assert_eq!(inst.params[3], 5.0); + } +} diff --git a/src/scene/pipeline/circle_gpu.rs b/src/scene/pipeline/circle_gpu.rs index f38b46605..f5e898cd5 100644 --- a/src/scene/pipeline/circle_gpu.rs +++ b/src/scene/pipeline/circle_gpu.rs @@ -258,7 +258,7 @@ pub fn extract_circle_instance_from_geom_indexed( let (start_width, end_width) = if !wire.taper_widths.is_empty() { if wire.taper_widths.len() >= 2 && wire.tangent_geoms.len() == 1 { - (wire.taper_widths[0], wire.taper_widths[1]) + (wire.taper_widths[0], *wire.taper_widths.last().unwrap()) } else if wire.taper_widths.len() > geom_index { let sw = wire.taper_widths[geom_index]; let ew = wire.taper_widths.get(geom_index + 1).copied().unwrap_or(sw); diff --git a/src/scene/pipeline/wire_gpu.rs b/src/scene/pipeline/wire_gpu.rs index e3901ee3d..d71c15d58 100644 --- a/src/scene/pipeline/wire_gpu.rs +++ b/src/scene/pipeline/wire_gpu.rs @@ -116,7 +116,7 @@ pub struct WireConst { /// the quad by `world_half_width / world_per_pixel` so the band tracks zoom /// in drawing units. pub world_half_width: f32, - pub _pad1: f32, + pub is_tapered: f32, pub _pad2: f32, /// Point-marker origin as a double-single pair. `marker_normal_scale.w` /// stores the viewport-height percentage; zero disables marker scaling. @@ -592,7 +592,14 @@ pub(crate) fn emit_wire_packed( let (dists, align_end, align_total) = wire_distances(wire); let (marker_origin_high, marker_origin_low, marker_normal_scale) = marker_metadata(wire); let low = |i: usize| -> [f32; 3] { wire.points_low.get(i).copied().unwrap_or([0.0; 3]) }; - let tw = |i: usize| -> f32 { wire.taper_widths.get(i).copied().unwrap_or(0.0) * 0.5 }; + let is_tapered = !wire.taper_widths.is_empty(); + let tw = |i: usize| -> f32 { + if is_tapered { + wire.taper_widths.get(i).copied().unwrap_or(0.0) * 0.5 + } else { + -1.0 + } + }; let mut instances: Vec = Vec::with_capacity(seg_count); for i in 0..seg_count { let a = wire.points[i]; @@ -636,6 +643,7 @@ pub(crate) fn emit_wire_native( ) -> (Vec, WireConst) { let (dists, align_end, align_total) = wire_distances(wire); let (marker_origin_high, marker_origin_low, marker_normal_scale) = marker_metadata(wire); + let is_tapered = !wire.taper_widths.is_empty(); let cst = WireConst { color, pat0: [wire.pattern[0], wire.pattern[1], wire.pattern[2], wire.pattern[3]], @@ -646,7 +654,7 @@ pub(crate) fn emit_wire_native( align_end, align_total, world_half_width: wire.world_width * 0.5, - _pad1: 0.0, + is_tapered: if is_tapered { 1.0 } else { 0.0 }, _pad2: 0.0, marker_origin_high, marker_origin_low, @@ -658,18 +666,12 @@ pub(crate) fn emit_wire_native( return (Vec::new(), cst); } let low = |i: usize| -> [f32; 3] { wire.points_low.get(i).copied().unwrap_or([0.0; 3]) }; - // Store an endpoint/max-width ratio. The shared f32 maximum keeps drawing - // units and range out of the packed field; UNORM16 contributes only a - // relative error below 1/65535. Preserve zero as the existing constant - // width fallback sentinel. let taper_ratio = |i: usize| -> u16 { - let width = wire.taper_widths.get(i).copied().unwrap_or(0.0); - if width <= 0.0 || wire.world_width <= 0.0 { + if !is_tapered || wire.world_width <= 0.0 { 0 } else { - ((width / wire.world_width).clamp(0.0, 1.0) * u16::MAX as f32) - .round() - .max(1.0) as u16 + let width = wire.taper_widths.get(i).copied().unwrap_or(0.0); + ((width / wire.world_width).clamp(0.0, 1.0) * u16::MAX as f32).round() as u16 } }; let mut instances: Vec = Vec::with_capacity(seg_count); diff --git a/src/shaders/block_wire.wgsl b/src/shaders/block_wire.wgsl index d70301806..ba3adb899 100644 --- a/src/shaders/block_wire.wgsl +++ b/src/shaders/block_wire.wgsl @@ -24,7 +24,7 @@ struct WireConst { align_end: f32, align_total: f32, world_half_width: f32, - _pad1: f32, + is_tapered: f32, _pad2: f32, marker_origin_high: vec4, marker_origin_low: vec4, @@ -121,7 +121,8 @@ fn vs_main(@builtin(vertex_index) vertex_index: u32, in: VertexIn) -> VertexOut let rel_a = marker_relative(in.pos_a, in.pos_a_low, in.translation, in.translation_low); let rel_b = marker_relative(in.pos_b, in.pos_b_low, in.translation, in.translation_low); - let is_world = wire_const.world_half_width > 0.0 || in.taper_ratio.x > 0.0 || in.taper_ratio.y > 0.0; + let is_tapered = wire_const.is_tapered > 0.5; + let is_world = wire_const.world_half_width > 0.0; var final_clip: vec4; var out_dist: f32; @@ -134,8 +135,8 @@ fn vs_main(@builtin(vertex_index) vertex_index: u32, in: VertexIn) -> VertexOut // 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(wire_const.world_half_width, in.taper_ratio.x * wire_const.world_half_width, in.taper_ratio.x > 0.0); - let world_hw_b = select(wire_const.world_half_width, in.taper_ratio.y * wire_const.world_half_width, in.taper_ratio.y > 0.0); + let world_hw_a = select(wire_const.world_half_width, in.taper_ratio.x * wire_const.world_half_width, is_tapered); + let world_hw_b = select(wire_const.world_half_width, in.taper_ratio.y * wire_const.world_half_width, is_tapered); 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); diff --git a/src/shaders/block_wire_storage.wgsl b/src/shaders/block_wire_storage.wgsl index 3b3544e22..ea952bca2 100644 --- a/src/shaders/block_wire_storage.wgsl +++ b/src/shaders/block_wire_storage.wgsl @@ -43,7 +43,7 @@ struct WireConst { align_end: f32, align_total: f32, world_half_width: f32, - _pad1: f32, + is_tapered: f32, _pad2: f32, marker_origin_high: vec4, marker_origin_low: vec4, @@ -152,7 +152,8 @@ fn vs_main(@builtin(vertex_index) vertex_index: u32, in: VertexIn) -> VertexOut let rel_a = marker_relative(seg_pos_a, seg_pos_a_low, in.translation, in.translation_low); let rel_b = marker_relative(seg_pos_b, seg_pos_b_low, in.translation, in.translation_low); - let is_world = wire_const.world_half_width > 0.0 || seg_taper_ratio.x > 0.0 || seg_taper_ratio.y > 0.0; + let is_tapered = wire_const.is_tapered > 0.5; + let is_world = wire_const.world_half_width > 0.0; var final_clip: vec4; var out_dist: f32; @@ -165,8 +166,8 @@ fn vs_main(@builtin(vertex_index) vertex_index: u32, in: VertexIn) -> VertexOut // 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(wire_const.world_half_width, seg_taper_ratio.x * wire_const.world_half_width, seg_taper_ratio.x > 0.0); - let world_hw_b = select(wire_const.world_half_width, seg_taper_ratio.y * wire_const.world_half_width, seg_taper_ratio.y > 0.0); + let world_hw_a = select(wire_const.world_half_width, seg_taper_ratio.x * wire_const.world_half_width, is_tapered); + let world_hw_b = select(wire_const.world_half_width, seg_taper_ratio.y * wire_const.world_half_width, is_tapered); 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); diff --git a/src/shaders/circle.wgsl b/src/shaders/circle.wgsl index 22b0973f8..43d157286 100644 --- a/src/shaders/circle.wgsl +++ b/src/shaders/circle.wgsl @@ -207,32 +207,48 @@ fn in_dash(dist: f32, pat_len: f32, p0: vec4, p1: vec4) -> bool { let is_full_or_in_sweep = (sweep >= TAU - 1e-5) || (d_theta <= sweep); if in.is_world > 0.5 { - // Physical world-unit width (thick analytical circular arcs, donuts, tapered segments). - // Distance-to-edge calculations are strictly performed in the circle plane's world units - // so that 3D perspective projection yields exact concentric ellipses with invariant - // physical stroke widths across any camera tilt or acute perspective angle. - if is_full_or_in_sweep { - let t = select(0.0, clamp(d_theta / sweep, 0.0, 1.0), sweep > 1e-5); - let world_hw = mix(in.hw_ends.x, in.hw_ends.y, t); - let eff_hw = max(world_hw, 0.5 * fw); + let is_full = sweep >= TAU - 1e-5; + if is_full { + let eff_hw = max(in.hw_ends.x, 0.5 * fw); let d_world = abs(r - in.radius); let delta_px = (eff_hw - d_world) / fw; let cov = clamp(0.5 + delta_px, 0.0, 1.0); - let subpixel_fade = min(1.0, (2.0 * world_hw) / fw); + let subpixel_fade = min(1.0, (2.0 * in.hw_ends.x) / fw); alpha_cov = cov * subpixel_fade; arc_dist = in.radius * d_theta; } else { - // Outside sweep: evaluate distance to start and end endpoints for smooth round end caps - let p_start = in.radius * vec2(cos(sa), sin(sa)); - let p_end = in.radius * vec2(cos(ea), sin(ea)); - let d_start = length(in.local_pos - p_start); - let d_end = length(in.local_pos - p_end); - let eff_hw_a = max(in.hw_ends.x, 0.5 * fw); - let eff_hw_b = max(in.hw_ends.y, 0.5 * fw); - let cov_start = clamp(0.5 + (eff_hw_a - d_start) / fw, 0.0, 1.0) * min(1.0, (2.0 * in.hw_ends.x) / fw); - let cov_end = clamp(0.5 + (eff_hw_b - d_end) / fw, 0.0, 1.0) * min(1.0, (2.0 * in.hw_ends.y) / fw); - alpha_cov = max(cov_start, cov_end); - arc_dist = select(in.radius * sweep, 0.0, cov_start >= cov_end); + // Flat (radial) end caps perpendicular to arc tangent at start_angle and end_angle. + let d_start_rad = r * d_theta; + let d_end_rad = r * (sweep - d_theta); + let d_before_sa = r * mod_tau(sa - theta); + let d_after_ea = r * mod_tau(theta - ea); + + var cov_angular: f32 = 0.0; + var world_hw: f32 = 0.0; + + if is_full_or_in_sweep { + let t = select(0.0, clamp(d_theta / sweep, 0.0, 1.0), sweep > 1e-5); + world_hw = mix(in.hw_ends.x, in.hw_ends.y, t); + let d_inside = min(d_start_rad, d_end_rad); + cov_angular = clamp(0.5 + d_inside / fw, 0.0, 1.0); + arc_dist = in.radius * d_theta; + } else { + let d_outside = min(d_before_sa, d_after_ea); + if d_outside <= 0.5 * fw { + cov_angular = clamp(0.5 - d_outside / fw, 0.0, 1.0); + world_hw = select(in.hw_ends.y, in.hw_ends.x, d_before_sa <= d_after_ea); + arc_dist = select(in.radius * sweep, 0.0, d_before_sa <= d_after_ea); + } + } + + if cov_angular > 0.0 { + let eff_hw = max(world_hw, 0.5 * fw); + let d_world = abs(r - in.radius); + let delta_px = (eff_hw - d_world) / fw; + let cov_radial = clamp(0.5 + delta_px, 0.0, 1.0); + let subpixel_fade = min(1.0, (2.0 * world_hw) / fw); + alpha_cov = cov_radial * cov_angular * subpixel_fade; + } } } else { // Lineweight stroke (screen-pixel width) diff --git a/src/shaders/wire.wgsl b/src/shaders/wire.wgsl index afa7a0d75..f1622b691 100644 --- a/src/shaders/wire.wgsl +++ b/src/shaders/wire.wgsl @@ -102,7 +102,7 @@ struct VertexOut { // 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 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); } var display_hw = max(px_hw * u.lineweight_scale, 0.5); if u.lineweight_scale < 0.0 { @@ -156,7 +156,7 @@ fn marker_relative(position_high: vec3, position_low: vec3, instance: // 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; + 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; @@ -169,8 +169,8 @@ fn marker_relative(position_high: vec3, position_low: vec3, instance: // 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 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); diff --git a/src/shaders/wire_indexed.wgsl b/src/shaders/wire_indexed.wgsl index d739d6e15..a18ee51b6 100644 --- a/src/shaders/wire_indexed.wgsl +++ b/src/shaders/wire_indexed.wgsl @@ -32,7 +32,7 @@ struct WireConst { align_end: f32, align_total: f32, world_half_width: f32, - _pad1: f32, + is_tapered: f32, _pad2: f32, marker_origin_high: vec4, marker_origin_low: vec4, @@ -128,7 +128,8 @@ fn marker_relative(position_high: vec3, position_low: vec3, c: WireCon let rel_a = marker_relative(in.pos_a, in.pos_a_low, c); let rel_b = marker_relative(in.pos_b, in.pos_b_low, c); - let is_world = c.world_half_width > 0.0 || in.taper_ratio.x > 0.0 || in.taper_ratio.y > 0.0; + let is_tapered = c.is_tapered > 0.5; + let is_world = c.world_half_width > 0.0; var final_clip: vec4; var out_dist: f32; @@ -141,8 +142,8 @@ fn marker_relative(position_high: vec3, position_low: vec3, c: WireCon // 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(c.world_half_width, in.taper_ratio.x * c.world_half_width, in.taper_ratio.x > 0.0); - let world_hw_b = select(c.world_half_width, in.taper_ratio.y * c.world_half_width, in.taper_ratio.y > 0.0); + let world_hw_a = select(c.world_half_width, in.taper_ratio.x * c.world_half_width, is_tapered); + let world_hw_b = select(c.world_half_width, in.taper_ratio.y * c.world_half_width, is_tapered); 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);