Merge branch 'main' into feature/parametric-constraint-system

# Conflicts:
#	src/app/automation.rs
#	src/app/mod.rs
#	src/ui/ribbon/widgets.rs
This commit is contained in:
Felix Riestra committed 2026-09-09 13:35:07 +02:00
commit e2d39b049d
114 files changed
+13641 -1685

No files matched your search

+413
View File
@@ -0,0 +1,413 @@
//! GPU-instanced analytical circle buffer & pipeline layout.
//!
//! Renders 2D/planar circles as analytical screen/plane-aligned quads with
//! screen-space derivative anti-aliasing. Each circle is a single instance
//! (6 vertices for a 2-triangle quad), eliminating CPU re-tessellation on zoom
//! and providing infinite visual smoothness.
use iced::wgpu;
#[repr(C)]
#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
pub struct CircleInstance {
pub center_high: [f32; 3],
pub _pad0: f32,
pub center_low: [f32; 3],
pub radius: f32,
pub axis_x: [f32; 3],
pub start_angle: f32,
pub axis_y: [f32; 3],
pub end_angle: f32,
pub color: [f32; 4],
/// `[half_width, pattern_length, draw_depth, padding]`
pub params: [f32; 4],
pub pat0: [f32; 4],
pub pat1: [f32; 4],
}
impl CircleInstance {
pub fn layout<'a>() -> wgpu::VertexBufferLayout<'a> {
const ATTRS: &[wgpu::VertexAttribute] = &[
wgpu::VertexAttribute {
offset: std::mem::offset_of!(CircleInstance, center_high) as u64,
shader_location: 0,
format: wgpu::VertexFormat::Float32x4,
},
wgpu::VertexAttribute {
offset: std::mem::offset_of!(CircleInstance, center_low) as u64,
shader_location: 1,
format: wgpu::VertexFormat::Float32x4,
},
wgpu::VertexAttribute {
offset: std::mem::offset_of!(CircleInstance, axis_x) as u64,
shader_location: 2,
format: wgpu::VertexFormat::Float32x4,
},
wgpu::VertexAttribute {
offset: std::mem::offset_of!(CircleInstance, axis_y) as u64,
shader_location: 3,
format: wgpu::VertexFormat::Float32x4,
},
wgpu::VertexAttribute {
offset: std::mem::offset_of!(CircleInstance, color) as u64,
shader_location: 4,
format: wgpu::VertexFormat::Float32x4,
},
wgpu::VertexAttribute {
offset: std::mem::offset_of!(CircleInstance, params) as u64,
shader_location: 5,
format: wgpu::VertexFormat::Float32x4,
},
wgpu::VertexAttribute {
offset: std::mem::offset_of!(CircleInstance, pat0) as u64,
shader_location: 6,
format: wgpu::VertexFormat::Float32x4,
},
wgpu::VertexAttribute {
offset: std::mem::offset_of!(CircleInstance, pat1) as u64,
shader_location: 7,
format: wgpu::VertexFormat::Float32x4,
},
];
wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<CircleInstance>() as u64,
step_mode: wgpu::VertexStepMode::Instance,
attributes: ATTRS,
}
}
}
pub struct CircleGpu {
pub instance_buffer: wgpu::Buffer,
pub instance_count: u32,
}
impl CircleGpu {
pub fn from_instances(
device: &wgpu::Device,
queue: &wgpu::Queue,
label: &str,
instances: &[CircleInstance],
) -> Self {
let instance_buffer = super::gpu_upload::upload_buffer(
device,
queue,
label,
instances,
wgpu::BufferUsages::VERTEX,
);
Self {
instance_buffer,
instance_count: instances.len() as u32,
}
}
}
/// Build the base and xray circle pipelines.
pub fn create_pipelines(
device: &wgpu::Device,
frame_bgl: &wgpu::BindGroupLayout,
color_format: wgpu::TextureFormat,
sample_count: u32,
content_stencil: &wgpu::StencilState,
) -> (wgpu::RenderPipeline, wgpu::RenderPipeline) {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("circle.wgsl"),
source: wgpu::ShaderSource::Wgsl(include_str!("../../shaders/circle.wgsl").into()),
});
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("circle.pipeline.layout"),
bind_group_layouts: &[Some(frame_bgl)],
immediate_size: 0,
});
let circle_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("circle.pipeline"),
layout: Some(&layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_main"),
buffers: &[CircleInstance::layout()],
compilation_options: Default::default(),
},
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs_main"),
targets: &[Some(wgpu::ColorTargetState {
format: color_format,
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
write_mask: wgpu::ColorWrites::ALL,
})],
compilation_options: Default::default(),
}),
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
..Default::default()
},
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth24PlusStencil8,
depth_write_enabled: Some(true),
depth_compare: Some(wgpu::CompareFunction::LessEqual),
stencil: content_stencil.clone(),
bias: wgpu::DepthBiasState::default(),
}),
multisample: wgpu::MultisampleState {
count: sample_count,
mask: !0,
alpha_to_coverage_enabled: false,
},
multiview_mask: None,
cache: None,
});
let circle_xray_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("circle_xray.pipeline"),
layout: Some(&layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_main"),
buffers: &[CircleInstance::layout()],
compilation_options: Default::default(),
},
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs_main"),
targets: &[Some(wgpu::ColorTargetState {
format: color_format,
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
write_mask: wgpu::ColorWrites::ALL,
})],
compilation_options: Default::default(),
}),
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
..Default::default()
},
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth24PlusStencil8,
depth_write_enabled: Some(false),
depth_compare: Some(wgpu::CompareFunction::Always),
stencil: content_stencil.clone(),
bias: wgpu::DepthBiasState::default(),
}),
multisample: wgpu::MultisampleState {
count: sample_count,
mask: !0,
alpha_to_coverage_enabled: false,
},
multiview_mask: None,
cache: None,
});
(circle_pipeline, circle_xray_pipeline)
}
/// Helper to extract an analytical circle or circular arc instance from a single `TangentGeom`.
pub fn extract_circle_instance_from_geom(
geom: &crate::scene::model::wire_model::TangentGeom,
wire: &crate::scene::WireModel,
draw_depth: f32,
) -> Option<CircleInstance> {
let (center, axis_x, axis_y, radius, start_angle, end_angle) = match *geom {
crate::scene::model::wire_model::TangentGeom::Circle { center, radius } => (
[center[0] as f64, center[1] as f64, center[2] as f64],
[1.0, 0.0, 0.0],
[0.0, 1.0, 0.0],
radius as f64,
0.0f32,
std::f32::consts::TAU,
),
crate::scene::model::wire_model::TangentGeom::PlanarCircle {
center,
axis_x,
axis_y,
radius,
} => (center, axis_x, axis_y, radius, 0.0f32, std::f32::consts::TAU),
crate::scene::model::wire_model::TangentGeom::Arc {
center,
axis_x,
axis_y,
radius,
start_angle,
end_angle,
} => (
center,
axis_x,
axis_y,
radius,
start_angle as f32,
end_angle as f32,
),
_ => return None,
};
if radius <= 0.0 || !radius.is_finite() || !start_angle.is_finite() || !end_angle.is_finite() || radius > 1e6 {
return None;
}
let (ch, cl) = split_ds_xyz(center[0], center[1], center[2]);
let hw = wire.line_weight_px * 0.5;
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]];
Some(CircleInstance {
center_high: ch,
_pad0: 0.0,
center_low: cl,
radius: radius as f32,
axis_x: [axis_x[0] as f32, axis_x[1] as f32, axis_x[2] as f32],
start_angle,
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],
pat0,
pat1,
})
}
/// 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.
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.text_verts.is_empty()
|| wire.render_instance.is_some()
{
return None;
}
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) {
instances.push(inst);
} else {
return None;
}
}
Some(instances)
}
/// Helper to extract a single analytical circle or circular arc instance from a `WireModel`.
pub fn extract_circle_instance(
wire: &crate::scene::WireModel,
draw_depth: f32,
) -> Option<CircleInstance> {
if wire.tangent_geoms.len() == 1 {
extract_circle_instances(wire, draw_depth).and_then(|mut v| v.pop())
} else {
None
}
}
fn split_ds_xyz(x: f64, y: f64, z: f64) -> ([f32; 3], [f32; 3]) {
let hx = x as f32;
let lx = (x - hx as f64) as f32;
let hy = y as f32;
let ly = (y - hy as f64) as f32;
let hz = z as f32;
let lz = (z - hz as f64) as f32;
([hx, hy, hz], [lx, ly, lz])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn circle_instance_size_matches_stride() {
assert_eq!(std::mem::size_of::<CircleInstance>(), 128);
}
#[test]
fn circle_shader_validates_with_naga() {
let source = include_str!("../../shaders/circle.wgsl");
let module = naga::front::wgsl::parse_str(source).expect("circle.wgsl parses cleanly");
let mut validator = naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
);
validator
.validate(&module)
.expect("circle.wgsl validates cleanly with naga");
}
#[test]
fn extract_circle_instance_planar_success() {
let mut wire = crate::scene::WireModel::default();
wire.tangent_geoms.push(crate::scene::model::wire_model::TangentGeom::PlanarCircle {
center: [100.0, 200.0, 300.0],
axis_x: [1.0, 0.0, 0.0],
axis_y: [0.0, 1.0, 0.0],
radius: 50.0,
});
wire.line_weight_px = 2.0;
wire.color = [1.0, 0.0, 0.0, 1.0];
let inst = extract_circle_instance(&wire, 1.5).expect("should extract planar circle");
assert_eq!(inst.radius, 50.0);
assert_eq!(inst.start_angle, 0.0);
assert_eq!(inst.end_angle, std::f32::consts::TAU);
assert_eq!(inst.center_high, [100.0, 200.0, 300.0]);
assert_eq!(inst.color, [1.0, 0.0, 0.0, 1.0]);
assert_eq!(inst.params[0], 1.0); // half lineweight
assert_eq!(inst.params[2], 1.5); // draw depth
}
#[test]
fn extract_circle_instance_arc_success() {
let mut wire = crate::scene::WireModel::default();
wire.tangent_geoms.push(crate::scene::model::wire_model::TangentGeom::Arc {
center: [10.0, 20.0, 30.0],
axis_x: [1.0, 0.0, 0.0],
axis_y: [0.0, 1.0, 0.0],
radius: 25.0,
start_angle: 0.5,
end_angle: 2.5,
});
wire.line_weight_px = 3.0;
wire.color = [0.0, 1.0, 0.0, 1.0];
let inst = extract_circle_instance(&wire, 0.5).expect("should extract circular arc");
assert_eq!(inst.radius, 25.0);
assert_eq!(inst.start_angle, 0.5);
assert_eq!(inst.end_angle, 2.5);
assert_eq!(inst.center_high, [10.0, 20.0, 30.0]);
assert_eq!(inst.color, [0.0, 1.0, 0.0, 1.0]);
assert_eq!(inst.params[0], 1.5);
}
#[test]
fn extract_circle_instance_rejects_non_planar_or_complex() {
let mut wire = crate::scene::WireModel::default();
wire.tangent_geoms.push(crate::scene::model::wire_model::TangentGeom::PlanarCircle {
center: [0.0, 0.0, 0.0],
axis_x: [1.0, 0.0, 0.0],
axis_y: [0.0, 1.0, 0.0],
radius: 10.0,
});
// 1. With fill_tris (e.g. 3D solid or hatch)
wire.fill_tris = vec![[0.0; 3]];
assert!(extract_circle_instance(&wire, 0.0).is_none());
// 2. Clear fill_tris, add render_instance (block instance)
wire.fill_tris.clear();
wire.render_instance = Some(crate::scene::model::instance_model::RenderInstance {
source_id: 1,
translation: [0.0; 3],
});
assert!(extract_circle_instance(&wire, 0.0).is_none());
// 3. Invalid radius
wire.render_instance = None;
wire.tangent_geoms[0] = crate::scene::model::wire_model::TangentGeom::PlanarCircle {
center: [0.0, 0.0, 0.0],
axis_x: [1.0, 0.0, 0.0],
axis_y: [0.0, 1.0, 0.0],
radius: -5.0,
};
assert!(extract_circle_instance(&wire, 0.0).is_none());
}
}
+450
View File
@@ -0,0 +1,450 @@
//! GPU-instanced analytical ellipse buffer & pipeline layout.
//!
//! Renders 2D/planar ellipses and elliptical arcs as analytical screen/plane-aligned quads
//! with sub-pixel screen-space derivative anti-aliasing. Each ellipse is a single instance
//! (6 vertices for a 2-triangle quad), eliminating CPU re-tessellation on zoom and providing
//! mathematically exact curvature at any magnification.
use iced::wgpu;
#[repr(C)]
#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
pub struct EllipseInstance {
pub center_high: [f32; 3],
pub _pad0: f32,
pub center_low: [f32; 3],
pub minor_axis_ratio: f32,
pub major_axis: [f32; 3],
pub start_param: f32,
pub normal: [f32; 3],
pub end_param: f32,
pub color: [f32; 4],
/// `[half_width, pattern_length, draw_depth, padding]`
pub params: [f32; 4],
pub pat0: [f32; 4],
pub pat1: [f32; 4],
}
impl EllipseInstance {
pub fn layout<'a>() -> wgpu::VertexBufferLayout<'a> {
const ATTRS: &[wgpu::VertexAttribute] = &[
wgpu::VertexAttribute {
offset: std::mem::offset_of!(EllipseInstance, center_high) as u64,
shader_location: 0,
format: wgpu::VertexFormat::Float32x4,
},
wgpu::VertexAttribute {
offset: std::mem::offset_of!(EllipseInstance, center_low) as u64,
shader_location: 1,
format: wgpu::VertexFormat::Float32x4,
},
wgpu::VertexAttribute {
offset: std::mem::offset_of!(EllipseInstance, major_axis) as u64,
shader_location: 2,
format: wgpu::VertexFormat::Float32x4,
},
wgpu::VertexAttribute {
offset: std::mem::offset_of!(EllipseInstance, normal) as u64,
shader_location: 3,
format: wgpu::VertexFormat::Float32x4,
},
wgpu::VertexAttribute {
offset: std::mem::offset_of!(EllipseInstance, color) as u64,
shader_location: 4,
format: wgpu::VertexFormat::Float32x4,
},
wgpu::VertexAttribute {
offset: std::mem::offset_of!(EllipseInstance, params) as u64,
shader_location: 5,
format: wgpu::VertexFormat::Float32x4,
},
wgpu::VertexAttribute {
offset: std::mem::offset_of!(EllipseInstance, pat0) as u64,
shader_location: 6,
format: wgpu::VertexFormat::Float32x4,
},
wgpu::VertexAttribute {
offset: std::mem::offset_of!(EllipseInstance, pat1) as u64,
shader_location: 7,
format: wgpu::VertexFormat::Float32x4,
},
];
wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<EllipseInstance>() as u64,
step_mode: wgpu::VertexStepMode::Instance,
attributes: ATTRS,
}
}
}
pub struct EllipseGpu {
pub instance_buffer: wgpu::Buffer,
pub instance_count: u32,
}
impl EllipseGpu {
pub fn from_instances(
device: &wgpu::Device,
queue: &wgpu::Queue,
label: &str,
instances: &[EllipseInstance],
) -> Self {
let instance_buffer = super::gpu_upload::upload_buffer(
device,
queue,
label,
instances,
wgpu::BufferUsages::VERTEX,
);
Self {
instance_buffer,
instance_count: instances.len() as u32,
}
}
}
/// Build the base and xray ellipse pipelines.
pub fn create_pipelines(
device: &wgpu::Device,
frame_bgl: &wgpu::BindGroupLayout,
color_format: wgpu::TextureFormat,
sample_count: u32,
content_stencil: &wgpu::StencilState,
) -> (wgpu::RenderPipeline, wgpu::RenderPipeline) {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("ellipse.wgsl"),
source: wgpu::ShaderSource::Wgsl(include_str!("../../shaders/ellipse.wgsl").into()),
});
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("ellipse.pipeline.layout"),
bind_group_layouts: &[Some(frame_bgl)],
immediate_size: 0,
});
let ellipse_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("ellipse.pipeline"),
layout: Some(&layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_main"),
buffers: &[EllipseInstance::layout()],
compilation_options: Default::default(),
},
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs_main"),
targets: &[Some(wgpu::ColorTargetState {
format: color_format,
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
write_mask: wgpu::ColorWrites::ALL,
})],
compilation_options: Default::default(),
}),
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
..Default::default()
},
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth24PlusStencil8,
depth_write_enabled: Some(true),
depth_compare: Some(wgpu::CompareFunction::LessEqual),
stencil: content_stencil.clone(),
bias: wgpu::DepthBiasState::default(),
}),
multisample: wgpu::MultisampleState {
count: sample_count,
mask: !0,
alpha_to_coverage_enabled: false,
},
multiview_mask: None,
cache: None,
});
let ellipse_xray_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("ellipse_xray.pipeline"),
layout: Some(&layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_main"),
buffers: &[EllipseInstance::layout()],
compilation_options: Default::default(),
},
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs_main"),
targets: &[Some(wgpu::ColorTargetState {
format: color_format,
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
write_mask: wgpu::ColorWrites::ALL,
})],
compilation_options: Default::default(),
}),
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
..Default::default()
},
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth24PlusStencil8,
depth_write_enabled: Some(false),
depth_compare: Some(wgpu::CompareFunction::Always),
stencil: content_stencil.clone(),
bias: wgpu::DepthBiasState::default(),
}),
multisample: wgpu::MultisampleState {
count: sample_count,
mask: !0,
alpha_to_coverage_enabled: false,
},
multiview_mask: None,
cache: None,
});
(ellipse_pipeline, ellipse_xray_pipeline)
}
/// Helper to extract an analytical ellipse instance from a single `TangentGeom`.
pub fn extract_ellipse_instance_from_geom(
geom: &crate::scene::model::wire_model::TangentGeom,
wire: &crate::scene::WireModel,
draw_depth: f32,
) -> Option<EllipseInstance> {
let crate::scene::model::wire_model::TangentGeom::PlanarEllipse {
center,
major_axis,
normal,
minor_axis_ratio,
start_param,
end_param,
} = *geom else {
return None;
};
let major_len_sq = major_axis[0] * major_axis[0] + major_axis[1] * major_axis[1] + major_axis[2] * major_axis[2];
let norm_len_sq = normal[0] * normal[0] + normal[1] * normal[1] + normal[2] * normal[2];
if major_len_sq <= 1e-12 || !major_len_sq.is_finite() || major_len_sq > 1e12
|| norm_len_sq <= 1e-12 || !norm_len_sq.is_finite()
|| minor_axis_ratio <= 1e-6 || !minor_axis_ratio.is_finite()
|| !start_param.is_finite() || !end_param.is_finite()
{
return None;
}
let (ch, cl) = split_ds_xyz(center[0], center[1], center[2]);
let hw = wire.line_weight_px * 0.5;
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]];
Some(EllipseInstance {
center_high: ch,
_pad0: 0.0,
center_low: cl,
minor_axis_ratio: minor_axis_ratio.min(1.0) as f32,
major_axis: [major_axis[0] as f32, major_axis[1] as f32, major_axis[2] as f32],
start_param: start_param as f32,
normal: [normal[0] as f32, normal[1] as f32, normal[2] as f32],
end_param: end_param as f32,
color: wire.color,
params: [hw, wire.pattern_length, draw_depth, 0.0],
pat0,
pat1,
})
}
/// Helper to extract all analytical ellipse instances from a `WireModel`.
pub fn extract_ellipse_instances(
wire: &crate::scene::WireModel,
draw_depth: f32,
) -> Option<Vec<EllipseInstance>> {
if wire.tangent_geoms.is_empty()
|| !wire.fill_tris.is_empty()
|| !wire.pick_tris.is_empty()
|| !wire.text_verts.is_empty()
|| wire.render_instance.is_some()
{
return None;
}
let mut instances = Vec::with_capacity(wire.tangent_geoms.len());
for geom in &wire.tangent_geoms {
if let Some(inst) = extract_ellipse_instance_from_geom(geom, wire, draw_depth) {
instances.push(inst);
} else {
return None;
}
}
Some(instances)
}
/// Helper to extract a single analytical ellipse instance from a `WireModel`.
pub fn extract_ellipse_instance(
wire: &crate::scene::WireModel,
draw_depth: f32,
) -> Option<EllipseInstance> {
if wire.tangent_geoms.len() == 1 {
extract_ellipse_instances(wire, draw_depth).and_then(|mut v| v.pop())
} else {
None
}
}
fn split_ds_xyz(x: f64, y: f64, z: f64) -> ([f32; 3], [f32; 3]) {
let hx = x as f32;
let lx = (x - hx as f64) as f32;
let hy = y as f32;
let ly = (y - hy as f64) as f32;
let hz = z as f32;
let lz = (z - hz as f64) as f32;
([hx, hy, hz], [lx, ly, lz])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ellipse_instance_size_matches_stride() {
assert_eq!(std::mem::size_of::<EllipseInstance>(), 128);
}
#[test]
fn ellipse_shader_validates_with_naga() {
let source = include_str!("../../shaders/ellipse.wgsl");
let module = naga::front::wgsl::parse_str(source).expect("ellipse.wgsl parses cleanly");
let mut validator = naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
);
validator
.validate(&module)
.expect("ellipse.wgsl validates cleanly with naga");
}
#[test]
fn extract_ellipse_instance_success() {
let mut wire = crate::scene::WireModel::default();
wire.tangent_geoms.push(crate::scene::model::wire_model::TangentGeom::PlanarEllipse {
center: [100.0, 200.0, 300.0],
major_axis: [50.0, 0.0, 0.0],
normal: [0.0, 0.0, 1.0],
minor_axis_ratio: 0.5,
start_param: 0.0,
end_param: std::f64::consts::TAU,
});
wire.line_weight_px = 2.0;
wire.color = [1.0, 0.5, 0.0, 1.0];
let inst = extract_ellipse_instance(&wire, 1.0).expect("should extract ellipse");
assert_eq!(inst.center_high, [100.0, 200.0, 300.0]);
assert_eq!(inst.major_axis, [50.0, 0.0, 0.0]);
assert_eq!(inst.minor_axis_ratio, 0.5);
assert_eq!(inst.start_param, 0.0);
assert_eq!(inst.end_param, std::f32::consts::TAU);
assert_eq!(inst.color, [1.0, 0.5, 0.0, 1.0]);
assert_eq!(inst.params[0], 1.0);
assert_eq!(inst.params[2], 1.0);
}
#[test]
fn extract_elliptical_arc_instance_success() {
let mut wire = crate::scene::WireModel::default();
wire.tangent_geoms.push(crate::scene::model::wire_model::TangentGeom::PlanarEllipse {
center: [0.0, 0.0, 0.0],
major_axis: [10.0, 0.0, 0.0],
normal: [0.0, 0.0, 1.0],
minor_axis_ratio: 0.8,
start_param: 0.5,
end_param: 2.5,
});
wire.line_weight_px = 1.0;
wire.color = [0.0, 1.0, 0.0, 1.0];
let inst = extract_ellipse_instance(&wire, 0.5).expect("should extract elliptical arc");
assert_eq!(inst.minor_axis_ratio, 0.8);
assert_eq!(inst.start_param, 0.5);
assert_eq!(inst.end_param, 2.5);
assert_eq!(inst.params[0], 0.5); // half-width
assert_eq!(inst.params[2], 0.5); // draw-depth
}
fn rust_closest_point_ellipse(p_in: [f32; 2], ab_in: [f32; 2]) -> [f32; 2] {
let px = p_in[0].abs();
let py = p_in[1].abs();
let a = ab_in[0];
let b = ab_in[1];
if (a - b).abs() < 1e-6 {
let len = (px * px + py * py).sqrt();
let q = if len > 1e-6 {
[px / len * a, py / len * a]
} else {
[a, 0.0]
};
return [
if p_in[0] >= 0.0 { q[0] } else { -q[0] },
if p_in[1] >= 0.0 { q[1] } else { -q[1] },
];
}
// Initial estimate from eccentric anomaly of radial direction
let mut t = (py * a).atan2(px * b);
let k = a * a - b * b;
// 3 iterations of Newton-Raphson
for _ in 0..3 {
let (st, ct) = t.sin_cos();
let f = k * st * ct - px * a * st + py * b * ct;
let f_prime = k * (ct * ct - st * st) - px * a * ct - py * b * st;
if f_prime.abs() > 1e-7 {
t -= f / f_prime;
}
}
t = t.clamp(0.0, std::f32::consts::FRAC_PI_2);
let q = [a * t.cos(), b * t.sin()];
[
if p_in[0] >= 0.0 { q[0] } else { -q[0] },
if p_in[1] >= 0.0 { q[1] } else { -q[1] },
]
}
#[test]
fn closest_point_ellipse_accuracy() {
let a = 100.0f32;
let b = 40.0f32;
// Test points directly on the ellipse: closest point should be itself!
for i in 0..36 {
let angle = (i as f32) * std::f32::consts::TAU / 36.0;
let px = a * angle.cos();
let py = b * angle.sin();
let q = rust_closest_point_ellipse([px, py], [a, b]);
let dist = ((q[0] - px).powi(2) + (q[1] - py).powi(2)).sqrt();
assert!(
dist < 0.2,
"Point on ellipse at angle {angle} failed: px={px}, py={py}, q={:?}, dist={dist}",
q
);
// Verify q is on the ellipse (x/a)^2 + (y/b)^2 == 1
let eq = (q[0] / a).powi(2) + (q[1] / b).powi(2);
assert!((eq - 1.0).abs() < 0.01, "eq={eq} for angle {angle}");
}
// Test points offset from ellipse (inside and outside)
for i in 0..36 {
let angle = (i as f32) * std::f32::consts::TAU / 36.0;
for offset in [-5.0f32, -1.0, 1.0, 5.0] {
let norm = [angle.cos() / a, angle.sin() / b];
let norm_len = (norm[0] * norm[0] + norm[1] * norm[1]).sqrt();
let un = [norm[0] / norm_len, norm[1] / norm_len];
let px = a * angle.cos() + offset * un[0];
let py = b * angle.sin() + offset * un[1];
let q = rust_closest_point_ellipse([px, py], [a, b]);
let eq = (q[0] / a).powi(2) + (q[1] / b).powi(2);
assert!(
(eq - 1.0).abs() < 0.05,
"Offset point at angle {angle}, offset {offset}: q={:?}, eq={eq}",
q
);
let measured_dist = ((q[0] - px).powi(2) + (q[1] - py).powi(2)).sqrt();
assert!(
(measured_dist - offset.abs()).abs() < 0.2,
"Distance error at angle {angle}, offset {offset}: measured={measured_dist}"
);
}
}
}
}
+300
View File
@@ -408,3 +408,303 @@ fn late_device_errors_invalidate_uploaded_content_and_targets() {
"the same error must not invalidate twice"
);
}
#[test]
#[ignore = "requires a GPU adapter"]
fn test_selected_circle_arc_ellipse_highlight_overlay() {
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);
let depth = rustc_hash::FxHashMap::default();
let mut circle_wire = WireModel::default();
circle_wire.name = "circle_wire".into();
circle_wire.tangent_geoms.push(crate::scene::model::wire_model::TangentGeom::PlanarCircle {
center: [0.0, 0.0, 0.0],
axis_x: [1.0, 0.0, 0.0],
axis_y: [0.0, 1.0, 0.0],
radius: 10.0,
});
let handle = acadrust::Handle::new(100);
let mut selected_handles = rustc_hash::FxHashSet::default();
selected_handles.insert(handle);
let hover_handles = rustc_hash::FxHashSet::default();
let mut index = rustc_hash::FxHashMap::default();
index.insert(handle.value(), vec![0]);
pipeline.wire_handle_index = std::sync::Arc::new(index);
let wires = vec![circle_wire];
pipeline.upload_selected_wires(
&device,
&queue,
&wires,
&selected_handles,
&hover_handles,
&[],
&depth,
Some([0.0, 1.0, 1.0, 1.0]),
);
assert_eq!(pipeline.gpu_selected_circles.len(), 1);
assert_eq!(pipeline.gpu_selected_circles[0].instance_count, 1);
assert!(pipeline.gpu_selected_wires.is_empty());
// Verify classic highlight mode (SELECTIONEFFECT 0 / selected_tint: None)
pipeline.upload_selected_wires(
&device,
&queue,
&wires,
&selected_handles,
&hover_handles,
&[],
&depth,
None,
);
assert_eq!(pipeline.gpu_selected_circles.len(), 1);
assert_eq!(pipeline.gpu_selected_circles[0].instance_count, 1);
// Verify hover highlight
let mut circle_hover = rustc_hash::FxHashSet::default();
circle_hover.insert(handle);
let empty_selected = rustc_hash::FxHashSet::default();
pipeline.upload_selected_wires(
&device,
&queue,
&wires,
&empty_selected,
&circle_hover,
&[],
&depth,
None,
);
assert_eq!(pipeline.gpu_selected_circles.len(), 1);
assert_eq!(pipeline.gpu_selected_circles[0].instance_count, 1);
pipeline.ensure_depth_texture(&device, iced::Size::new(512, 512));
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("test_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("test_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() {
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);
let depth = rustc_hash::FxHashMap::default();
let mut ellipse_wire = WireModel::default();
ellipse_wire.name = "ellipse_wire".into();
ellipse_wire.tangent_geoms.push(crate::scene::model::wire_model::TangentGeom::PlanarEllipse {
center: [0.0, 0.0, 0.0],
major_axis: [10.0, 0.0, 0.0],
normal: [0.0, 0.0, 1.0],
minor_axis_ratio: 0.5,
start_param: 0.0,
end_param: std::f64::consts::TAU,
});
let handle = acadrust::Handle::new(200);
let mut selected_handles = rustc_hash::FxHashSet::default();
selected_handles.insert(handle);
let hover_handles = rustc_hash::FxHashSet::default();
let mut index = rustc_hash::FxHashMap::default();
index.insert(handle.value(), vec![0]);
pipeline.wire_handle_index = std::sync::Arc::new(index);
let wires = vec![ellipse_wire];
// Standard tint
pipeline.upload_selected_wires(
&device,
&queue,
&wires,
&selected_handles,
&hover_handles,
&[],
&depth,
Some([0.0, 1.0, 1.0, 1.0]),
);
assert_eq!(pipeline.gpu_selected_ellipses.len(), 1);
assert_eq!(pipeline.gpu_selected_ellipses[0].instance_count, 1);
assert!(pipeline.gpu_selected_wires.is_empty());
// Classic highlight mode (SELECTIONEFFECT 0 / selected_tint: None)
pipeline.upload_selected_wires(
&device,
&queue,
&wires,
&selected_handles,
&hover_handles,
&[],
&depth,
None,
);
assert_eq!(pipeline.gpu_selected_ellipses.len(), 1);
assert_eq!(pipeline.gpu_selected_ellipses[0].instance_count, 1);
// Hover highlight
let mut ellipse_hover = rustc_hash::FxHashSet::default();
ellipse_hover.insert(handle);
let empty_selected = rustc_hash::FxHashSet::default();
pipeline.upload_selected_wires(
&device,
&queue,
&wires,
&empty_selected,
&ellipse_hover,
&[],
&depth,
None,
);
assert_eq!(pipeline.gpu_selected_ellipses.len(), 1);
assert_eq!(pipeline.gpu_selected_ellipses[0].instance_count, 1);
pipeline.ensure_depth_texture(&device, iced::Size::new(512, 512));
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("test_target_ellipse"),
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("test_encoder_ellipse"),
});
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_pline_arc_switch_preview_and_render() {
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);
let depth = rustc_hash::FxHashMap::default();
use crate::command::CadCommand;
let mut cmd = crate::modules::draw::draw::polyline::PlineCommand::new();
cmd.on_point(glam::DVec3::new(0.0, 0.0, 0.0));
let _res = cmd.on_point(glam::DVec3::new(10.0, 0.0, 0.0));
cmd.set_live_handle(acadrust::Handle::new(1));
cmd.on_text_input("A");
let test_points = [
glam::DVec3::new(10.0, 0.0, 0.0),
glam::DVec3::new(10.0000001, 0.0, 0.0),
glam::DVec3::new(10.0, 0.0000001, 0.0),
glam::DVec3::new(10.0, -0.0000001, 0.0),
glam::DVec3::new(9.9999999, 0.0, 0.0),
glam::DVec3::new(15.0, 5.0, 0.0),
glam::DVec3::new(10.0, 10.0, 0.0),
glam::DVec3::new(5.0, 5.0, 0.0),
glam::DVec3::new(0.0, 0.0, 0.0),
];
pipeline.ensure_depth_texture(&device, iced::Size::new(512, 512));
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("test_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());
for pt in test_points {
let wire = cmd.on_mouse_move(pt);
if let Some(w) = wire {
pipeline.upload_preview_wires(&device, &queue, &[w], &depth);
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("test_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();
}
}
}
+372 -41
View File
@@ -1,6 +1,8 @@
#[cfg(test)]
mod gpu_tests;
mod device_capabilities;
pub mod circle_gpu;
pub mod ellipse_gpu;
pub mod face3d_gpu;
pub mod gpu_budget;
pub mod gpu_upload;
@@ -20,6 +22,8 @@ pub mod wire_gpu;
use iced::wgpu;
use iced::{Rectangle, Size};
pub use circle_gpu::{CircleGpu, CircleInstance};
pub use ellipse_gpu::{EllipseGpu, EllipseInstance};
pub use face3d_gpu::Face3DGpu;
pub use wipeout_gpu::WipeoutGpu;
pub use image_gpu::ImageGpu;
@@ -79,6 +83,8 @@ pub struct Pipeline {
shadow_pipeline: wgpu::RenderPipeline,
shadow_plain_pipeline: wgpu::RenderPipeline,
wire_pipeline: wgpu::RenderPipeline,
circle_pipeline: wgpu::RenderPipeline,
ellipse_pipeline: wgpu::RenderPipeline,
block_wire_pipeline: wgpu::RenderPipeline,
/// Stamps clip-boundary polygons into the stencil buffer (viewports + XCLIP).
clip_mask_pipeline: wgpu::RenderPipeline,
@@ -89,6 +95,8 @@ pub struct Pipeline {
/// Same shader as wire_pipeline but depth_compare=Greater, depth_write_enabled=false.
/// Used to draw ghost copies of selected wires through occluding geometry.
wire_xray_pipeline: wgpu::RenderPipeline,
circle_xray_pipeline: wgpu::RenderPipeline,
ellipse_xray_pipeline: wgpu::RenderPipeline,
block_wire_xray_pipeline: wgpu::RenderPipeline,
/// Layout for the per-wire `WireConst` storage buffer (group 1 of the wire /
/// xray pipelines). `Some` on any storage-capable device; `None` in packed
@@ -225,6 +233,8 @@ pub struct Pipeline {
/// replace this thin draw-range list on camera changes without touching the
/// shared resident buffer.
pub(crate) gpu_wires: std::sync::Arc<Vec<WireGpu>>,
pub(crate) gpu_circles: std::sync::Arc<Vec<CircleGpu>>,
pub(crate) gpu_ellipses: std::sync::Arc<Vec<EllipseGpu>>,
pub(crate) gpu_block_wires: std::sync::Arc<Vec<BlockWireGpu>>,
/// Persistent per-entity wire instance arena (capability-selected format).
/// When active, `gpu_wires` is a thin wrapper over this arena's buffers and an
@@ -241,6 +251,11 @@ pub struct Pipeline {
pub(crate) wire_arena_fallback_handles: rustc_hash::FxHashSet<acadrust::Handle>,
/// The Model content id both arenas currently mirror (`u64::MAX` = none).
pub(crate) wire_arena_id: u64,
/// Handles that contributed to this slot's retained analytical uploads.
pub(crate) partition_contributors: rustc_hash::FxHashSet<acadrust::Handle>,
/// The draw-depth generation those uploads baked. A full depth rebuild
/// reassigns every label, so they stop being reusable when it moves.
pub(crate) partition_depth_generation: u64,
/// Last content/camera/viewport tuple used to derive visible instance
/// ranges from the resident arena.
pub(crate) wire_cull_key: (u64, u64, u32, u32),
@@ -256,11 +271,15 @@ pub struct Pipeline {
clip_boundary: Option<(wgpu::Buffer, u32)>,
/// Ghost copies (25% alpha) of selected wires for the X-ray depth pass.
gpu_selected_wires: Vec<WireGpu>,
gpu_selected_circles: Vec<CircleGpu>,
gpu_selected_ellipses: Vec<EllipseGpu>,
gpu_selected_block_wires: Vec<BlockWireGpu>,
/// Command-preview / interim / grip-drag overlay wires. Re-uploaded every
/// frame they are present (small), drawn on top of the base wire pass — so
/// a live drag never re-uploads the resident base buffer.
gpu_preview_wires: Vec<WireGpu>,
gpu_preview_circles: Vec<CircleGpu>,
gpu_preview_ellipses: Vec<EllipseGpu>,
/// Wipeout masks — solid fills rendered after wires in a separate pass via
/// the legacy per-primitive `WipeoutGpu` renderer.
gpu_wipeouts: Vec<WipeoutGpu>,
@@ -2083,6 +2102,22 @@ impl Pipeline {
&content_stencil,
);
let (circle_pipeline, circle_xray_pipeline) = circle_gpu::create_pipelines(
device,
&frame_bgl,
format,
MSAA_SAMPLES,
&content_stencil,
);
let (ellipse_pipeline, ellipse_xray_pipeline) = ellipse_gpu::create_pipelines(
device,
&frame_bgl,
format,
MSAA_SAMPLES,
&content_stencil,
);
let viewcube = ViewCubePipeline::new(device, queue, format);
let init_size = Size::new(1, 1);
@@ -2223,6 +2258,10 @@ impl Pipeline {
wire_black_pipeline,
block_wire_black_pipeline,
wire_xray_pipeline,
circle_pipeline,
circle_xray_pipeline,
ellipse_pipeline,
ellipse_xray_pipeline,
block_wire_xray_pipeline,
wire_const_bgl,
block_wire_const_bgl,
@@ -2295,6 +2334,8 @@ impl Pipeline {
blit_uniform_buffer,
surface_format: format,
gpu_wires: std::sync::Arc::new(vec![]),
gpu_circles: std::sync::Arc::new(vec![]),
gpu_ellipses: std::sync::Arc::new(vec![]),
gpu_block_wires: std::sync::Arc::new(vec![]),
wire_arena: None,
wire_arena_mesh: None,
@@ -2302,14 +2343,20 @@ impl Pipeline {
wire_arena_fallback_kind: None,
wire_arena_fallback_handles: rustc_hash::FxHashSet::default(),
wire_arena_id: u64::MAX,
partition_contributors: rustc_hash::FxHashSet::default(),
partition_depth_generation: u64::MAX,
wire_cull_key: (u64::MAX, u64::MAX, 0, 0),
hatch_lod_key: (usize::MAX, u64::MAX, 0, 0, false),
wipeout_lod_key: (usize::MAX, u64::MAX, 0, 0, false),
silhouette_key: (usize::MAX, u64::MAX, [u32::MAX; 3], false),
clip_boundary: None,
gpu_selected_wires: vec![],
gpu_selected_circles: vec![],
gpu_selected_ellipses: vec![],
gpu_selected_block_wires: vec![],
gpu_preview_wires: vec![],
gpu_preview_circles: vec![],
gpu_preview_ellipses: vec![],
gpu_wipeouts: vec![],
wipeout_skip_flags: vec![],
gpu_images: vec![],
@@ -2371,6 +2418,86 @@ impl Pipeline {
)
}
/// Upload GPU-instanced analytical circles from a wire slice.
pub fn upload_circles(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
wires: &[WireModel],
depth_map: &rustc_hash::FxHashMap<u64, [f32; 2]>,
) -> Vec<CircleGpu> {
let mut instances: Vec<CircleInstance> = Vec::new();
for wire in wires {
if !wire.display_visible {
continue;
}
let depth = wire_gpu::wire_draw_depth(wire, depth_map);
if let Some(insts) = circle_gpu::extract_circle_instances(wire, depth) {
instances.extend(insts);
}
}
self.upload_circles_from_instances(device, queue, &instances)
}
/// Upload GPU-instanced analytical circles from pre-extracted instances.
pub fn upload_circles_from_instances(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
instances: &[CircleInstance],
) -> Vec<CircleGpu> {
if instances.is_empty() {
vec![]
} else {
vec![CircleGpu::from_instances(
device,
queue,
"viewer.circles",
instances,
)]
}
}
/// Upload GPU-instanced analytical ellipses from a wire slice.
pub fn upload_ellipses(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
wires: &[WireModel],
depth_map: &rustc_hash::FxHashMap<u64, [f32; 2]>,
) -> Vec<EllipseGpu> {
let mut instances: Vec<EllipseInstance> = Vec::new();
for wire in wires {
if !wire.display_visible {
continue;
}
let depth = wire_gpu::wire_draw_depth(wire, depth_map);
if let Some(insts) = ellipse_gpu::extract_ellipse_instances(wire, depth) {
instances.extend(insts);
}
}
self.upload_ellipses_from_instances(device, queue, &instances)
}
/// Upload GPU-instanced analytical ellipses from pre-extracted instances.
pub fn upload_ellipses_from_instances(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
instances: &[EllipseInstance],
) -> Vec<EllipseGpu> {
if instances.is_empty() {
vec![]
} else {
vec![EllipseGpu::from_instances(
device,
queue,
"viewer.ellipses",
instances,
)]
}
}
/// Build resident batches and a handle index shared across viewport slots.
pub fn build_wire_buffers(
&mut self,
@@ -2383,6 +2510,8 @@ impl Pipeline {
std::sync::Arc<Vec<WireGpu>>,
std::sync::Arc<Vec<BlockWireGpu>>,
std::sync::Arc<rustc_hash::FxHashMap<u64, Vec<u32>>>,
std::sync::Arc<Vec<CircleGpu>>,
std::sync::Arc<Vec<EllipseGpu>>,
) {
// Batch the wire pass: instead of one GPU buffer + one draw call per
// WireModel (tens of thousands on a large drawing), merge maximal runs
@@ -2392,40 +2521,42 @@ impl Pipeline {
// wire order — already sorted by draw order — is preserved; depth bias
// and alpha blending both depend on it. Scissor and mesh-edge stay
// grouping keys because the draw loop sets one scissor per batch and
// skips whole mesh-edge batches in shaded modes.
let is_mesh_edge = |w: &WireModel| !w.points.is_empty() && w.fill_is_3d;
let mut batches: Vec<WireGpu> = Vec::new();
let mut block_wires: Vec<&WireModel> = Vec::new();
let mut i = 0;
while i < wires.len() {
if !wires[i].display_visible {
i += 1;
continue;
let kind = wire_arena::classify_wire(&wires[i]);
match kind {
wire_arena::WireKind::Invisible
| wire_arena::WireKind::Circle
| wire_arena::WireKind::Ellipse
| wire_arena::WireKind::Empty => {
i += 1;
continue;
}
wire_arena::WireKind::Block => {
block_wires.push(&wires[i]);
i += 1;
continue;
}
wire_arena::WireKind::Regular | wire_arena::WireKind::MeshEdge => {
let mesh_edge = kind == wire_arena::WireKind::MeshEdge;
let mut j = i + 1;
while j < wires.len() && wire_arena::classify_wire(&wires[j]) == kind {
j += 1;
}
let refs: Vec<&WireModel> = wires[i..j].iter().collect();
batches.extend(WireGpu::from_run_refs(
device,
queue,
&refs,
depth_map,
mesh_edge,
self.wire_const_bgl.as_ref(),
));
i = j;
}
}
if wires[i].render_instance.is_some() {
block_wires.push(&wires[i]);
i += 1;
continue;
}
let mesh_edge = is_mesh_edge(&wires[i]);
let mut j = i + 1;
while j < wires.len()
&& wires[j].display_visible
&& wires[j].render_instance.is_none()
&& is_mesh_edge(&wires[j]) == mesh_edge
{
j += 1;
}
let refs: Vec<&WireModel> = wires[i..j].iter().collect();
batches.extend(WireGpu::from_run_refs(
device,
queue,
&refs,
depth_map,
mesh_edge,
self.wire_const_bgl.as_ref(),
));
i = j;
}
let block_batches = BlockWireGpu::from_wires(
device,
@@ -2462,10 +2593,14 @@ impl Pipeline {
wires.len(),
);
}
let circle_batches = self.upload_circles(device, queue, wires, depth_map);
let ellipse_batches = self.upload_ellipses(device, queue, wires, depth_map);
(
std::sync::Arc::new(batches),
std::sync::Arc::new(block_batches),
std::sync::Arc::new(index),
std::sync::Arc::new(circle_batches),
std::sync::Arc::new(ellipse_batches),
)
}
@@ -2492,6 +2627,8 @@ impl Pipeline {
if selected.is_empty() && hovered.is_empty() && annotation_context_wires.is_empty() {
self.gpu_selected_wires = vec![];
self.gpu_selected_block_wires = vec![];
self.gpu_selected_circles = vec![];
self.gpu_selected_ellipses = vec![];
return;
}
// Gather borrowed highlighted wires via the prebuilt index —
@@ -2504,6 +2641,9 @@ impl Pipeline {
slots.sort_unstable();
for &i in &slots {
if let Some(w) = wires.get(i as usize) {
if !w.display_visible {
continue;
}
selected_wires.push(w);
}
}
@@ -2515,27 +2655,103 @@ impl Pipeline {
slots.sort_unstable();
for &i in &slots {
if let Some(w) = wires.get(i as usize) {
if !w.display_visible {
continue;
}
hover_wires.push(w);
}
}
}
}
for wire in annotation_context_wires {
if !wire.display_visible {
continue;
}
if wire.selected {
selected_wires.push(wire);
} else {
hover_wires.push(wire);
}
}
let mut selected_circles: Vec<CircleInstance> = Vec::new();
let circle_sel_tint = selected_tint.unwrap_or(WireModel::SELECTED);
for &wire in &selected_wires {
let depth = wire_gpu::wire_draw_depth(wire, depth_map);
if let Some(insts) = circle_gpu::extract_circle_instances(wire, depth) {
for mut inst in insts {
inst.color = circle_sel_tint;
selected_circles.push(inst);
}
}
}
for &wire in &hover_wires {
let depth = wire_gpu::wire_draw_depth(wire, depth_map);
if let Some(insts) = circle_gpu::extract_circle_instances(wire, depth) {
for mut inst in insts {
inst.color = WireModel::HOVER;
selected_circles.push(inst);
}
}
}
self.gpu_selected_circles = if selected_circles.is_empty() {
vec![]
} else {
vec![CircleGpu::from_instances(
device,
queue,
"viewer.selected_circles",
&selected_circles,
)]
};
let mut selected_ellipses: Vec<EllipseInstance> = Vec::new();
let ellipse_sel_tint = selected_tint.unwrap_or(WireModel::SELECTED);
for &wire in &selected_wires {
let depth = wire_gpu::wire_draw_depth(wire, depth_map);
if let Some(insts) = ellipse_gpu::extract_ellipse_instances(wire, depth) {
for mut inst in insts {
inst.color = ellipse_sel_tint;
selected_ellipses.push(inst);
}
}
}
for &wire in &hover_wires {
let depth = wire_gpu::wire_draw_depth(wire, depth_map);
if let Some(insts) = ellipse_gpu::extract_ellipse_instances(wire, depth) {
for mut inst in insts {
inst.color = WireModel::HOVER;
selected_ellipses.push(inst);
}
}
}
self.gpu_selected_ellipses = if selected_ellipses.is_empty() {
vec![]
} else {
vec![EllipseGpu::from_instances(
device,
queue,
"viewer.selected_ellipses",
&selected_ellipses,
)]
};
let selected_regular: Vec<&WireModel> = selected_wires
.iter()
.copied()
.filter(|wire| wire.render_instance.is_none())
.filter(|wire| {
wire.render_instance.is_none()
&& circle_gpu::extract_circle_instances(wire, 0.0).is_none()
&& ellipse_gpu::extract_ellipse_instances(wire, 0.0).is_none()
})
.collect();
let hover_regular: Vec<&WireModel> = hover_wires
.iter()
.copied()
.filter(|wire| wire.render_instance.is_none())
.filter(|wire| {
wire.render_instance.is_none()
&& circle_gpu::extract_circle_instances(wire, 0.0).is_none()
&& ellipse_gpu::extract_ellipse_instances(wire, 0.0).is_none()
})
.collect();
let selected_blocks: Vec<&WireModel> = selected_wires
.iter()
@@ -2742,11 +2958,47 @@ impl Pipeline {
wires: &[WireModel],
depth_map: &rustc_hash::FxHashMap<u64, [f32; 2]>,
) {
self.gpu_preview_wires = if wires.is_empty() {
vec![]
if wires.is_empty() {
self.gpu_preview_wires.clear();
self.gpu_preview_circles.clear();
self.gpu_preview_ellipses.clear();
} else {
WireGpu::from_run(device, queue, wires, depth_map, false, self.wire_const_bgl.as_ref())
};
let partitioned = wire_arena::partition_wires(wires, depth_map);
let mut non_analytical = partitioned.regular;
non_analytical.extend(partitioned.mesh);
self.gpu_preview_wires = if non_analytical.is_empty() {
vec![]
} else {
WireGpu::from_run_refs(
device,
queue,
&non_analytical,
depth_map,
false,
self.wire_const_bgl.as_ref(),
)
};
self.gpu_preview_circles = if partitioned.circle_instances.is_empty() {
vec![]
} else {
vec![CircleGpu::from_instances(
device,
queue,
"preview.circles",
&partitioned.circle_instances,
)]
};
self.gpu_preview_ellipses = if partitioned.ellipse_instances.is_empty() {
vec![]
} else {
vec![EllipseGpu::from_instances(
device,
queue,
"preview.ellipses",
&partitioned.ellipse_instances,
)]
};
}
}
/// Upload the live grip-drag / command-preview SDF glyph quads. Re-uploaded
@@ -4373,6 +4625,26 @@ impl Pipeline {
}
bind_and_draw_block_wire(&mut pass, wire);
}
// Analytical GPU circles (instanced screen-space quads)
if self.gpu_circles.iter().any(|cg| cg.instance_count > 0) {
pass.set_pipeline(&self.circle_pipeline);
for cg in self.gpu_circles.iter() {
if cg.instance_count > 0 {
pass.set_vertex_buffer(0, cg.instance_buffer.slice(..));
pass.draw(0..6, 0..cg.instance_count);
}
}
}
// Analytical GPU ellipses (instanced screen-space quads)
if self.gpu_ellipses.iter().any(|eg| eg.instance_count > 0) {
pass.set_pipeline(&self.ellipse_pipeline);
for eg in self.gpu_ellipses.iter() {
if eg.instance_count > 0 {
pass.set_vertex_buffer(0, eg.instance_buffer.slice(..));
pass.draw(0..6, 0..eg.instance_count);
}
}
}
// Live overlay wires (command preview / interim / grip drag) always
// on top: the xray pipeline (depth_compare=Always, no depth write)
// keeps them visible through any occluding geometry — a 3D solid, or
@@ -4393,6 +4665,24 @@ impl Pipeline {
}
}
}
if self.gpu_preview_circles.iter().any(|cg| cg.instance_count > 0) {
pass.set_pipeline(&self.circle_xray_pipeline);
for cg in &self.gpu_preview_circles {
if cg.instance_count > 0 {
pass.set_vertex_buffer(0, cg.instance_buffer.slice(..));
pass.draw(0..6, 0..cg.instance_count);
}
}
}
if self.gpu_preview_ellipses.iter().any(|eg| eg.instance_count > 0) {
pass.set_pipeline(&self.ellipse_xray_pipeline);
for eg in &self.gpu_preview_ellipses {
if eg.instance_count > 0 {
pass.set_vertex_buffer(0, eg.instance_buffer.slice(..));
pass.draw(0..6, 0..eg.instance_count);
}
}
}
}
// ── Pass 5c: SDF text quads (drawn over wires) ────────────────────
@@ -4503,6 +4793,8 @@ impl Pipeline {
|| !self.block_text_highlight_gpu.is_empty();
if !self.gpu_selected_wires.is_empty()
|| !self.gpu_selected_block_wires.is_empty()
|| !self.gpu_selected_circles.is_empty()
|| !self.gpu_selected_ellipses.is_empty()
|| have_text_highlight
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
@@ -4563,6 +4855,34 @@ impl Pipeline {
bind_and_draw_block_wire(&mut pass, wire);
}
}
if !self.gpu_selected_circles.is_empty() {
pass.set_pipeline(if hidden_line {
&self.circle_pipeline
} else {
&self.circle_xray_pipeline
});
pass.set_bind_group(0, &self.uniform_bind_group, &[]);
for cg in &self.gpu_selected_circles {
if cg.instance_count > 0 {
pass.set_vertex_buffer(0, cg.instance_buffer.slice(..));
pass.draw(0..6, 0..cg.instance_count);
}
}
}
if !self.gpu_selected_ellipses.is_empty() {
pass.set_pipeline(if hidden_line {
&self.ellipse_pipeline
} else {
&self.ellipse_xray_pipeline
});
pass.set_bind_group(0, &self.uniform_bind_group, &[]);
for eg in &self.gpu_selected_ellipses {
if eg.instance_count > 0 {
pass.set_vertex_buffer(0, eg.instance_buffer.slice(..));
pass.draw(0..6, 0..eg.instance_count);
}
}
}
if let Some(atlas) = &self.text_atlas_gpu {
if !self.text_highlight_gpu.is_empty() {
pass.set_pipeline(&self.text_highlight_pipeline);
@@ -4648,12 +4968,7 @@ impl Pipeline {
}
}
/// Forget every cache key that describes this slot's uploaded content.
///
/// Shared by slot reuse and by [`Self::release_heavy_resources`] so the two
/// cannot disagree about what "this slot holds nothing you can trust"
/// means. Keys only — the buffers themselves are dropped by the caller that
/// wants the memory back.
/// Reset every cache key that describes this slot's uploaded content.
pub(crate) fn forget_cached_keys(&mut self) {
self.cached_epoch = (u64::MAX, u64::MAX, u64::MAX);
self.cached_wire_id = u64::MAX;
@@ -4676,6 +4991,9 @@ impl Pipeline {
self.silhouette_key = (usize::MAX, u64::MAX, [u32::MAX; 3], false);
self.silhouette_source_key = (usize::MAX, usize::MAX, u64::MAX);
self.render_sig = u64::MAX;
// The retained-upload record is part of the slot's cached state.
self.partition_contributors.clear();
self.partition_depth_generation = u64::MAX;
}
/// Catch errors delivered after the previous upload/submit completed.
@@ -4689,6 +5007,8 @@ impl Pipeline {
self.wire_arena = None;
self.wire_arena_mesh = None;
self.wire_arena_id = u64::MAX;
self.partition_contributors.clear();
self.partition_depth_generation = u64::MAX;
self.alloc_size = Size::new(0, 0);
self.shadow_full = None;
self.background_source_id = usize::MAX;
@@ -4711,6 +5031,8 @@ impl Pipeline {
self.gpu_wires = std::sync::Arc::new(Vec::new());
self.gpu_block_wires = std::sync::Arc::new(Vec::new());
self.gpu_circles = std::sync::Arc::new(Vec::new());
self.gpu_ellipses = std::sync::Arc::new(Vec::new());
self.wire_handle_index = std::sync::Arc::new(rustc_hash::FxHashMap::default());
self.wire_arena = None;
self.wire_arena_mesh = None;
@@ -4718,10 +5040,16 @@ impl Pipeline {
self.wire_arena_fallback_kind = None;
self.wire_arena_fallback_handles.clear();
self.wire_arena_id = u64::MAX;
self.partition_contributors.clear();
self.partition_depth_generation = u64::MAX;
self.gpu_selected_wires.clear();
self.gpu_selected_block_wires.clear();
self.gpu_selected_circles.clear();
self.gpu_selected_ellipses.clear();
self.gpu_preview_wires.clear();
self.gpu_preview_circles.clear();
self.gpu_preview_ellipses.clear();
self.hatch_gpu.clear();
self.gpu_wipeouts.clear();
self.wipeout_skip_flags.clear();
@@ -4990,6 +5318,7 @@ impl MultiPipeline {
self.wire_buffer_cache.retain(|_, entry| {
std::sync::Arc::strong_count(&entry.0) > 1
|| std::sync::Arc::strong_count(&entry.1) > 1
|| std::sync::Arc::strong_count(&entry.3) > 1
});
self.block_geometry.retain(|_, chunks| {
chunks.iter().any(|chunk| std::sync::Arc::strong_count(&chunk.bind_group) > 1)
@@ -5140,6 +5469,8 @@ pub struct MultiPipeline {
std::sync::Arc<Vec<WireGpu>>,
std::sync::Arc<Vec<BlockWireGpu>>,
std::sync::Arc<rustc_hash::FxHashMap<u64, Vec<u32>>>,
std::sync::Arc<Vec<CircleGpu>>,
std::sync::Arc<Vec<EllipseGpu>>,
),
>,
}
+187 -6
View File
@@ -120,15 +120,152 @@ fn handle_of(w: &WireModel) -> Option<Handle> {
crate::scene::Scene::handle_from_wire_name(&w.name)
}
/// Classification of a wire model for rendering pipelines.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum WireKind {
Invisible,
Block,
Circle,
Ellipse,
Regular,
MeshEdge,
Empty,
}
/// Fast, branch-predicted classification of a wire model.
///
/// Bypasses analytical circle/ellipse extraction for the 95%+ of wires that have
/// no tangent geometry or multiple tangent primitives, reducing overhead to ~2 ns.
#[inline]
pub fn classify_wire(wire: &WireModel) -> WireKind {
if !wire.display_visible {
return WireKind::Invisible;
}
if wire.render_instance.is_some() {
return WireKind::Block;
}
if !wire.tangent_geoms.is_empty()
&& wire.fill_tris.is_empty()
&& wire.pick_tris.is_empty()
&& wire.text_verts.is_empty()
{
if super::circle_gpu::extract_circle_instances(wire, 0.0).is_some() {
return WireKind::Circle;
}
if super::ellipse_gpu::extract_ellipse_instances(wire, 0.0).is_some() {
return WireKind::Ellipse;
}
}
if !wire.points.is_empty() {
if wire.fill_is_3d {
WireKind::MeshEdge
} else {
WireKind::Regular
}
} else {
WireKind::Empty
}
}
/// Result of a single-pass partitioning of viewport wires.
pub struct PartitionedWires<'a> {
pub regular: Vec<&'a WireModel>,
pub mesh: Vec<&'a WireModel>,
pub instanced: Vec<&'a WireModel>,
pub circle_instances: Vec<super::circle_gpu::CircleInstance>,
pub ellipse_instances: Vec<super::ellipse_gpu::EllipseInstance>,
/// Handles that contributed to the retained analytical uploads.
pub contributors: rustc_hash::FxHashSet<Handle>,
}
/// Whether this wire contributes to an upload retained across arena patches.
pub fn feeds_analytical_uploads(wire: &WireModel) -> bool {
matches!(
classify_wire(wire),
WireKind::Block | WireKind::Circle | WireKind::Ellipse
)
}
/// Single-pass classification and extraction of all viewport wire categories.
///
/// Traverses the wire slice exactly once, eliminating redundant passes and duplicate
/// analytical extractions across split_wires, upload_block_wires, upload_circles,
/// and upload_ellipses.
pub fn partition_wires<'a>(
wires: &'a [WireModel],
depth_map: &rustc_hash::FxHashMap<u64, [f32; 2]>,
) -> PartitionedWires<'a> {
let mut regular = Vec::new();
let mut mesh = Vec::new();
let mut instanced = Vec::new();
let mut circle_instances = Vec::new();
let mut ellipse_instances = Vec::new();
let mut contributors: rustc_hash::FxHashSet<Handle> = rustc_hash::FxHashSet::default();
let note = |wire: &WireModel, set: &mut rustc_hash::FxHashSet<Handle>| {
if let Some(handle) = handle_of(wire) {
set.insert(handle);
}
};
for wire in wires {
if !wire.display_visible {
continue;
}
if wire.render_instance.is_some() {
note(wire, &mut contributors);
instanced.push(wire);
continue;
}
// 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.text_verts.is_empty()
{
let depth = super::wire_gpu::wire_draw_depth(wire, depth_map);
if let Some(insts) = super::circle_gpu::extract_circle_instances(wire, depth) {
note(wire, &mut contributors);
circle_instances.extend(insts);
continue;
}
if let Some(insts) = super::ellipse_gpu::extract_ellipse_instances(wire, depth) {
note(wire, &mut contributors);
ellipse_instances.extend(insts);
continue;
}
}
if !wire.points.is_empty() {
if wire.fill_is_3d {
mesh.push(wire);
} else {
regular.push(wire);
}
}
}
PartitionedWires {
regular,
mesh,
instanced,
circle_instances,
ellipse_instances,
contributors,
}
}
/// Use the same regular/mesh partition for full uploads and changed runs.
/// Instanced blocks are uploaded separately by `BlockWireGpu`.
pub fn split_wires(wires: &[WireModel]) -> (Vec<&WireModel>, Vec<&WireModel>) {
wires
.iter()
.filter(|wire| {
wire.display_visible && !wire.points.is_empty() && wire.render_instance.is_none()
})
.partition(|wire| !wire.fill_is_3d)
let mut regular = Vec::new();
let mut mesh = Vec::new();
for wire in wires {
match classify_wire(wire) {
WireKind::Regular => regular.push(wire),
WireKind::MeshEdge => mesh.push(wire),
_ => {}
}
}
(regular, mesh)
}
/// True when appending a new entity at the tail could change the image, so the
@@ -1528,4 +1665,48 @@ mod tests {
assert!(blank_const().pattern_length < 0.0);
assert!(blank_packed_instance().pattern_length < 0.0);
}
#[test]
fn partition_wires_partitions_preview_analytical_curves() {
use crate::scene::model::wire_model::TangentGeom;
let mut circle_preview = WireModel::default();
circle_preview.tangent_geoms.push(TangentGeom::PlanarCircle {
center: [1.0, 2.0, 3.0],
axis_x: [1.0, 0.0, 0.0],
axis_y: [0.0, 1.0, 0.0],
radius: 10.0,
});
let mut arc_preview = WireModel::default();
arc_preview.tangent_geoms.push(TangentGeom::Arc {
center: [4.0, 5.0, 6.0],
axis_x: [1.0, 0.0, 0.0],
axis_y: [0.0, 1.0, 0.0],
radius: 8.0,
start_angle: 0.1,
end_angle: 2.0,
});
let mut ellipse_preview = WireModel::default();
ellipse_preview.tangent_geoms.push(TangentGeom::PlanarEllipse {
center: [7.0, 8.0, 9.0],
major_axis: [5.0, 0.0, 0.0],
normal: [0.0, 0.0, 1.0],
minor_axis_ratio: 0.6,
start_param: 0.0,
end_param: std::f64::consts::TAU,
});
let mut regular_line = WireModel::default();
regular_line.points.push([0.0, 0.0, 0.0]);
regular_line.points.push([1.0, 1.0, 1.0]);
let wires = vec![circle_preview, arc_preview, ellipse_preview, regular_line];
let depth_map = rustc_hash::FxHashMap::default();
let partitioned = partition_wires(&wires, &depth_map);
assert_eq!(partitioned.circle_instances.len(), 2);
assert_eq!(partitioned.ellipse_instances.len(), 1);
assert_eq!(partitioned.regular.len(), 1);
}
}