feat(render): render thick and tapered analytical arcs directly on GPU

- Repurpose CircleInstance center_high.w and params.w for start_width and end_width (preserving exact 128-byte stride)
- Route wide polylines, tapered polylines with bulges, and donuts through split_mixed_polyline into GPU analytical arc instances
- Compute physical annular bands, width tapers, and round end caps in circle.wgsl using world-space distances to prevent 3D perspective foreshortening distortion
- Expand bounding quad margins dynamically with grazing-angle compensation to eliminate clipping
- Relax pick_tris check in wire classification and partition routines so wide entities retain CPU picking while drawing analytically
- Add unit and hardware GPU integration tests, including tilted 3D perspective rendering verification
This commit is contained in:
Karim Jerbi committed 2026-09-09 13:02:02 +01:00
1 parent 50e4f2bfe3
commit 9bbc5c3182
8 files changed
+714 -72

No files matched your search

+130 -8
View File
@@ -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<CircleInstance> {
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<CircleInstance> {
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<Vec<CircleInstance>> {
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);
}
}
+1 -1
View File
@@ -254,7 +254,7 @@ pub fn extract_ellipse_instances(
) -> Option<Vec<EllipseInstance>> {
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()
{
+174
View File
@@ -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() {
+45 -2
View File
@@ -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);
}
}