diff --git a/src/modules/draw/draw/donut.rs b/src/modules/draw/draw/donut.rs index fe1fb8412..12d552ba1 100644 --- a/src/modules/draw/draw/donut.rs +++ b/src/modules/draw/draw/donut.rs @@ -253,7 +253,7 @@ fn donut_wire( WireModel::solid_f64("rubber_band".into(), points, WireModel::CYAN, false) } -fn make_donut(cx: f64, cy: f64, elevation: f64, inner_r: f64, outer_r: f64) -> EntityType { +pub(crate) fn make_donut(cx: f64, cy: f64, elevation: f64, inner_r: f64, outer_r: f64) -> EntityType { use acadrust::types::Vector2; let r_avg = (inner_r + outer_r) / 2.0; let width = outer_r - inner_r; diff --git a/src/scene/convert/tessellate.rs b/src/scene/convert/tessellate.rs index cbbfacd7a..8c9a22af5 100644 --- a/src/scene/convert/tessellate.rs +++ b/src/scene/convert/tessellate.rs @@ -202,6 +202,66 @@ pub(crate) fn points_to_ds( (high, low) } +fn polyline_segment_widths(entity: &EntityType) -> Vec<(f32, f32)> { + match entity { + EntityType::LwPolyline(p) => { + let count = p.vertices.len(); + let seg_count = if p.is_closed { + count + } else { + count.saturating_sub(1) + }; + let c = p.constant_width; + (0..seg_count) + .map(|i| { + let v = &p.vertices[i]; + let sw = if v.start_width > 1e-9 { + v.start_width + } else { + c + } as f32; + let ew = if v.end_width > 1e-9 { + v.end_width + } else { + c + } as f32; + (sw, ew) + }) + .collect() + } + EntityType::Polyline2D(p) => { + let filtered = crate::entities::polyline::drawn_vertices2d(p); + let verts: &[acadrust::entities::Vertex2D] = + filtered.as_deref().unwrap_or(&p.vertices); + let count = verts.len(); + let seg_count = if p.is_closed() { + count + } else { + count.saturating_sub(1) + }; + let def_start = p.start_width; + let def_end = p.end_width; + (0..seg_count) + .map(|i| { + let v = &verts[i]; + let sw = if v.start_width > 1e-9 { + v.start_width + } else { + def_start + } as f32; + let ew = if v.end_width > 1e-9 { + v.end_width + } else { + def_end + } as f32; + (sw, ew) + }) + .collect() + } + _ => Vec::new(), + } +} + fn split_mixed_polyline( tangent_geoms: &[TangentGeom], key_vertices: &[[f64; 3]], @@ -214,11 +274,15 @@ fn split_mixed_polyline( snap_pts: Vec<(glam::DVec3, SnapHint)>, point_marker: Option, plinegen: bool, + seg_widths: &[(f32, f32)], + global_world_width: f32, + mut pick_tris: Vec<[f32; 3]>, + mut pick_tris_low: Vec<[f32; 3]>, ) -> Vec { let mut out = Vec::new(); // Emit each arc segment as an analytical wire (CircleGpu target) - for tg in tangent_geoms { + for (i, tg) in tangent_geoms.iter().enumerate() { if let TangentGeom::Arc { center, axis_x, @@ -246,20 +310,48 @@ fn split_mixed_polyline( arc_pts.push(p); } 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] + } else { + Vec::new() + }; + let world_width = sw.max(ew); + + let (arc_pt, arc_ptl) = if out.is_empty() && !pick_tris.is_empty() { + (std::mem::take(&mut pick_tris), std::mem::take(&mut pick_tris_low)) + } else { + (Vec::new(), Vec::new()) + }; + out.push(WireModel { bg_adapt: None, point_marker: None, - taper_widths: Vec::new(), + taper_widths, pattern_stations: Vec::new(), - world_width: 0.0, + world_width, depth_override: None, display_visible: true, plot_visible: true, fill_is_3d: false, fill_is_2d_solid: false, render_instance: None, - pick_tris: Vec::new(), - pick_tris_low: Vec::new(), + pick_tris: arc_pt, + pick_tris_low: arc_ptl, dash_from_start: false, dash_align_end: None, text_verts: Vec::new(), @@ -330,20 +422,25 @@ fn split_mixed_polyline( let (line_pts, line_pts_low) = points_to_ds(straight_pts); if !line_pts.is_empty() { + let (pt, ptl) = if !pick_tris.is_empty() { + (pick_tris, pick_tris_low) + } else { + (Vec::new(), Vec::new()) + }; out.push(WireModel { bg_adapt: None, point_marker, taper_widths: Vec::new(), pattern_stations: Vec::new(), - world_width: 0.0, + world_width: global_world_width, depth_override: None, display_visible: true, plot_visible: true, fill_is_3d: false, fill_is_2d_solid: false, render_instance: None, - pick_tris: Vec::new(), - pick_tris_low: Vec::new(), + pick_tris: pt, + pick_tris_low: ptl, dash_from_start: false, dash_align_end: None, text_verts: Vec::new(), @@ -367,6 +464,10 @@ fn split_mixed_polyline( } else if let Some(first_arc) = out.first_mut() { first_arc.snap_pts = snap_pts; first_arc.key_vertices = key_vertices.to_vec(); + if first_arc.pick_tris.is_empty() && !pick_tris.is_empty() { + first_arc.pick_tris = pick_tris; + first_arc.pick_tris_low = pick_tris_low; + } } out @@ -1744,17 +1845,13 @@ pub fn tessellate( .tangent_geoms .iter() .any(|tg| matches!(tg, TangentGeom::Arc { .. })); - let has_line = te - .tangent_geoms - .iter() - .any(|tg| matches!(tg, TangentGeom::Line { .. })); - let is_thin_polyline = !is_thick_extrusion - && polyline_band_width(entity, document.header.fill_mode) <= 1e-9 + let can_split = !is_thick_extrusion && fill_tris.is_empty() - && pick_tris.is_empty() && matches!(entity, EntityType::LwPolyline(_) | EntityType::Polyline2D(_)); - if has_arc && has_line && is_thin_polyline { + if has_arc && can_split { + let seg_widths = polyline_segment_widths(entity); + let ww = polyline_band_width(entity, document.header.fill_mode); out.extend(split_mixed_polyline( &te.tangent_geoms, &keys, @@ -1767,6 +1864,10 @@ pub fn tessellate( snap, point_marker, true, + &seg_widths, + ww, + pick_tris, + pick_tris_low, )); } else { out.push(WireModel { @@ -1966,20 +2067,14 @@ pub fn tessellate( entity, EntityType::Polyline2D(p) if p.thickness.abs() > 1e-10 ); - let is_thin_polyline = !is_thick_extrusion - && polyline_band_width(entity, document.header.fill_mode) <= 1e-9 - && pick_tris.is_empty() + let can_split = !is_thick_extrusion && matches!(entity, EntityType::LwPolyline(_) | EntityType::Polyline2D(_)); let has_arc = te .tangent_geoms .iter() .any(|tg| matches!(tg, TangentGeom::Arc { .. })); - let has_line = te - .tangent_geoms - .iter() - .any(|tg| matches!(tg, TangentGeom::Line { .. })); - if has_arc && has_line && is_thin_polyline { + if has_arc && can_split { let edge_color = if is_thick_extrusion { [0.0, 0.0, 0.0, 1.0] } else { @@ -1987,6 +2082,8 @@ pub fn tessellate( }; let point_marker = crate::entities::point::relative_marker_spec(entity, document); + let seg_widths = polyline_segment_widths(entity); + let ww = polyline_band_width(entity, document.header.fill_mode); return split_mixed_polyline( &te.tangent_geoms, &key_vertices, @@ -1999,6 +2096,10 @@ pub fn tessellate( snap_pts, point_marker, false, + &seg_widths, + ww, + pick_tris, + pick_tris_low, ); } return vec![WireModel { @@ -2038,6 +2139,40 @@ pub fn tessellate( } RenderObject::TaperedLines(points, widths) => { + let has_arc = te + .tangent_geoms + .iter() + .any(|tg| matches!(tg, TangentGeom::Arc { .. })); + let can_split = matches!(entity, EntityType::LwPolyline(_) | EntityType::Polyline2D(_)); + let (pick_tris, pick_tris_low) = points_to_ds(te.pick_tris); + let world_width = widths.iter().copied().fold(0.0f32, f32::max); + let key_vertices: Vec<[f64; 3]> = te + .key_vertices + .into_iter() + .map(|[x, y, z]| [x, y, z]) + .collect(); + + if has_arc && can_split { + let seg_widths = polyline_segment_widths(entity); + return split_mixed_polyline( + &te.tangent_geoms, + &key_vertices, + &name, + color, + selected, + pattern_length, + pattern, + line_weight_px, + te.snap_pts, + None, + true, + &seg_widths, + world_width, + pick_tris, + pick_tris_low, + ); + } + // A wide polyline whose width varies: one continuous band wire // carrying a per-point width; the shader interpolates each // segment's two endpoint widths. `world_width` (the widest edge) @@ -2045,13 +2180,6 @@ pub fn tessellate( // floor when zoomed out. let (local_pts, local_pts_low) = points_to_ds(points); let snap_pts = te.snap_pts; - let key_vertices: Vec<[f64; 3]> = te - .key_vertices - .into_iter() - .map(|[x, y, z]| [x, y, z]) - .collect(); - let (pick_tris, pick_tris_low) = points_to_ds(te.pick_tris); - let world_width = widths.iter().copied().fold(0.0f32, f32::max); return vec![WireModel { bg_adapt: None, point_marker: None, diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 49feddc94..64a21970d 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -12038,4 +12038,129 @@ mod layout_cache_tests { println!("Zoom Frame Overhead: {:.3} ms ({:.0} FPS)", per_zoom_ms, 1000.0 / per_zoom_ms.max(0.001)); println!("============================================================================\n"); } + + #[test] + #[ignore = "benchmark"] + fn bench_wide_and_tapered_arc_rendering() { + use std::time::Instant; + use acadrust::entities::LwPolyline; + use acadrust::types::Vector2; + + let n_wide_arcs = 2500; + let n_tapered_arcs = 2500; + let n_donuts = 2500; + let total_entities = n_wide_arcs + n_tapered_arcs + n_donuts; + + let mut scene = Scene::new(); + + // 1. Wide polyline arcs (constant width 6.0) + for i in 0..n_wide_arcs { + let x = (i % 50) as f64 * 80.0; + let y = (i / 50) as f64 * 80.0; + let mut pline = LwPolyline::new(); + pline.constant_width = 6.0; + pline.vertices = vec![ + acadrust::entities::LwVertex { + location: Vector2::new(x, y), + bulge: 0.5, + start_width: 0.0, + end_width: 0.0, + vertex_id: 0, + }, + acadrust::entities::LwVertex { + location: Vector2::new(x + 40.0, y), + bulge: 0.0, + start_width: 0.0, + end_width: 0.0, + vertex_id: 1, + }, + ]; + scene.add_entity(EntityType::LwPolyline(pline)); + } + + // 2. Tapered arcs (width: 2.0 -> 12.0) + for i in 0..n_tapered_arcs { + let x = (i % 50) as f64 * 80.0; + let y = (i / 50) as f64 * 80.0 + 4500.0; + let mut pline = LwPolyline::new(); + pline.vertices = vec![ + acadrust::entities::LwVertex { + location: Vector2::new(x, y), + bulge: 0.7, + start_width: 2.0, + end_width: 12.0, + vertex_id: 0, + }, + acadrust::entities::LwVertex { + location: Vector2::new(x + 35.0, y + 10.0), + bulge: 0.0, + start_width: 0.0, + end_width: 0.0, + vertex_id: 1, + }, + ]; + scene.add_entity(EntityType::LwPolyline(pline)); + } + + // 3. Donuts (2 semicircular arc segments, constant width 16.0) + for i in 0..n_donuts { + let cx = (i % 50) as f64 * 80.0; + let cy = (i / 50) as f64 * 80.0 + 9000.0; + let donut = crate::modules::draw::draw::donut::make_donut(cx, cy, 0.0, 10.0, 26.0); + scene.add_entity(donut); + } + + let cam = Camera::default(); + let t_tess_start = Instant::now(); + let wires = scene.model_tile_wires_arc(0, &cam, 1.0, 1000.0); + let tess_duration = t_tess_start.elapsed(); + + let total_wires = wires.len(); + let mut chord_points = 0usize; + let mut analytical_tangents = 0usize; + for w in wires.iter() { + chord_points += w.points.len(); + analytical_tangents += w.tangent_geoms.len(); + } + + let depths = rustc_hash::FxHashMap::default(); + let t_part_start = Instant::now(); + let iters = 100; + let mut part = None; + for _ in 0..iters { + part = Some(crate::scene::pipeline::wire_arena::partition_wires(&wires, &depths)); + } + let part_duration = t_part_start.elapsed() / (iters as u32); + let p = part.unwrap(); + + // Zoom simulation + let zoom_levels = [0.1f32, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0, 50.0, 100.0, 1000.0]; + let t_zoom_start = Instant::now(); + let mut dynamic_cam = Camera::default(); + for &zoom in zoom_levels.iter().cycle().take(100) { + dynamic_cam.distance = 1000.0 / zoom; + let _w = scene.model_tile_wires_arc(0, &dynamic_cam, 1.0, 1000.0); + } + let zoom_100_duration = t_zoom_start.elapsed(); + let per_zoom_ms = zoom_100_duration.as_secs_f64() * 1000.0 / 100.0; + + let line_vram_mb = (chord_points * 36) as f64 / (1024.0 * 1024.0); + let inst_vram_mb = (p.circle_instances.len() * 128) as f64 / (1024.0 * 1024.0); + + println!("\n=== BENCHMARK REPORT: THICK & TAPERED ANALYTICAL ARCS ==="); + println!("Thick & Tapered Entities: {total_entities}"); + println!("Initial Wire Build Time: {:.2?}", tess_duration); + println!("Total Wires: {total_wires}"); + println!("Tessellated Chord Vertices: {chord_points}"); + println!("Analytical Tangent Geometries: {analytical_tangents}"); + println!("Partitioning Time (per frame): {:.3?}", part_duration); + println!("Regular Line Wires: {}", p.regular.len()); + println!("GPU Circle/Arc Instances: {}", p.circle_instances.len()); + println!("Line Arena VRAM (chord lines): {:.2} MB", line_vram_mb); + println!("GPU Analytical VRAM: {:.2} MB", inst_vram_mb); + println!("VRAM Reduction Ratio: {:.1}x", if inst_vram_mb > 0.0 { line_vram_mb / inst_vram_mb } else { 0.0 }); + println!("100 Camera Zoom Navigations: {:.2?}", zoom_100_duration); + println!("Zoom Frame Overhead: {:.3} ms ({:.0} FPS)", per_zoom_ms, 1000.0 / per_zoom_ms.max(0.001)); + println!("========================================================\n"); + } } diff --git a/src/scene/pipeline/circle_gpu.rs b/src/scene/pipeline/circle_gpu.rs index d62902a64..ba47dcbbb 100644 --- a/src/scene/pipeline/circle_gpu.rs +++ b/src/scene/pipeline/circle_gpu.rs @@ -11,7 +11,7 @@ use iced::wgpu; #[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)] pub struct CircleInstance { pub center_high: [f32; 3], - pub _pad0: f32, + pub start_width: f32, pub center_low: [f32; 3], pub radius: f32, pub axis_x: [f32; 3], @@ -19,7 +19,7 @@ pub struct CircleInstance { pub axis_y: [f32; 3], pub end_angle: f32, pub color: [f32; 4], - /// `[half_width, pattern_length, draw_depth, padding]` + /// `[half_width, pattern_length, draw_depth, end_width]` pub params: [f32; 4], pub pat0: [f32; 4], pub pat1: [f32; 4], @@ -204,6 +204,16 @@ pub fn extract_circle_instance_from_geom( geom: &crate::scene::model::wire_model::TangentGeom, wire: &crate::scene::WireModel, draw_depth: f32, +) -> Option { + extract_circle_instance_from_geom_indexed(geom, wire, draw_depth, 0) +} + +/// Helper to extract an analytical circle or circular arc instance at a specific segment index. +pub fn extract_circle_instance_from_geom_indexed( + geom: &crate::scene::model::wire_model::TangentGeom, + wire: &crate::scene::WireModel, + draw_depth: f32, + geom_index: usize, ) -> Option { let (center, axis_x, axis_y, radius, start_angle, end_angle) = match *geom { crate::scene::model::wire_model::TangentGeom::Circle { center, radius } => ( @@ -246,9 +256,25 @@ pub fn extract_circle_instance_from_geom( let pat0 = [wire.pattern[0], wire.pattern[1], wire.pattern[2], wire.pattern[3]]; let pat1 = [wire.pattern[4], wire.pattern[5], wire.pattern[6], wire.pattern[7]]; + 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]) + } 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); + (sw, ew) + } else { + (wire.world_width, wire.world_width) + } + } else if wire.world_width > 0.0 { + (wire.world_width, wire.world_width) + } else { + (0.0, 0.0) + }; + Some(CircleInstance { center_high: ch, - _pad0: 0.0, + start_width, center_low: cl, radius: radius as f32, axis_x: [axis_x[0] as f32, axis_x[1] as f32, axis_x[2] as f32], @@ -256,7 +282,7 @@ pub fn extract_circle_instance_from_geom( axis_y: [axis_y[0] as f32, axis_y[1] as f32, axis_y[2] as f32], end_angle, color: wire.color, - params: [hw, wire.pattern_length, draw_depth, 0.0], + params: [hw, wire.pattern_length, draw_depth, end_width], pat0, pat1, }) @@ -265,14 +291,14 @@ pub fn extract_circle_instance_from_geom( /// Helper to extract all analytical circle/arc instances from a `WireModel`. /// /// Returns `Some(instances)` if the wire is non-empty, contains solely analytical -/// circle/arc tangent geometries, and has no mesh/pick fills, text, or block instance. +/// circle/arc tangent geometries, and has no mesh fills, text, or block instance. pub fn extract_circle_instances( wire: &crate::scene::WireModel, draw_depth: f32, ) -> Option> { if wire.tangent_geoms.is_empty() || !wire.fill_tris.is_empty() - || !wire.pick_tris.is_empty() + || wire.fill_is_3d || !wire.text_verts.is_empty() || wire.render_instance.is_some() { @@ -280,8 +306,8 @@ pub fn extract_circle_instances( } let mut instances = Vec::with_capacity(wire.tangent_geoms.len()); - for geom in &wire.tangent_geoms { - if let Some(inst) = extract_circle_instance_from_geom(geom, wire, draw_depth) { + for (i, geom) in wire.tangent_geoms.iter().enumerate() { + if let Some(inst) = extract_circle_instance_from_geom_indexed(geom, wire, draw_depth, i) { instances.push(inst); } else { return None; @@ -410,4 +436,100 @@ mod tests { }; assert!(extract_circle_instance(&wire, 0.0).is_none()); } + + #[test] + fn extract_circle_instance_constant_wide_arc() { + let mut wire = crate::scene::WireModel::default(); + wire.tangent_geoms.push(crate::scene::model::wire_model::TangentGeom::Arc { + center: [100.0, 50.0, 0.0], + axis_x: [1.0, 0.0, 0.0], + axis_y: [0.0, 1.0, 0.0], + radius: 40.0, + start_angle: 0.0, + end_angle: std::f64::consts::PI, + }); + wire.world_width = 12.0; + wire.color = [1.0, 0.5, 0.0, 1.0]; + + let inst = extract_circle_instance(&wire, 0.0).expect("extract constant wide arc"); + assert_eq!(inst.start_width, 12.0); + assert_eq!(inst.params[3], 12.0); // end_width + assert_eq!(inst.radius, 40.0); + } + + #[test] + fn extract_circle_instance_tapered_arc() { + let mut wire = crate::scene::WireModel::default(); + wire.tangent_geoms.push(crate::scene::model::wire_model::TangentGeom::Arc { + center: [0.0, 0.0, 0.0], + axis_x: [1.0, 0.0, 0.0], + axis_y: [0.0, 1.0, 0.0], + radius: 30.0, + start_angle: 0.2, + end_angle: 1.8, + }); + wire.world_width = 20.0; + wire.taper_widths = vec![4.0, 20.0]; + wire.color = [0.2, 0.8, 1.0, 1.0]; + + let inst = extract_circle_instance(&wire, 0.0).expect("extract tapered arc"); + assert_eq!(inst.start_width, 4.0); + assert_eq!(inst.params[3], 20.0); // end_width + assert_eq!(inst.radius, 30.0); + } + + #[test] + fn extract_circle_instance_accepts_pick_tris() { + let mut wire = crate::scene::WireModel::default(); + wire.tangent_geoms.push(crate::scene::model::wire_model::TangentGeom::Circle { + center: [5.0, 5.0, 0.0], + radius: 15.0, + }); + wire.world_width = 6.0; + wire.pick_tris = vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]; + wire.pick_tris_low = vec![[0.0; 3]; 3]; + + let insts = extract_circle_instances(&wire, 0.0).expect("wide curve with pick_tris should extract"); + assert_eq!(insts.len(), 1); + assert_eq!(insts[0].start_width, 6.0); + assert_eq!(insts[0].params[3], 6.0); + } + + #[test] + fn test_donut_polyline_tessellates_to_analytical_arcs() { + use acadrust::CadDocument; + let mut doc = CadDocument::new(); + let donut = crate::modules::draw::draw::donut::make_donut(10.0, 20.0, 0.0, 10.0, 30.0); + let h = doc.add_entity(donut).unwrap(); + let entity = doc.get_entity(h).unwrap(); + let selected = rustc_hash::FxHashSet::default(); + let wires = crate::scene::convert::tess::tessellate_entity( + &doc, + &selected, + None, + [0.0, 0.0, 0.0, 1.0], + 1.0, + None, + entity, + None, + None, + None, + false, + ); + assert_eq!(wires.len(), 2, "donut should split into 2 analytical arc wires"); + for w in &wires { + assert_eq!(w.world_width, 20.0); + assert_eq!(w.tangent_geoms.len(), 1); + assert!(matches!(w.tangent_geoms[0], crate::scene::model::wire_model::TangentGeom::Arc { .. })); + } + + let depth_map = rustc_hash::FxHashMap::default(); + let partitioned = crate::scene::pipeline::wire_arena::partition_wires(&wires, &depth_map); + assert_eq!(partitioned.circle_instances.len(), 2); + assert_eq!(partitioned.regular.len(), 0); + assert_eq!(partitioned.circle_instances[0].start_width, 20.0); + assert_eq!(partitioned.circle_instances[0].params[3], 20.0); + assert_eq!(partitioned.circle_instances[1].start_width, 20.0); + assert_eq!(partitioned.circle_instances[1].params[3], 20.0); + } } diff --git a/src/scene/pipeline/ellipse_gpu.rs b/src/scene/pipeline/ellipse_gpu.rs index 50bc2e59d..170af46ce 100644 --- a/src/scene/pipeline/ellipse_gpu.rs +++ b/src/scene/pipeline/ellipse_gpu.rs @@ -254,7 +254,7 @@ pub fn extract_ellipse_instances( ) -> Option> { if wire.tangent_geoms.is_empty() || !wire.fill_tris.is_empty() - || !wire.pick_tris.is_empty() + || wire.fill_is_3d || !wire.text_verts.is_empty() || wire.render_instance.is_some() { diff --git a/src/scene/pipeline/gpu_tests.rs b/src/scene/pipeline/gpu_tests.rs index 6ff34c4d0..3aeacb28c 100644 --- a/src/scene/pipeline/gpu_tests.rs +++ b/src/scene/pipeline/gpu_tests.rs @@ -521,6 +521,180 @@ fn test_selected_circle_arc_ellipse_highlight_overlay() { device.poll(wgpu::PollType::wait_indefinitely()).unwrap(); } +#[test] +#[ignore = "requires a GPU adapter"] +fn test_thick_and_tapered_arc_gpu_rendering() { + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()); + let adapter = block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default())) + .expect("GPU adapter"); + let (device, queue) = block_on(adapter.request_device(&wgpu::DeviceDescriptor { + required_limits: adapter.limits(), + ..Default::default() + })) + .expect("GPU device"); + let mut pipeline = Pipeline::new(&device, &queue, wgpu::TextureFormat::Bgra8UnormSrgb); + + // 1. Wide circular arc with pick triangles + let mut wide_arc = WireModel::default(); + wide_arc.name = "wide_arc".into(); + wide_arc.tangent_geoms.push(crate::scene::model::wire_model::TangentGeom::Arc { + center: [50.0, 50.0, 0.0], + axis_x: [1.0, 0.0, 0.0], + axis_y: [0.0, 1.0, 0.0], + radius: 30.0, + start_angle: 0.0, + end_angle: std::f64::consts::PI, + }); + wide_arc.world_width = 10.0; + wide_arc.color = [1.0, 0.2, 0.2, 1.0]; + wide_arc.pick_tris = vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]; + wide_arc.pick_tris_low = vec![[0.0; 3]; 3]; + + // 2. Tapered circular arc + let mut tapered_arc = WireModel::default(); + tapered_arc.name = "tapered_arc".into(); + tapered_arc.tangent_geoms.push(crate::scene::model::wire_model::TangentGeom::Arc { + center: [150.0, 50.0, 0.0], + axis_x: [1.0, 0.0, 0.0], + axis_y: [0.0, 1.0, 0.0], + radius: 40.0, + start_angle: 0.2, + end_angle: 2.8, + }); + tapered_arc.world_width = 16.0; + tapered_arc.taper_widths = vec![2.0, 16.0]; + tapered_arc.color = [0.2, 0.8, 1.0, 1.0]; + + let wires = vec![wide_arc, tapered_arc]; + let depth_map = rustc_hash::FxHashMap::default(); + let circles = pipeline.upload_circles(&device, &queue, &wires, &depth_map); + + // Both curves must be routed to GPU analytical circle instances! + assert!(!circles.is_empty(), "analytical circles must be uploaded"); + assert_eq!(circles[0].instance_count, 2); + pipeline.gpu_circles = std::sync::Arc::new(circles); + assert_eq!(pipeline.gpu_circles.len(), 1); + assert_eq!(pipeline.gpu_circles[0].instance_count, 2); + + pipeline.ensure_depth_texture(&device, iced::Size::new(512, 512)); + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("thick_arc_target"), + size: wgpu::Extent3d { + width: 512, + height: 512, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Bgra8UnormSrgb, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + view_formats: &[], + }); + let target = texture.create_view(&wgpu::TextureViewDescriptor::default()); + let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("thick_arc_encoder"), + }); + pipeline.render( + &mut encoder, + &target, + iced::Size::new(512, 512), + iced::Rectangle { x: 0, y: 0, width: 512, height: 512 }, + [0.0, 0.0, 0.0, 1.0], + false, + false, + false, + ); + queue.submit(Some(encoder.finish())); + device.poll(wgpu::PollType::wait_indefinitely()).unwrap(); +} + +#[test] +#[ignore = "requires a GPU adapter"] +fn test_tilted_3d_donut_and_thick_arc_gpu_rendering() { + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()); + let adapter = block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default())) + .expect("GPU adapter"); + let (device, queue) = block_on(adapter.request_device(&wgpu::DeviceDescriptor { + required_limits: adapter.limits(), + ..Default::default() + })) + .expect("GPU device"); + let mut pipeline = Pipeline::new(&device, &queue, wgpu::TextureFormat::Bgra8UnormSrgb); + + // Setup 3D perspective camera with acute tilt + let mut camera = crate::scene::view::camera::Camera::default(); + camera.projection = crate::scene::view::camera::Projection::Perspective; + camera.distance = 200.0; + camera.target = glam::DVec3::new(50.0, 50.0, 0.0); + camera.rotation = glam::Quat::from_axis_angle(glam::Vec3::X, 1.1); // ~63 degree tilt (acute perspective) + let bounds = iced::Rectangle::new(iced::Point::ORIGIN, iced::Size::new(512.0, 512.0)); + let uniforms = Uniforms::new(&camera, bounds, false); + pipeline.upload_uniforms(&device, &queue, &uniforms); + + // Donut circular arc segments + let mut donut = WireModel::default(); + donut.name = "donut".into(); + donut.tangent_geoms.push(crate::scene::model::wire_model::TangentGeom::Arc { + center: [50.0, 50.0, 0.0], + axis_x: [1.0, 0.0, 0.0], + axis_y: [0.0, 1.0, 0.0], + radius: 30.0, + start_angle: 0.0, + end_angle: std::f64::consts::PI, + }); + donut.tangent_geoms.push(crate::scene::model::wire_model::TangentGeom::Arc { + center: [50.0, 50.0, 0.0], + axis_x: [1.0, 0.0, 0.0], + axis_y: [0.0, 1.0, 0.0], + radius: 30.0, + start_angle: std::f64::consts::PI, + end_angle: std::f64::consts::TAU, + }); + donut.world_width = 12.0; + donut.color = [0.2, 0.9, 0.3, 1.0]; + + let wires = vec![donut]; + let depth_map = rustc_hash::FxHashMap::default(); + let circles = pipeline.upload_circles(&device, &queue, &wires, &depth_map); + + assert_eq!(circles.len(), 1); + assert_eq!(circles[0].instance_count, 2); + pipeline.gpu_circles = std::sync::Arc::new(circles); + + pipeline.ensure_depth_texture(&device, iced::Size::new(512, 512)); + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("tilted_donut_target"), + size: wgpu::Extent3d { + width: 512, + height: 512, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Bgra8UnormSrgb, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + view_formats: &[], + }); + let target = texture.create_view(&wgpu::TextureViewDescriptor::default()); + let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("tilted_donut_encoder"), + }); + pipeline.render( + &mut encoder, + &target, + iced::Size::new(512, 512), + iced::Rectangle { x: 0, y: 0, width: 512, height: 512 }, + [0.0, 0.0, 0.0, 1.0], + false, + false, + false, + ); + queue.submit(Some(encoder.finish())); + device.poll(wgpu::PollType::wait_indefinitely()).unwrap(); +} + #[test] #[ignore = "requires a GPU adapter"] fn test_selected_ellipse_overlay() { diff --git a/src/scene/pipeline/wire_arena.rs b/src/scene/pipeline/wire_arena.rs index 265ef257d..752072b99 100644 --- a/src/scene/pipeline/wire_arena.rs +++ b/src/scene/pipeline/wire_arena.rs @@ -146,7 +146,7 @@ pub fn classify_wire(wire: &WireModel) -> WireKind { } if !wire.tangent_geoms.is_empty() && wire.fill_tris.is_empty() - && wire.pick_tris.is_empty() + && !wire.fill_is_3d && wire.text_verts.is_empty() { if super::circle_gpu::extract_circle_instances(wire, 0.0).is_some() { @@ -219,7 +219,7 @@ pub fn partition_wires<'a>( // Resolve draw depth only for wires eligible for analytical extraction. if !wire.tangent_geoms.is_empty() && wire.fill_tris.is_empty() - && wire.pick_tris.is_empty() + && !wire.fill_is_3d && wire.text_verts.is_empty() { let depth = super::wire_gpu::wire_draw_depth(wire, depth_map); @@ -1709,4 +1709,47 @@ mod tests { assert_eq!(partitioned.ellipse_instances.len(), 1); assert_eq!(partitioned.regular.len(), 1); } + + #[test] + fn partition_wires_partitions_thick_and_tapered_arcs() { + use crate::scene::model::wire_model::TangentGeom; + + // Wide arc with pick triangles + let mut wide_arc = WireModel::default(); + wide_arc.tangent_geoms.push(TangentGeom::Arc { + center: [10.0, 20.0, 0.0], + axis_x: [1.0, 0.0, 0.0], + axis_y: [0.0, 1.0, 0.0], + radius: 25.0, + start_angle: 0.0, + end_angle: std::f64::consts::PI, + }); + wide_arc.world_width = 8.0; + wide_arc.pick_tris = vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]; + wide_arc.pick_tris_low = vec![[0.0; 3]; 3]; + + // Tapered arc + let mut tapered_arc = WireModel::default(); + tapered_arc.tangent_geoms.push(TangentGeom::Arc { + center: [50.0, 60.0, 0.0], + axis_x: [1.0, 0.0, 0.0], + axis_y: [0.0, 1.0, 0.0], + radius: 15.0, + start_angle: 0.5, + end_angle: 2.5, + }); + tapered_arc.world_width = 10.0; + tapered_arc.taper_widths = vec![2.0, 10.0]; + + let wires = vec![wide_arc, tapered_arc]; + let depth_map = rustc_hash::FxHashMap::default(); + let partitioned = partition_wires(&wires, &depth_map); + + assert_eq!(partitioned.circle_instances.len(), 2); + assert_eq!(partitioned.regular.len(), 0); + assert_eq!(partitioned.circle_instances[0].start_width, 8.0); + assert_eq!(partitioned.circle_instances[0].params[3], 8.0); + assert_eq!(partitioned.circle_instances[1].start_width, 2.0); + assert_eq!(partitioned.circle_instances[1].params[3], 10.0); + } } diff --git a/src/shaders/circle.wgsl b/src/shaders/circle.wgsl index 3e88c88aa..22b0973f8 100644 --- a/src/shaders/circle.wgsl +++ b/src/shaders/circle.wgsl @@ -32,12 +32,12 @@ const MODEL_LINEWEIGHT_MAX_PX: f32 = 10.0; const TAU: f32 = 6.283185307179586; struct InstanceIn { - @location(0) center_high: vec4, // xyz = center_high, w = unused + @location(0) center_high: vec4, // xyz = center_high, w = start_width @location(1) center_low: vec4, // xyz = center_low, w = radius @location(2) axis_x: vec4, // xyz = axis_x, w = start_angle @location(3) axis_y: vec4, // xyz = axis_y, w = end_angle @location(4) color: vec4, // rgba - @location(5) params: vec4, // half_width_px, pattern_length, draw_depth, unused + @location(5) params: vec4, // half_width_px, pattern_length, draw_depth, end_width @location(6) pat0: vec4, @location(7) pat1: vec4, } @@ -46,7 +46,7 @@ struct VertexOut { @builtin(position) clip_pos: vec4, @location(0) local_pos: vec2, @location(1) radius: f32, - @location(2) hw_px: f32, + @location(2) @interpolate(flat) hw_ends: vec2, @location(3) color: vec4, @location(4) pattern_length: f32, @location(5) pat0: vec4, @@ -54,9 +54,10 @@ struct VertexOut { @location(7) @interpolate(flat) min_elem: f32, @location(8) @interpolate(flat) start_angle: f32, @location(9) @interpolate(flat) end_angle: f32, + @location(10) @interpolate(flat) is_world: f32, } -fn resolve_hw(px_hw: f32) -> f32 { +fn resolve_px_hw(px_hw: f32) -> f32 { var display_hw = max(px_hw * u.lineweight_scale, 0.5); if u.lineweight_scale < 0.0 { let scale = -u.lineweight_scale; @@ -85,14 +86,32 @@ fn resolve_hw(px_hw: f32) -> f32 { let axis_x = in.axis_x.xyz; let axis_y = in.axis_y.xyz; let color = in.color; - let hw_px = resolve_hw(in.params.x); + let world_w_a = in.center_high.w; + let world_w_b = in.params.w; + let is_world = select(0.0, 1.0, world_w_a > 0.0 || world_w_b > 0.0); + + var hw_a: f32; + var hw_b: f32; + var ext: f32; + + if is_world > 0.5 { + hw_a = world_w_a * 0.5; + hw_b = world_w_b * 0.5; + let max_hw = max(hw_a, hw_b); + // Expand bounding quad so anti-aliasing margin and physical width never clip under 3D tilt + let margin_world = max_hw + 16.0 * max(u.world_per_pixel, 1e-6); + ext = radius + margin_world; + } else { + let px_hw = resolve_px_hw(in.params.x); + hw_a = px_hw; + hw_b = px_hw; + let margin_world = (px_hw + 2.0) * max(u.world_per_pixel, 1e-6); + ext = radius + margin_world; + } + let pattern_length = in.params.y; let draw_depth = in.params.z; - // Expand bounding quad so anti-aliasing margin and lineweight never clip - let margin_world = (hw_px + 2.0) * max(u.world_per_pixel, 1e-6); - let ext = radius + margin_world; - let center_rel = (center_high - u.eye_high) + (center_low - u.eye_low); let world_pos_rel = center_rel + (u_val * axis_x + v_val * axis_y) * ext; @@ -103,7 +122,7 @@ fn resolve_hw(px_hw: f32) -> f32 { out.clip_pos = clip_pos; out.local_pos = vec2(u_val * ext, v_val * ext); out.radius = radius; - out.hw_px = hw_px; + out.hw_ends = vec2(hw_a, hw_b); out.color = color; out.pattern_length = pattern_length; out.pat0 = in.pat0; @@ -122,6 +141,7 @@ fn resolve_hw(px_hw: f32) -> f32 { out.start_angle = in.axis_x.w; out.end_angle = in.axis_y.w; + out.is_world = is_world; return out; } @@ -181,30 +201,60 @@ fn in_dash(dist: f32, pat_len: f32, p0: vec4, p1: vec4) -> bool { let theta = select(angle, angle + TAU, angle < 0.0); let d_theta = mod_tau(theta - sa); - var d_world: f32; - var arc_dist: f32; + var alpha_cov: f32 = 0.0; + var arc_dist: f32 = 0.0; - if sweep >= TAU - 1e-5 || d_theta <= sweep { - // Inside arc sweep or full circle - d_world = abs(r - in.radius); - arc_dist = in.radius * d_theta; - } else { - // Outside sweep: measure distance to start and end endpoints for 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); - if d_start < d_end { - d_world = d_start; - arc_dist = 0.0; + 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 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); + alpha_cov = cov * subpixel_fade; + arc_dist = in.radius * d_theta; } else { - d_world = d_end; - arc_dist = in.radius * sweep; + // 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); + } + } else { + // Lineweight stroke (screen-pixel width) + if is_full_or_in_sweep { + let t = select(0.0, clamp(d_theta / sweep, 0.0, 1.0), sweep > 1e-5); + let cur_hw = mix(in.hw_ends.x, in.hw_ends.y, t); + let d_world = abs(r - in.radius); + let d_px = d_world / fw; + alpha_cov = clamp(0.5 + cur_hw - d_px, 0.0, 1.0); + arc_dist = in.radius * d_theta; + } else { + let p_start = in.radius * vec2(cos(sa), sin(sa)); + let p_end = in.radius * vec2(cos(ea), sin(ea)); + let d_start_px = length(in.local_pos - p_start) / fw; + let d_end_px = length(in.local_pos - p_end) / fw; + let cov_start = clamp(0.5 + in.hw_ends.x - d_start_px, 0.0, 1.0); + let cov_end = clamp(0.5 + in.hw_ends.y - d_end_px, 0.0, 1.0); + alpha_cov = max(cov_start, cov_end); + arc_dist = select(in.radius * sweep, 0.0, cov_start >= cov_end); } } - let d_px = d_world / fw; - let alpha_cov = clamp(0.5 + in.hw_px - d_px, 0.0, 1.0); if alpha_cov <= 0.0 { discard; }