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

Brings in 54 commits from origin/main: a selection-performance overhaul,
surface thickening, GPU-native wide/tapered stroke rendering, continued
ocs_doc_api evolution, and assorted fixes/polish (block double-click
editing, DWG hatch/database repair, clipboard, collapsible Properties
sections).

Six real conflicts, all where this branch had independently re-applied
a fix upstream later revised or already carried natively:

- src/app/automation.rs, src/app/command_driver.rs: combined both
  sides — kept this branch's sketch-constraint/named-parameter loading
  on "new"/"open", adopted main's Scene::clear()/material_base_dir and
  its newer rebuild_derived_caches() over the older bump_geometry().
  Scene::clear() (src/scene/entity.rs) now also resets
  sketch_constraints/named_parameters, so every caller gets this for
  free instead of each call site repeating it.
- src/io/mod.rs, src/scene/render_graph.rs: adopted main's design
  outright (commit 973af87d, authored by Hakan directly) — a large
  MINSERT is no longer purged as "corrupt" at load, only its rendering
  is bounded, preserving the source data. This branch's own earlier
  design (reject at load) predates that revision.
- src/scene/pipeline/mesh_gpu.rs: adopted main's valid_rgba_size()
  guard (a real robustness improvement over this branch's version).
- src/snap.rs: replaced wholesale with main's version — this branch's
  copy was an explicit verbatim snapshot from an earlier upstream PR
  (commit 4c264b8c) with zero constraint-system-specific content, and
  main has since evolved it considerably further.

Also removed two now-dead duplicates the merge surfaced without a
conflict marker (both branches had independently fixed the same
things): the OPTIONS/OP command dispatch/autocomplete entries, and
automation.rs's open_finalizes_and_purges_like_the_ui_open_path test
(kept main's version, which covers strictly more: solid_models/
material_base_dir reset on "new").

Full workspace suite passes (812 lib tests + this branch's own 24
constraint/persistence integration tests) except the one pre-existing,
unrelated font-test failure already present on main. Zero clippy
findings in the hand-written merge resolution itself; the ~1,171
warnings clippy reports are pre-existing in the 54 absorbed commits,
not introduced here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Felix RiestraandClaude Sonnet 5 committed 2026-09-09 20:44:38 +02:00
commit d675244fb8
89 files changed
+5700 -1101

No files matched your search

+131 -9
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],
@@ -145,7 +145,7 @@ pub fn create_pipelines(
},
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth24PlusStencil8,
depth_write_enabled: Some(true),
depth_write_enabled: Some(false),
depth_compare: Some(wgpu::CompareFunction::LessEqual),
stencil: content_stencil.clone(),
bias: wgpu::DepthBiasState::default(),
@@ -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);
}
}
+2 -2
View File
@@ -145,7 +145,7 @@ pub fn create_pipelines(
},
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth24PlusStencil8,
depth_write_enabled: Some(true),
depth_write_enabled: Some(false),
depth_compare: Some(wgpu::CompareFunction::LessEqual),
stencil: content_stencil.clone(),
bias: wgpu::DepthBiasState::default(),
@@ -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()
{
+193
View File
@@ -521,6 +521,199 @@ 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);
// Wide straight polyline segment on the 3D plane
let mut straight_wide = WireModel::default();
straight_wide.name = "wide_straight".into();
straight_wide.points = vec![[0.0, 0.0, 0.0], [50.0, 50.0, 0.0]];
straight_wide.world_width = 10.0;
straight_wide.color = [0.9, 0.2, 0.3, 1.0];
let wide_wires = vec![straight_wide];
let gpu_wires = wire_gpu::WireGpu::from_run(
&device,
&queue,
&wide_wires,
&depth_map,
false,
pipeline.wire_const_bgl.as_ref(),
);
assert!(!gpu_wires.is_empty());
pipeline.gpu_wires = std::sync::Arc::new(gpu_wires);
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() {
+18 -11
View File
@@ -568,12 +568,15 @@ struct MeshSurfaceParams {
flags: [u32; 4],
}
/// Downscale a decoded RGBA buffer so neither dimension exceeds `limit`,
/// preserving aspect ratio (scaled by whichever dimension overshoots more).
/// `None` only for genuinely malformed input (`rgba`'s length doesn't match
/// `width * height * 4`) — the caller falls back to the 1x1 material color
/// in that case, same as it already does for a missing image entirely.
fn valid_rgba_size(width: u32, height: u32, pixels: &[u8]) -> bool {
width > 0 && height > 0
&& (width as usize).checked_mul(height as usize).and_then(|size| size.checked_mul(4)) == Some(pixels.len())
}
fn downscale_rgba_to_limit(width: u32, height: u32, rgba: &[u8], limit: u32) -> Option<(u32, u32, Vec<u8>)> {
if limit == 0 || !valid_rgba_size(width, height, rgba) {
return None;
}
let buffer = image::RgbaImage::from_raw(width, height, rgba.to_vec())?;
let scale = (limit as f64 / width.max(height) as f64).min(1.0);
let new_width = ((width as f64 * scale).round() as u32).clamp(1, limit);
@@ -590,14 +593,9 @@ fn upload_rgba_texture(
fallback: [u8; 4],
srgb: bool,
) -> wgpu::TextureView {
// A material map's decoded dimensions come straight from the source file
// (`material_model::load_map_image`), uncapped — an oversized image would
// otherwise fail `wgpu` texture-size validation and leave the material
// unusable (audit: raster-image rendering already tiles to this same
// limit in `image_gpu.rs`; a mesh material sampled by UV can't use that
// per-quad tiling trick, so downscale instead of tiling).
let limit = device.limits().max_texture_dimension_2d.max(1);
let resized;
let image = image.filter(|image| valid_rgba_size(image.width, image.height, &image.rgba));
let (width, height, pixels): (u32, u32, &[u8]) = match image {
Some(image) if image.width > limit || image.height > limit => {
match downscale_rgba_to_limit(image.width, image.height, &image.rgba, limit) {
@@ -2494,4 +2492,13 @@ mod texture_limit_tests {
let rgba = vec![0u8; 10];
assert!(downscale_rgba_to_limit(1000, 1000, &rgba, 512).is_none());
}
#[test]
fn malformed_material_images_are_rejected_even_below_the_texture_limit() {
for (width, height, pixels) in [(0, 1, vec![]), (1, 0, vec![]), (1, 1, vec![0; 3]), (1, 1, vec![0; 5])] {
assert!(!super::valid_rgba_size(width, height, &pixels));
assert!(downscale_rgba_to_limit(width, height, &pixels, 16).is_none());
}
assert!(downscale_rgba_to_limit(1, 1, &[0; 4], 0).is_none());
}
}
+205 -78
View File
@@ -2449,11 +2449,17 @@ impl Pipeline {
if instances.is_empty() {
vec![]
} else {
let mut sorted = instances.to_vec();
sorted.sort_by(|a, b| {
a.params[2]
.partial_cmp(&b.params[2])
.unwrap_or(std::cmp::Ordering::Equal)
});
vec![CircleGpu::from_instances(
device,
queue,
"viewer.circles",
instances,
&sorted,
)]
}
}
@@ -2489,11 +2495,17 @@ impl Pipeline {
if instances.is_empty() {
vec![]
} else {
let mut sorted = instances.to_vec();
sorted.sort_by(|a, b| {
a.params[2]
.partial_cmp(&b.params[2])
.unwrap_or(std::cmp::Ordering::Equal)
});
vec![EllipseGpu::from_instances(
device,
queue,
"viewer.ellipses",
instances,
&sorted,
)]
}
}
@@ -2612,6 +2624,72 @@ impl Pipeline {
/// refreshes just this overlay instead of re-tessellating the model. The
/// xray pass applies neither scissor nor mesh-edge skip, so everything
/// merges into one order-preserving run.
/// Split highlighted wires into analytic instances and the wires that need
/// real geometry, in one extraction pass per wire.
#[allow(clippy::type_complexity)]
fn classify_highlight_wires<'a>(
wires: &[&'a WireModel],
color: Option<[f32; 4]>,
depth_map: &rustc_hash::FxHashMap<u64, [f32; 2]>,
) -> (
Vec<CircleInstance>,
Vec<EllipseInstance>,
Vec<&'a WireModel>,
Vec<&'a WireModel>,
) {
use crate::par::prelude::*;
// Pure per-wire work, so it fans out; `collect` on an indexed
// parallel iterator keeps the order, and the order is what decides
// the instance layout.
let per: Vec<(Option<Vec<CircleInstance>>, Option<Vec<EllipseInstance>>, bool)> =
wires
.par_iter()
.map(|&wire| {
if wire.render_instance.is_some() {
return (None, None, true);
}
let depth = wire_gpu::wire_draw_depth(wire, depth_map);
let mut circles = circle_gpu::extract_circle_instances(wire, depth);
let mut ellipses = ellipse_gpu::extract_ellipse_instances(wire, depth);
if let Some(color) = color {
if let Some(instances) = circles.as_mut() {
for instance in instances {
instance.color = color;
}
}
if let Some(instances) = ellipses.as_mut() {
for instance in instances {
instance.color = color;
}
}
}
(circles, ellipses, false)
})
.collect();
let mut circles: Vec<CircleInstance> = Vec::new();
let mut ellipses: Vec<EllipseInstance> = Vec::new();
let mut regular: Vec<&'a WireModel> = Vec::new();
let mut blocks: Vec<&'a WireModel> = Vec::new();
for (&wire, (wire_circles, wire_ellipses, is_block)) in wires.iter().zip(per) {
if is_block {
blocks.push(wire);
continue;
}
let shaped = wire_circles.is_some() || wire_ellipses.is_some();
if let Some(instances) = wire_circles {
circles.extend(instances);
}
if let Some(instances) = wire_ellipses {
ellipses.extend(instances);
}
if !shaped {
regular.push(wire);
}
}
(circles, ellipses, regular, blocks)
}
pub fn upload_selected_wires(
&mut self,
device: &wgpu::Device,
@@ -2637,9 +2715,11 @@ impl Pipeline {
let mut hover_wires: Vec<&WireModel> = Vec::new();
for h in selected {
if let Some(idxs) = self.wire_handle_index.get(&h.value()) {
let mut slots = idxs.clone();
slots.sort_unstable();
for &i in &slots {
debug_assert!(
idxs.windows(2).all(|pair| pair[0] <= pair[1]),
"wire_handle_index slots must stay ascending",
);
for &i in idxs {
if let Some(w) = wires.get(i as usize) {
if !w.display_visible {
continue;
@@ -2651,9 +2731,7 @@ impl Pipeline {
}
for h in hovered.iter().filter(|handle| !selected.contains(handle)) {
if let Some(idxs) = self.wire_handle_index.get(&h.value()) {
let mut slots = idxs.clone();
slots.sort_unstable();
for &i in &slots {
for &i in idxs {
if let Some(w) = wires.get(i as usize) {
if !w.display_visible {
continue;
@@ -2673,26 +2751,23 @@ impl Pipeline {
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);
}
}
}
let t_gather = perf_started.map(|t| t.elapsed().as_secs_f64() * 1000.0);
let (mut selected_circles, mut selected_ellipses, selected_regular, selected_blocks) =
Self::classify_highlight_wires(
&selected_wires,
Some(selected_tint.unwrap_or(WireModel::SELECTED)),
depth_map,
);
let (hover_circles, hover_ellipses, hover_regular, hover_blocks) =
Self::classify_highlight_wires(
&hover_wires,
Some(WireModel::HOVER),
depth_map,
);
selected_circles.extend(hover_circles);
selected_ellipses.extend(hover_ellipses);
let t_shapes = perf_started.map(|t| t.elapsed().as_secs_f64() * 1000.0);
self.gpu_selected_circles = if selected_circles.is_empty() {
vec![]
} else {
@@ -2703,27 +2778,6 @@ impl Pipeline {
&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 {
@@ -2734,35 +2788,7 @@ impl Pipeline {
&selected_ellipses,
)]
};
let selected_regular: Vec<&WireModel> = selected_wires
.iter()
.copied()
.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()
&& 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()
.copied()
.filter(|wire| wire.render_instance.is_some())
.collect();
let hover_blocks: Vec<&WireModel> = hover_wires
.iter()
.copied()
.filter(|wire| wire.render_instance.is_some())
.collect();
let t_split = perf_started.map(|t| t.elapsed().as_secs_f64() * 1000.0);
let mut gpu = if let Some(tint) = selected_tint {
WireGpu::from_highlight_refs(
device,
@@ -2783,6 +2809,7 @@ impl Pipeline {
depth_map,
self.wire_const_bgl.as_ref(),
));
let t_regular = perf_started.map(|t| t.elapsed().as_secs_f64() * 1000.0);
self.gpu_selected_wires = gpu;
let mut block_gpu = if let Some(tint) = selected_tint {
BlockWireGpu::from_wires(
@@ -2816,6 +2843,18 @@ impl Pipeline {
if let Some(started) = perf_started {
let elapsed_ms = started.elapsed().as_secs_f64() * 1000.0;
if elapsed_ms >= 1.0 {
let gather = t_gather.unwrap_or(0.0);
let classify = t_shapes.unwrap_or(0.0);
let analytic = t_split.unwrap_or(0.0);
let regular = t_regular.unwrap_or(0.0);
crate::perf_record!(
"[perf] wire-highlight-detail gather={gather:.1} classify={:.1} \
analytic={:.1} regular={:.1} blocks={:.1}",
classify - gather,
analytic - classify,
regular - analytic,
elapsed_ms - regular,
);
crate::perf_record!(
"[perf] wire-highlight {:>7.1}ms handles={} wires={}",
elapsed_ms,
@@ -5627,3 +5666,91 @@ impl iced::widget::shader::Pipeline for MultiPipeline {
}
}
}
#[cfg(test)]
mod highlight_classification_tests {
use super::*;
use crate::scene::model::wire_model::TangentGeom;
fn plain(name: &str) -> WireModel {
WireModel::solid(
name.to_string(),
vec![[0.0, 0.0, 0.0], [1.0, 1.0, 0.0]],
[1.0; 4],
false,
)
}
fn circle(name: &str) -> WireModel {
let mut wire = plain(name);
wire.tangent_geoms.push(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: 2.0,
});
wire
}
fn ellipse(name: &str) -> WireModel {
let mut wire = plain(name);
wire.tangent_geoms.push(TangentGeom::PlanarEllipse {
center: [0.0, 0.0, 0.0],
major_axis: [2.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
}
#[test]
fn each_wire_lands_in_the_bucket_the_old_predicate_chose() {
let wires = vec![plain("1"), circle("2"), ellipse("3"), plain("4")];
let refs: Vec<&WireModel> = wires.iter().collect();
let depth_map = rustc_hash::FxHashMap::default();
let (circles, ellipses, regular, blocks) =
Pipeline::classify_highlight_wires(&refs, None, &depth_map);
for wire in &refs {
let was_regular = 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();
assert_eq!(
was_regular,
regular.iter().any(|kept| std::ptr::eq(*kept, *wire)),
"wire {} disagrees with the old predicate",
wire.name,
);
}
assert_eq!(regular.len(), 2, "two plain lines");
assert_eq!(circles.len(), 1, "one circle instance");
assert_eq!(ellipses.len(), 1, "one ellipse instance");
assert!(blocks.is_empty(), "no block wires in this sample");
}
// Hover recolours every instance; a selection only recolours when a tint is
// configured. Both go through the same argument, so it has to actually
// reach the instances.
#[test]
fn the_colour_override_reaches_the_instances() {
let wires = vec![circle("2"), ellipse("3")];
let refs: Vec<&WireModel> = wires.iter().collect();
let depth_map = rustc_hash::FxHashMap::default();
let (tinted_circles, tinted_ellipses, _, _) =
Pipeline::classify_highlight_wires(&refs, Some(WireModel::HOVER), &depth_map);
assert_eq!(tinted_circles[0].color, WireModel::HOVER);
assert_eq!(tinted_ellipses[0].color, WireModel::HOVER);
let (plain_circles, _, _, _) =
Pipeline::classify_highlight_wires(&refs, None, &depth_map);
assert_ne!(
plain_circles[0].color, WireModel::HOVER,
"without a tint the extractor's own colour stands",
);
}
}
+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);
}
}
+57 -2
View File
@@ -532,7 +532,35 @@ fn finite3(p: [f32; 3]) -> bool {
fn marker_metadata(wire: &WireModel) -> ([f32; 4], [f32; 4], [f32; 4]) {
let Some(marker) = wire.point_marker else {
return ([0.0; 4], [0.0; 4], [0.0; 4]);
for tg in &wire.tangent_geoms {
match tg {
crate::scene::model::wire_model::TangentGeom::Arc { axis_x, axis_y, .. }
| crate::scene::model::wire_model::TangentGeom::PlanarCircle { axis_x, axis_y, .. } => {
let ax = glam::DVec3::from_array(*axis_x);
let ay = glam::DVec3::from_array(*axis_y);
let n = ax.cross(ay).normalize_or(glam::DVec3::Z);
return ([0.0; 4], [0.0; 4], [n.x as f32, n.y as f32, n.z as f32, 0.0]);
}
crate::scene::model::wire_model::TangentGeom::PlanarEllipse { normal, .. } => {
let n = glam::DVec3::from_array(*normal).normalize_or(glam::DVec3::Z);
return ([0.0; 4], [0.0; 4], [n.x as f32, n.y as f32, n.z as f32, 0.0]);
}
_ => {}
}
}
if wire.points.len() >= 3 {
let p0 = glam::Vec3::from_array(wire.points[0]);
for i in 1..wire.points.len() - 1 {
let v1 = glam::Vec3::from_array(wire.points[i]) - p0;
let v2 = glam::Vec3::from_array(wire.points[i + 1]) - p0;
let cross = v1.cross(v2);
if cross.length_squared() > 1e-6 {
let n = cross.normalize();
return ([0.0; 4], [0.0; 4], [n.x, n.y, n.z, 0.0]);
}
}
}
return ([0.0; 4], [0.0; 4], [0.0, 0.0, 1.0, 0.0]);
};
let origin = marker.origin;
let (hx, lx) = WireModel::split_ds(origin.x);
@@ -1148,8 +1176,9 @@ impl WireGpu {
let _ = const_bgl;
let max_packed_instances = super::gpu_budget::max_elements::<PackedWireInstance>(device);
use crate::par::prelude::*;
let per: Vec<Vec<PackedWireInstance>> = wires
.iter()
.par_iter()
.map(|wire| {
emit_wire_packed(wire, color, wire_draw_depth(wire, depth_map))
})
@@ -1463,4 +1492,30 @@ mod block_wire_storage_tests {
};
assert_eq!(names(&packed), names(&storage));
}
#[test]
fn wire_shader_validates_with_naga() {
let source = include_str!("../../shaders/wire.wgsl");
let module = naga::front::wgsl::parse_str(source).expect("wire.wgsl parses cleanly");
let mut validator = naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
);
validator
.validate(&module)
.expect("wire.wgsl validates cleanly with naga");
}
#[test]
fn wire_indexed_shader_validates_with_naga() {
let source = include_str!("../../shaders/wire_indexed.wgsl");
let module = naga::front::wgsl::parse_str(source).expect("wire_indexed.wgsl parses cleanly");
let mut validator = naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
);
validator
.validate(&module)
.expect("wire_indexed.wgsl validates cleanly with naga");
}
}