forked from 1b-agent/opencadstudio-upstream
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 (commit973af87d, 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 (commit4c264b8c) 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 <[email protected]>
This commit is contained in:
commit
d675244fb8
89 files changed
+5700
-1101
No files matched your search
+46
-17
@@ -11,7 +11,7 @@
|
||||
|
||||
use acadrust::entities::{EntityCommon, EntityType};
|
||||
use acadrust::objects::{
|
||||
Dictionary, DimContext, DimSubtype, EmbeddedMTextContext, HatchScaleContext,
|
||||
Dictionary, DimContext, DimSubtype, EmbeddedMTextContext, HatchLoopContext, HatchScaleContext,
|
||||
MTextAttributeContext, MTextContext, ObjectContextData, ObjectContextKind, ObjectType,
|
||||
};
|
||||
use acadrust::types::{Vector2, Vector3};
|
||||
@@ -289,12 +289,15 @@ fn context_kind_for(
|
||||
pattern_lines: hatch.pattern.lines.clone(),
|
||||
pattern_scale: hatch.pattern_scale,
|
||||
pattern_base: Vector3::ZERO,
|
||||
loop_types: hatch
|
||||
loops: hatch
|
||||
.paths
|
||||
.iter()
|
||||
.map(|path| path.flags.bits() as i32)
|
||||
.map(|path| HatchLoopContext {
|
||||
loop_type: path.flags.bits() as i32,
|
||||
supports_context: true,
|
||||
boundary: None,
|
||||
})
|
||||
.collect(),
|
||||
supports_context: true,
|
||||
}),
|
||||
)),
|
||||
_ => None,
|
||||
@@ -891,9 +894,43 @@ fn apply_hatch_context(hatch: &mut acadrust::entities::Hatch, context: &HatchSca
|
||||
line.base_point.x += context.pattern_base.x;
|
||||
line.base_point.y += context.pattern_base.y;
|
||||
}
|
||||
for (path, bits) in hatch.paths.iter_mut().zip(&context.loop_types) {
|
||||
path.flags = acadrust::entities::BoundaryPathFlags::from_bits(*bits as u32);
|
||||
}
|
||||
let base_paths = std::mem::take(&mut hatch.paths);
|
||||
hatch.paths = context
|
||||
.loops
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, loop_data)| {
|
||||
let flags =
|
||||
acadrust::entities::BoundaryPathFlags::from_bits(loop_data.loop_type as u32);
|
||||
let mut path = if loop_data.supports_context {
|
||||
base_paths.get(index).cloned()
|
||||
} else {
|
||||
loop_data.boundary.clone()
|
||||
}
|
||||
.unwrap_or_else(|| acadrust::entities::BoundaryPath::with_flags(flags));
|
||||
path.flags = flags;
|
||||
path
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
|
||||
fn sync_hatch_loops(loops: &mut Vec<HatchLoopContext>, paths: &[acadrust::entities::BoundaryPath]) {
|
||||
let previous = std::mem::take(loops);
|
||||
*loops = paths
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, path)| {
|
||||
let supports_context = previous
|
||||
.get(index)
|
||||
.map(|loop_data| loop_data.supports_context)
|
||||
.unwrap_or(true);
|
||||
HatchLoopContext {
|
||||
loop_type: path.flags.bits() as i32,
|
||||
supports_context,
|
||||
boundary: (!supports_context).then(|| path.clone()),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
|
||||
pub fn entity_for_annotation_context<'a>(
|
||||
@@ -1235,11 +1272,7 @@ pub fn sync_annotation_context_from_entity(
|
||||
line.base_point.y -= context.pattern_base.y;
|
||||
}
|
||||
context.pattern_scale = hatch.pattern_scale;
|
||||
context.loop_types = hatch
|
||||
.paths
|
||||
.iter()
|
||||
.map(|path| path.flags.bits() as i32)
|
||||
.collect();
|
||||
sync_hatch_loops(&mut context.loops, &hatch.paths);
|
||||
}
|
||||
(EntityType::Hatch(hatch), ObjectContextKind::HatchView(context)) => {
|
||||
context.hatch.pattern_lines.clone_from(&hatch.pattern.lines);
|
||||
@@ -1248,11 +1281,7 @@ pub fn sync_annotation_context_from_entity(
|
||||
line.base_point.y -= context.hatch.pattern_base.y;
|
||||
}
|
||||
context.hatch.pattern_scale = hatch.pattern_scale;
|
||||
context.hatch.loop_types = hatch
|
||||
.paths
|
||||
.iter()
|
||||
.map(|path| path.flags.bits() as i32)
|
||||
.collect();
|
||||
sync_hatch_loops(&mut context.hatch.loops, &hatch.paths);
|
||||
context.view_normal = hatch.normal;
|
||||
}
|
||||
_ => {}
|
||||
|
||||
+38
-15
@@ -9,26 +9,37 @@ fn rendered_wire_bounds(
|
||||
) -> Option<(glam::DVec3, glam::DVec3)> {
|
||||
let mut min = glam::DVec3::splat(f64::INFINITY);
|
||||
let mut max = glam::DVec3::splat(f64::NEG_INFINITY);
|
||||
let mut include = |point: glam::DVec3| {
|
||||
if point.is_finite() {
|
||||
min = min.min(point);
|
||||
max = max.max(point);
|
||||
}
|
||||
};
|
||||
macro_rules! include_bound {
|
||||
($p:expr) => {
|
||||
let point: glam::DVec3 = $p;
|
||||
if point.is_finite() {
|
||||
min = min.min(point);
|
||||
max = max.max(point);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
for wire in wires {
|
||||
let mut has_precise_position = false;
|
||||
let hw = wire.world_width as f64 * 0.5;
|
||||
|
||||
for (index, &[x, y, z]) in wire.points.iter().enumerate() {
|
||||
let low = wire.points_low.get(index).copied().unwrap_or([0.0; 3]);
|
||||
include(glam::DVec3::new(
|
||||
let p = glam::DVec3::new(
|
||||
x as f64 + low[0] as f64,
|
||||
y as f64 + low[1] as f64,
|
||||
z as f64 + low[2] as f64,
|
||||
));
|
||||
);
|
||||
if hw > 0.0 {
|
||||
include_bound!(p - glam::DVec3::splat(hw));
|
||||
include_bound!(p + glam::DVec3::splat(hw));
|
||||
} else {
|
||||
include_bound!(p);
|
||||
}
|
||||
has_precise_position = true;
|
||||
}
|
||||
for vertex in &wire.text_verts {
|
||||
include(glam::DVec3::new(
|
||||
include_bound!(glam::DVec3::new(
|
||||
vertex.pos[0] as f64 + vertex.pos_low[0] as f64,
|
||||
vertex.pos[1] as f64 + vertex.pos_low[1] as f64,
|
||||
vertex.pos[2] as f64 + vertex.pos_low[2] as f64,
|
||||
@@ -36,11 +47,17 @@ fn rendered_wire_bounds(
|
||||
has_precise_position = true;
|
||||
}
|
||||
for &[x, y, z] in &wire.key_vertices {
|
||||
include(glam::DVec3::new(x, y, z));
|
||||
let p = glam::DVec3::new(x, y, z);
|
||||
if hw > 0.0 {
|
||||
include_bound!(p - glam::DVec3::splat(hw));
|
||||
include_bound!(p + glam::DVec3::splat(hw));
|
||||
} else {
|
||||
include_bound!(p);
|
||||
}
|
||||
has_precise_position = true;
|
||||
}
|
||||
for &(point, _) in &wire.snap_pts {
|
||||
include(point);
|
||||
include_bound!(point);
|
||||
has_precise_position = true;
|
||||
}
|
||||
|
||||
@@ -51,8 +68,8 @@ fn rendered_wire_bounds(
|
||||
&& x0 <= x1
|
||||
&& y0 <= y1
|
||||
{
|
||||
include(glam::DVec3::new(x0 as f64, y0 as f64, fallback_z));
|
||||
include(glam::DVec3::new(x1 as f64, y1 as f64, fallback_z));
|
||||
include_bound!(glam::DVec3::new(x0 as f64 - hw, y0 as f64 - hw, fallback_z - hw));
|
||||
include_bound!(glam::DVec3::new(x1 as f64 + hw, y1 as f64 + hw, fallback_z + hw));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1455,12 +1472,18 @@ impl Scene {
|
||||
continue;
|
||||
}
|
||||
let wire = &wires[c.idx];
|
||||
let hw = (wire.world_width * 0.5) as f32;
|
||||
for &[x, y, z] in &wire.points {
|
||||
if !x.is_finite() || !y.is_finite() || !z.is_finite() {
|
||||
continue;
|
||||
}
|
||||
min = min.min(glam::Vec3::new(x, y, z));
|
||||
max = max.max(glam::Vec3::new(x, y, z));
|
||||
if hw > 0.0 {
|
||||
min = min.min(glam::Vec3::new(x - hw, y - hw, z - hw));
|
||||
max = max.max(glam::Vec3::new(x + hw, y + hw, z + hw));
|
||||
} else {
|
||||
min = min.min(glam::Vec3::new(x, y, z));
|
||||
max = max.max(glam::Vec3::new(x, y, z));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fold in 3D-solid mesh AABBs (not subject to the wire IQR reject).
|
||||
|
||||
@@ -167,7 +167,7 @@ pub fn kernel_surface_body(surface: &Surface) -> Option<KernelBody> {
|
||||
kernel_acis_body(&surface.acis_data)
|
||||
}
|
||||
|
||||
fn kernel_acis_body(acis: &acadrust::entities::AcisData) -> Option<KernelBody> {
|
||||
pub(crate) fn kernel_acis_body(acis: &acadrust::entities::AcisData) -> Option<KernelBody> {
|
||||
let sat = parse_acis(
|
||||
|| acis.parse(),
|
||||
acis.is_binary,
|
||||
|
||||
+159
-31
@@ -202,6 +202,66 @@ pub(crate) fn points_to_ds(
|
||||
(high, low)
|
||||
}
|
||||
|
||||
fn polyline_segment_widths(entity: &EntityType) -> Vec<(f32, f32)> {
|
||||
match entity {
|
||||
EntityType::LwPolyline(p) => {
|
||||
let count = p.vertices.len();
|
||||
let seg_count = if p.is_closed {
|
||||
count
|
||||
} else {
|
||||
count.saturating_sub(1)
|
||||
};
|
||||
let c = p.constant_width;
|
||||
(0..seg_count)
|
||||
.map(|i| {
|
||||
let v = &p.vertices[i];
|
||||
let sw = if v.start_width > 1e-9 {
|
||||
v.start_width
|
||||
} else {
|
||||
c
|
||||
} as f32;
|
||||
let ew = if v.end_width > 1e-9 {
|
||||
v.end_width
|
||||
} else {
|
||||
c
|
||||
} as f32;
|
||||
(sw, ew)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
EntityType::Polyline2D(p) => {
|
||||
let filtered = crate::entities::polyline::drawn_vertices2d(p);
|
||||
let verts: &[acadrust::entities::Vertex2D] =
|
||||
filtered.as_deref().unwrap_or(&p.vertices);
|
||||
let count = verts.len();
|
||||
let seg_count = if p.is_closed() {
|
||||
count
|
||||
} else {
|
||||
count.saturating_sub(1)
|
||||
};
|
||||
let def_start = p.start_width;
|
||||
let def_end = p.end_width;
|
||||
(0..seg_count)
|
||||
.map(|i| {
|
||||
let v = &verts[i];
|
||||
let sw = if v.start_width > 1e-9 {
|
||||
v.start_width
|
||||
} else {
|
||||
def_start
|
||||
} as f32;
|
||||
let ew = if v.end_width > 1e-9 {
|
||||
v.end_width
|
||||
} else {
|
||||
def_end
|
||||
} as f32;
|
||||
(sw, ew)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn split_mixed_polyline(
|
||||
tangent_geoms: &[TangentGeom],
|
||||
key_vertices: &[[f64; 3]],
|
||||
@@ -214,11 +274,15 @@ fn split_mixed_polyline(
|
||||
snap_pts: Vec<(glam::DVec3, SnapHint)>,
|
||||
point_marker: Option<crate::scene::model::wire_model::PointMarker>,
|
||||
plinegen: bool,
|
||||
seg_widths: &[(f32, f32)],
|
||||
global_world_width: f32,
|
||||
mut pick_tris: Vec<[f32; 3]>,
|
||||
mut pick_tris_low: Vec<[f32; 3]>,
|
||||
) -> Vec<WireModel> {
|
||||
let mut out = Vec::new();
|
||||
|
||||
// Emit each arc segment as an analytical wire (CircleGpu target)
|
||||
for tg in tangent_geoms {
|
||||
for (i, tg) in tangent_geoms.iter().enumerate() {
|
||||
if let TangentGeom::Arc {
|
||||
center,
|
||||
axis_x,
|
||||
@@ -246,20 +310,48 @@ fn split_mixed_polyline(
|
||||
arc_pts.push(p);
|
||||
}
|
||||
let (points, points_low) = points_to_ds(arc_pts);
|
||||
|
||||
let (sw, ew) = if let Some(&(w0, w1)) = seg_widths.get(i) {
|
||||
if w0 > 1e-9 || w1 > 1e-9 {
|
||||
(w0, w1)
|
||||
} else if global_world_width > 1e-9 {
|
||||
(global_world_width, global_world_width)
|
||||
} else {
|
||||
(0.0, 0.0)
|
||||
}
|
||||
} else if global_world_width > 1e-9 {
|
||||
(global_world_width, global_world_width)
|
||||
} else {
|
||||
(0.0, 0.0)
|
||||
};
|
||||
|
||||
let taper_widths = if (sw - ew).abs() > 1e-6 {
|
||||
vec![sw, ew]
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let world_width = sw.max(ew);
|
||||
|
||||
let (arc_pt, arc_ptl) = if out.is_empty() && !pick_tris.is_empty() {
|
||||
(std::mem::take(&mut pick_tris), std::mem::take(&mut pick_tris_low))
|
||||
} else {
|
||||
(Vec::new(), Vec::new())
|
||||
};
|
||||
|
||||
out.push(WireModel {
|
||||
bg_adapt: None,
|
||||
point_marker: None,
|
||||
taper_widths: Vec::new(),
|
||||
taper_widths,
|
||||
pattern_stations: Vec::new(),
|
||||
world_width: 0.0,
|
||||
world_width,
|
||||
depth_override: None,
|
||||
display_visible: true,
|
||||
plot_visible: true,
|
||||
fill_is_3d: false,
|
||||
fill_is_2d_solid: false,
|
||||
render_instance: None,
|
||||
pick_tris: Vec::new(),
|
||||
pick_tris_low: Vec::new(),
|
||||
pick_tris: arc_pt,
|
||||
pick_tris_low: arc_ptl,
|
||||
dash_from_start: false,
|
||||
dash_align_end: None,
|
||||
text_verts: Vec::new(),
|
||||
@@ -330,20 +422,25 @@ fn split_mixed_polyline(
|
||||
|
||||
let (line_pts, line_pts_low) = points_to_ds(straight_pts);
|
||||
if !line_pts.is_empty() {
|
||||
let (pt, ptl) = if !pick_tris.is_empty() {
|
||||
(pick_tris, pick_tris_low)
|
||||
} else {
|
||||
(Vec::new(), Vec::new())
|
||||
};
|
||||
out.push(WireModel {
|
||||
bg_adapt: None,
|
||||
point_marker,
|
||||
taper_widths: Vec::new(),
|
||||
pattern_stations: Vec::new(),
|
||||
world_width: 0.0,
|
||||
world_width: global_world_width,
|
||||
depth_override: None,
|
||||
display_visible: true,
|
||||
plot_visible: true,
|
||||
fill_is_3d: false,
|
||||
fill_is_2d_solid: false,
|
||||
render_instance: None,
|
||||
pick_tris: Vec::new(),
|
||||
pick_tris_low: Vec::new(),
|
||||
pick_tris: pt,
|
||||
pick_tris_low: ptl,
|
||||
dash_from_start: false,
|
||||
dash_align_end: None,
|
||||
text_verts: Vec::new(),
|
||||
@@ -367,6 +464,10 @@ fn split_mixed_polyline(
|
||||
} else if let Some(first_arc) = out.first_mut() {
|
||||
first_arc.snap_pts = snap_pts;
|
||||
first_arc.key_vertices = key_vertices.to_vec();
|
||||
if first_arc.pick_tris.is_empty() && !pick_tris.is_empty() {
|
||||
first_arc.pick_tris = pick_tris;
|
||||
first_arc.pick_tris_low = pick_tris_low;
|
||||
}
|
||||
}
|
||||
|
||||
out
|
||||
@@ -1744,17 +1845,13 @@ pub fn tessellate(
|
||||
.tangent_geoms
|
||||
.iter()
|
||||
.any(|tg| matches!(tg, TangentGeom::Arc { .. }));
|
||||
let has_line = te
|
||||
.tangent_geoms
|
||||
.iter()
|
||||
.any(|tg| matches!(tg, TangentGeom::Line { .. }));
|
||||
let is_thin_polyline = !is_thick_extrusion
|
||||
&& polyline_band_width(entity, document.header.fill_mode) <= 1e-9
|
||||
let can_split = !is_thick_extrusion
|
||||
&& fill_tris.is_empty()
|
||||
&& pick_tris.is_empty()
|
||||
&& matches!(entity, EntityType::LwPolyline(_) | EntityType::Polyline2D(_));
|
||||
|
||||
if has_arc && has_line && is_thin_polyline {
|
||||
if has_arc && can_split {
|
||||
let seg_widths = polyline_segment_widths(entity);
|
||||
let ww = polyline_band_width(entity, document.header.fill_mode);
|
||||
out.extend(split_mixed_polyline(
|
||||
&te.tangent_geoms,
|
||||
&keys,
|
||||
@@ -1767,6 +1864,10 @@ pub fn tessellate(
|
||||
snap,
|
||||
point_marker,
|
||||
true,
|
||||
&seg_widths,
|
||||
ww,
|
||||
pick_tris,
|
||||
pick_tris_low,
|
||||
));
|
||||
} else {
|
||||
out.push(WireModel {
|
||||
@@ -1966,20 +2067,14 @@ pub fn tessellate(
|
||||
entity,
|
||||
EntityType::Polyline2D(p) if p.thickness.abs() > 1e-10
|
||||
);
|
||||
let is_thin_polyline = !is_thick_extrusion
|
||||
&& polyline_band_width(entity, document.header.fill_mode) <= 1e-9
|
||||
&& pick_tris.is_empty()
|
||||
let can_split = !is_thick_extrusion
|
||||
&& matches!(entity, EntityType::LwPolyline(_) | EntityType::Polyline2D(_));
|
||||
let has_arc = te
|
||||
.tangent_geoms
|
||||
.iter()
|
||||
.any(|tg| matches!(tg, TangentGeom::Arc { .. }));
|
||||
let has_line = te
|
||||
.tangent_geoms
|
||||
.iter()
|
||||
.any(|tg| matches!(tg, TangentGeom::Line { .. }));
|
||||
|
||||
if has_arc && has_line && is_thin_polyline {
|
||||
if has_arc && can_split {
|
||||
let edge_color = if is_thick_extrusion {
|
||||
[0.0, 0.0, 0.0, 1.0]
|
||||
} else {
|
||||
@@ -1987,6 +2082,8 @@ pub fn tessellate(
|
||||
};
|
||||
let point_marker =
|
||||
crate::entities::point::relative_marker_spec(entity, document);
|
||||
let seg_widths = polyline_segment_widths(entity);
|
||||
let ww = polyline_band_width(entity, document.header.fill_mode);
|
||||
return split_mixed_polyline(
|
||||
&te.tangent_geoms,
|
||||
&key_vertices,
|
||||
@@ -1999,6 +2096,10 @@ pub fn tessellate(
|
||||
snap_pts,
|
||||
point_marker,
|
||||
false,
|
||||
&seg_widths,
|
||||
ww,
|
||||
pick_tris,
|
||||
pick_tris_low,
|
||||
);
|
||||
}
|
||||
return vec![WireModel {
|
||||
@@ -2038,6 +2139,40 @@ pub fn tessellate(
|
||||
}
|
||||
|
||||
RenderObject::TaperedLines(points, widths) => {
|
||||
let has_arc = te
|
||||
.tangent_geoms
|
||||
.iter()
|
||||
.any(|tg| matches!(tg, TangentGeom::Arc { .. }));
|
||||
let can_split = matches!(entity, EntityType::LwPolyline(_) | EntityType::Polyline2D(_));
|
||||
let (pick_tris, pick_tris_low) = points_to_ds(te.pick_tris);
|
||||
let world_width = widths.iter().copied().fold(0.0f32, f32::max);
|
||||
let key_vertices: Vec<[f64; 3]> = te
|
||||
.key_vertices
|
||||
.into_iter()
|
||||
.map(|[x, y, z]| [x, y, z])
|
||||
.collect();
|
||||
|
||||
if has_arc && can_split {
|
||||
let seg_widths = polyline_segment_widths(entity);
|
||||
return split_mixed_polyline(
|
||||
&te.tangent_geoms,
|
||||
&key_vertices,
|
||||
&name,
|
||||
color,
|
||||
selected,
|
||||
pattern_length,
|
||||
pattern,
|
||||
line_weight_px,
|
||||
te.snap_pts,
|
||||
None,
|
||||
true,
|
||||
&seg_widths,
|
||||
world_width,
|
||||
pick_tris,
|
||||
pick_tris_low,
|
||||
);
|
||||
}
|
||||
|
||||
// A wide polyline whose width varies: one continuous band wire
|
||||
// carrying a per-point width; the shader interpolates each
|
||||
// segment's two endpoint widths. `world_width` (the widest edge)
|
||||
@@ -2045,13 +2180,6 @@ pub fn tessellate(
|
||||
// floor when zoomed out.
|
||||
let (local_pts, local_pts_low) = points_to_ds(points);
|
||||
let snap_pts = te.snap_pts;
|
||||
let key_vertices: Vec<[f64; 3]> = te
|
||||
.key_vertices
|
||||
.into_iter()
|
||||
.map(|[x, y, z]| [x, y, z])
|
||||
.collect();
|
||||
let (pick_tris, pick_tris_low) = points_to_ds(te.pick_tris);
|
||||
let world_width = widths.iter().copied().fold(0.0f32, f32::max);
|
||||
return vec![WireModel {
|
||||
bg_adapt: None,
|
||||
point_marker: None,
|
||||
|
||||
+115
-1
@@ -143,6 +143,106 @@ impl Scene {
|
||||
let _ = self.document.app_ids.add(app_id);
|
||||
}
|
||||
|
||||
fn prepare_section_objects(&mut self, entity: &mut EntityType) -> bool {
|
||||
use acadrust::entities::ExtendedEntityData;
|
||||
use acadrust::objects::{
|
||||
ClassObject, ClassObjectData, SectionManager, SectionSettings,
|
||||
SectionTypeSettings,
|
||||
};
|
||||
|
||||
let EntityType::Extended(extended) = entity else {
|
||||
return false;
|
||||
};
|
||||
let ExtendedEntityData::SectionObject(data) = &mut extended.data else {
|
||||
return false;
|
||||
};
|
||||
|
||||
if extended.common.handle.is_null() {
|
||||
extended.common.handle = self.document.allocate_handle();
|
||||
}
|
||||
let entity_handle = extended.common.handle;
|
||||
|
||||
let valid_settings = matches!(
|
||||
self.document.objects.get(&data.settings_handle),
|
||||
Some(ObjectType::ClassObject(object))
|
||||
if matches!(&object.data, ClassObjectData::SectionSettings(_))
|
||||
);
|
||||
if !valid_settings {
|
||||
let settings_handle = self.document.allocate_handle();
|
||||
let mut settings = ClassObject::new(ClassObjectData::SectionSettings(
|
||||
SectionSettings {
|
||||
current_type: 4,
|
||||
types: vec![SectionTypeSettings {
|
||||
section_type: 4,
|
||||
generation: 17,
|
||||
..SectionTypeSettings::default()
|
||||
}],
|
||||
},
|
||||
));
|
||||
settings.handle = settings_handle;
|
||||
settings.owner = entity_handle;
|
||||
self.document
|
||||
.objects
|
||||
.insert(settings_handle, ObjectType::ClassObject(settings));
|
||||
data.settings_handle = settings_handle;
|
||||
} else if let Some(ObjectType::ClassObject(settings)) =
|
||||
self.document.objects.get_mut(&data.settings_handle)
|
||||
{
|
||||
settings.owner = entity_handle;
|
||||
}
|
||||
|
||||
let root = crate::scene::annotative::root_named_dict_handle(&mut self.document);
|
||||
let manager_handle = self
|
||||
.document
|
||||
.objects
|
||||
.get(&root)
|
||||
.and_then(|object| match object {
|
||||
ObjectType::Dictionary(dictionary) => dictionary
|
||||
.entries
|
||||
.iter()
|
||||
.find(|(name, _)| name.eq_ignore_ascii_case("ACAD_SECTION_MANAGER"))
|
||||
.map(|(_, handle)| *handle),
|
||||
_ => None,
|
||||
})
|
||||
.filter(|handle| {
|
||||
matches!(
|
||||
self.document.objects.get(handle),
|
||||
Some(ObjectType::ClassObject(object))
|
||||
if matches!(&object.data, ClassObjectData::SectionManager(_))
|
||||
)
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
let handle = self.document.allocate_handle();
|
||||
let mut manager =
|
||||
ClassObject::new(ClassObjectData::SectionManager(SectionManager::default()));
|
||||
manager.handle = handle;
|
||||
manager.owner = root;
|
||||
self.document
|
||||
.objects
|
||||
.insert(handle, ObjectType::ClassObject(manager));
|
||||
if let Some(ObjectType::Dictionary(dictionary)) =
|
||||
self.document.objects.get_mut(&root)
|
||||
{
|
||||
dictionary.entries.retain(|(name, _)| {
|
||||
!name.eq_ignore_ascii_case("ACAD_SECTION_MANAGER")
|
||||
});
|
||||
dictionary.add_entry("ACAD_SECTION_MANAGER", handle);
|
||||
}
|
||||
handle
|
||||
});
|
||||
if let Some(ObjectType::ClassObject(manager)) =
|
||||
self.document.objects.get_mut(&manager_handle)
|
||||
{
|
||||
manager.owner = root;
|
||||
if let ClassObjectData::SectionManager(manager) = &mut manager.data {
|
||||
if !manager.sections.contains(&entity_handle) {
|
||||
manager.sections.push(entity_handle);
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
pub fn add_entity(&mut self, entity: EntityType) -> Handle {
|
||||
self.add_entity_internal(entity, true)
|
||||
}
|
||||
@@ -195,6 +295,7 @@ impl Scene {
|
||||
// bookkeeping and remains in place while an entity delta is undone.
|
||||
let mutates_block_structure =
|
||||
matches!(&entity, EntityType::Block(_) | EntityType::BlockEnd(_));
|
||||
let creates_section_structure = self.prepare_section_objects(&mut entity);
|
||||
let hatch_seed = if let EntityType::Hatch(dxf) = &entity {
|
||||
let color = self.render_style(&entity).0;
|
||||
Self::hatch_model_from_dxf(dxf, color)
|
||||
@@ -298,6 +399,9 @@ impl Scene {
|
||||
};
|
||||
|
||||
if !handle.is_null() {
|
||||
if let Some(keep) = self.refedit_keep.as_mut() {
|
||||
keep.insert(handle);
|
||||
}
|
||||
self.invalidate_dependency_index();
|
||||
if let Some(model) = hatch_seed {
|
||||
self.hatches.insert(handle, model);
|
||||
@@ -334,7 +438,11 @@ impl Scene {
|
||||
// state (a new layer, application ID, or block).
|
||||
if self.is_recording_undo() {
|
||||
self.record_undo_before(handle, None);
|
||||
if creates_layer || creates_app_id || mutates_block_structure {
|
||||
if creates_layer
|
||||
|| creates_app_id
|
||||
|| mutates_block_structure
|
||||
|| creates_section_structure
|
||||
{
|
||||
self.poison_undo_recording();
|
||||
}
|
||||
}
|
||||
@@ -2583,6 +2691,12 @@ impl Scene {
|
||||
self.solid_models = HashMap::default();
|
||||
*self.camera.borrow_mut() = Camera::default();
|
||||
self.camera_generation += 1;
|
||||
// A brand-new/replaced document has none of these yet — without
|
||||
// resetting them, a "new"/"open" (e.g. via automation, which reuses
|
||||
// this same reset) would leak the previous document's constraints
|
||||
// and named parameters into the fresh one.
|
||||
self.sketch_constraints.clear();
|
||||
self.named_parameters = crate::scene::named_parameters::ParameterTable::new();
|
||||
self.bump_geometry();
|
||||
}
|
||||
}
|
||||
+52
-15
@@ -183,21 +183,14 @@ impl Scene {
|
||||
handles: &[Handle],
|
||||
) -> HashSet<Handle> {
|
||||
let mut expanded: HashSet<Handle> = handles.iter().copied().collect();
|
||||
expanded.extend(
|
||||
self.document
|
||||
.objects
|
||||
.values()
|
||||
.filter_map(|obj| match obj {
|
||||
ObjectType::Group(g)
|
||||
if g.selectable
|
||||
&& handles.iter().any(|handle| g.contains(*handle)) =>
|
||||
{
|
||||
Some(g.entities.clone())
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.flatten(),
|
||||
);
|
||||
let wanted: HashSet<Handle> = handles.iter().copied().collect();
|
||||
for obj in self.document.objects.values() {
|
||||
if let ObjectType::Group(g) = obj {
|
||||
if g.selectable && g.entities.iter().any(|e| wanted.contains(e)) {
|
||||
expanded.extend(g.entities.iter().copied());
|
||||
}
|
||||
}
|
||||
}
|
||||
expanded
|
||||
}
|
||||
|
||||
@@ -211,3 +204,47 @@ impl Scene {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod group_expansion_tests {
|
||||
use super::*;
|
||||
use crate::scene::Scene;
|
||||
|
||||
#[test]
|
||||
fn group_expansion_pulls_in_siblings_and_nothing_else() {
|
||||
use acadrust::entities::{EntityType, Line};
|
||||
use acadrust::types::Vector3;
|
||||
|
||||
let mut scene = Scene::new();
|
||||
let mut line = |x: f64| {
|
||||
scene.add_entity(EntityType::Line(Line::from_points(
|
||||
Vector3::new(x, 0.0, 0.0),
|
||||
Vector3::new(x + 1.0, 0.0, 0.0),
|
||||
)))
|
||||
};
|
||||
let (a, b, c) = (line(0.0), line(1.0), line(2.0));
|
||||
let (d, e) = (line(3.0), line(4.0));
|
||||
let lonely = line(9.0);
|
||||
scene.create_group("GRUPPO".to_string(), vec![a, b, c]);
|
||||
scene.create_group("ALTRO".to_string(), vec![d, e]);
|
||||
|
||||
// One member pulls in its siblings, and only its own group's.
|
||||
let from_a = scene.handles_expanded_for_selectable_groups(&[a]);
|
||||
assert_eq!(
|
||||
from_a,
|
||||
[a, b, c].into_iter().collect::<HashSet<_>>(),
|
||||
"selecting a member must select that group and no other",
|
||||
);
|
||||
|
||||
// An entity in no group expands to itself.
|
||||
assert_eq!(
|
||||
scene.handles_expanded_for_selectable_groups(&[lonely]),
|
||||
[lonely].into_iter().collect::<HashSet<_>>(),
|
||||
);
|
||||
|
||||
// Touching both groups pulls in both, and nothing outside them.
|
||||
let both = scene.handles_expanded_for_selectable_groups(&[a, e]);
|
||||
assert_eq!(both, [a, b, c, d, e].into_iter().collect::<HashSet<_>>());
|
||||
assert!(!both.contains(&lonely));
|
||||
}
|
||||
}
|
||||
+636
-13
@@ -1901,6 +1901,7 @@ pub struct Scene {
|
||||
entity_block_map_cache: RefCell<Option<(u64, HashMap<Handle, Handle>)>>,
|
||||
/// Candidate handles per block, in document order and keyed by geometry epoch.
|
||||
block_members_cache: RefCell<Option<BlockMembers>>,
|
||||
leaders_by_annotation_cache: RefCell<Option<(u64, HashMap<Handle, Vec<Handle>>)>>,
|
||||
/// Insert/Viewport/Block/BlockEnd handles omitted by the spatial index.
|
||||
unindexable_cache: RefCell<Option<(u64, Vec<Handle>)>>,
|
||||
/// `(epoch, layout block, present type names, list exposed to the UI)`.
|
||||
@@ -2069,6 +2070,90 @@ pub struct Scene {
|
||||
undo_recording: Option<UndoRecording>,
|
||||
}
|
||||
|
||||
fn section_frame(
|
||||
data: &acadrust::entities::SectionObjectData,
|
||||
) -> Option<(glam::DVec3, glam::DVec3, glam::DVec3, glam::DVec3)> {
|
||||
let first = data.vertices.first()?;
|
||||
let last = data.vertices.last()?;
|
||||
let origin = glam::DVec3::new(first.x, first.y, first.z);
|
||||
let tangent = (glam::DVec3::new(last.x, last.y, last.z) - origin).try_normalize()?;
|
||||
let raw_vertical = glam::DVec3::new(
|
||||
data.vertical_direction.x,
|
||||
data.vertical_direction.y,
|
||||
data.vertical_direction.z,
|
||||
);
|
||||
let vertical = (raw_vertical - tangent * raw_vertical.dot(tangent)).try_normalize()?;
|
||||
let base_view = vertical.cross(tangent).try_normalize()?;
|
||||
let viewing = if data.flags & 4 != 0 { base_view } else { -base_view };
|
||||
Some((origin, tangent, vertical, viewing))
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct LiveSection {
|
||||
data: acadrust::entities::SectionObjectData,
|
||||
slice_depth: Option<f64>,
|
||||
}
|
||||
|
||||
fn section_plane(
|
||||
origin: glam::DVec3,
|
||||
tangent: glam::DVec3,
|
||||
normal: glam::DVec3,
|
||||
body_to_world: Option<&acadrust::types::Transform>,
|
||||
) -> Option<cadkernel::space::Plane> {
|
||||
let Some(transform) = body_to_world else {
|
||||
return cadkernel::space::Plane::orthonormal(
|
||||
origin.to_array(),
|
||||
tangent.to_array(),
|
||||
normal.to_array(),
|
||||
);
|
||||
};
|
||||
let matrix = transform.matrix.m;
|
||||
let linear = acadrust::types::Matrix3::from_rows(
|
||||
[matrix[0][0], matrix[0][1], matrix[0][2]],
|
||||
[matrix[1][0], matrix[1][1], matrix[1][2]],
|
||||
[matrix[2][0], matrix[2][1], matrix[2][2]],
|
||||
);
|
||||
let inverse = linear.inverse()?;
|
||||
let translation = acadrust::types::Vector3::new(
|
||||
matrix[0][3],
|
||||
matrix[1][3],
|
||||
matrix[2][3],
|
||||
);
|
||||
let origin = inverse.transform_point(
|
||||
acadrust::types::Vector3::new(origin.x, origin.y, origin.z) - translation,
|
||||
);
|
||||
let tangent = inverse.transform_point(acadrust::types::Vector3::new(
|
||||
tangent.x, tangent.y, tangent.z,
|
||||
));
|
||||
let normal = linear
|
||||
.transpose()
|
||||
.transform_point(acadrust::types::Vector3::new(normal.x, normal.y, normal.z));
|
||||
cadkernel::space::Plane::orthonormal(
|
||||
[origin.x, origin.y, origin.z],
|
||||
[tangent.x, tangent.y, tangent.z],
|
||||
[normal.x, normal.y, normal.z],
|
||||
)
|
||||
}
|
||||
|
||||
fn keep_section_positive(
|
||||
body: &cadkernel::brep::Body,
|
||||
plane: cadkernel::space::Plane,
|
||||
) -> Result<Option<cadkernel::brep::Body>, ()> {
|
||||
match cadkernel::brep::slice_by_plane(body, plane) {
|
||||
Ok(Some(result)) => Ok(Some(result.positive)),
|
||||
Ok(None) => {
|
||||
let bounds = cadkernel::brep::body_bounds(body).ok_or(())?;
|
||||
let centre = [
|
||||
(bounds.min[0] + bounds.max[0]) * 0.5,
|
||||
(bounds.min[1] + bounds.max[1]) * 0.5,
|
||||
(bounds.min[2] + bounds.max[2]) * 0.5,
|
||||
];
|
||||
Ok((plane.distance_to(centre).ok_or(())? >= 0.0).then(|| body.clone()))
|
||||
}
|
||||
Err(_) => Err(()),
|
||||
}
|
||||
}
|
||||
|
||||
impl Scene {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
@@ -2176,6 +2261,7 @@ impl Scene {
|
||||
model_extents_cache: RefCell::new(None),
|
||||
entity_block_map_cache: RefCell::new(None),
|
||||
block_members_cache: RefCell::new(None),
|
||||
leaders_by_annotation_cache: RefCell::new(None),
|
||||
unindexable_cache: RefCell::new(None),
|
||||
layout_type_names_cache: RefCell::new(None),
|
||||
dependency_index_cache: RefCell::new(None),
|
||||
@@ -2481,7 +2567,17 @@ impl Scene {
|
||||
bits |= CACHE_CATEGORY_IMAGE;
|
||||
}
|
||||
let is_insert = matches!(entity, EntityType::Insert(_));
|
||||
if self.meshes.contains_key(&handle) || self.block_meshes.contains_key(&handle) || is_insert
|
||||
let is_section = matches!(
|
||||
entity,
|
||||
EntityType::Extended(acadrust::entities::ExtendedEntity {
|
||||
data: acadrust::entities::ExtendedEntityData::SectionObject(_),
|
||||
..
|
||||
})
|
||||
);
|
||||
if self.meshes.contains_key(&handle)
|
||||
|| self.block_meshes.contains_key(&handle)
|
||||
|| is_insert
|
||||
|| is_section
|
||||
{
|
||||
bits |= CACHE_CATEGORY_MESH | CACHE_CATEGORY_INTERACTION;
|
||||
}
|
||||
@@ -6671,6 +6767,12 @@ impl Scene {
|
||||
|| matches!(
|
||||
self.document.get_entity(h),
|
||||
Some(EntityType::Insert(_))
|
||||
| Some(EntityType::Extended(
|
||||
acadrust::entities::ExtendedEntity {
|
||||
data: acadrust::entities::ExtendedEntityData::SectionObject(_),
|
||||
..
|
||||
}
|
||||
))
|
||||
)
|
||||
},
|
||||
) =>
|
||||
@@ -6857,6 +6959,7 @@ impl Scene {
|
||||
all_visible: bool,
|
||||
viewport: Option<Handle>,
|
||||
) -> Vec<MeshLodSet> {
|
||||
let live_section = self.active_live_section(target_block);
|
||||
// Top-level solids: drop those whose layer is off/frozen or that are
|
||||
// flagged invisible / isolated-hidden, mirroring the 2D wire path, plus
|
||||
// any whose layer is frozen in the requesting viewport.
|
||||
@@ -6878,8 +6981,19 @@ impl Scene {
|
||||
})
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.map(|(&handle, set)| {
|
||||
let mut set = set.clone();
|
||||
.filter_map(|(&handle, set)| {
|
||||
let mut set = if let Some(section) = live_section.as_ref() {
|
||||
match self.sectioned_body(handle, section) {
|
||||
Ok(Some(body)) => self
|
||||
.prepare_solid_model_display(handle, &body)
|
||||
.map(|display| display.0)
|
||||
.unwrap_or_else(|| set.clone()),
|
||||
Ok(None) => return None,
|
||||
Err(()) => set.clone(),
|
||||
}
|
||||
} else {
|
||||
set.clone()
|
||||
};
|
||||
if let Some(entity) = self.document.get_entity(handle) {
|
||||
let style = crate::scene::view::render::render_style_for_viewport(
|
||||
&self.document,
|
||||
@@ -6903,7 +7017,7 @@ impl Scene {
|
||||
material.diffuse[3] = style.0[3];
|
||||
}
|
||||
}
|
||||
set
|
||||
Some(set)
|
||||
})
|
||||
.collect();
|
||||
// Block-definition solids are instanced per INSERT of the ACTIVE space's
|
||||
@@ -6916,10 +7030,179 @@ impl Scene {
|
||||
annotation_scale_handle,
|
||||
all_visible,
|
||||
viewport,
|
||||
live_section.as_ref(),
|
||||
));
|
||||
all
|
||||
}
|
||||
|
||||
fn active_live_section(
|
||||
&self,
|
||||
target_block: Handle,
|
||||
) -> Option<LiveSection> {
|
||||
self.document
|
||||
.entities()
|
||||
.filter_map(|entity| {
|
||||
let EntityType::Extended(extended) = entity else {
|
||||
return None;
|
||||
};
|
||||
let acadrust::entities::ExtendedEntityData::SectionObject(data) = &extended.data else {
|
||||
return None;
|
||||
};
|
||||
let owner = extended.common.owner_handle;
|
||||
(data.flags & 1 != 0
|
||||
&& !extended.common.invisible
|
||||
&& (owner.is_null() || owner == target_block))
|
||||
.then(|| LiveSection {
|
||||
data: data.clone(),
|
||||
slice_depth: crate::entities::extended::section_is_slice(extended)
|
||||
.then(|| {
|
||||
crate::entities::extended::section_slice_depth(extended)
|
||||
.unwrap_or(0.0)
|
||||
}),
|
||||
})
|
||||
})
|
||||
.last()
|
||||
}
|
||||
|
||||
fn sectioned_body(
|
||||
&self,
|
||||
handle: Handle,
|
||||
section: &LiveSection,
|
||||
) -> Result<Option<cadkernel::brep::Body>, ()> {
|
||||
let body = self.section_source_body(handle).ok_or(())?;
|
||||
Self::section_body(&body, §ion.data, section.slice_depth, None)
|
||||
}
|
||||
|
||||
fn section_source_body(
|
||||
&self,
|
||||
handle: Handle,
|
||||
) -> Option<std::borrow::Cow<'_, cadkernel::brep::Body>> {
|
||||
if let Some(body) = self.solid_models.get(&handle) {
|
||||
return Some(std::borrow::Cow::Borrowed(body));
|
||||
}
|
||||
let body = match self.document.get_entity(handle) {
|
||||
Some(EntityType::Solid3D(solid)) => {
|
||||
crate::scene::convert::solid3d_tess::kernel_body(solid)
|
||||
}
|
||||
Some(EntityType::Region(region)) => {
|
||||
crate::scene::convert::solid3d_tess::kernel_region_body(region)
|
||||
}
|
||||
Some(EntityType::Body(body)) => {
|
||||
crate::scene::convert::solid3d_tess::kernel_acis_body(&body.acis_data)
|
||||
}
|
||||
Some(EntityType::Surface(surface)) => {
|
||||
crate::scene::convert::solid3d_tess::kernel_surface_body(surface)
|
||||
}
|
||||
_ => None,
|
||||
}?;
|
||||
Some(std::borrow::Cow::Owned(body))
|
||||
}
|
||||
|
||||
fn section_body(
|
||||
body: &cadkernel::brep::Body,
|
||||
data: &acadrust::entities::SectionObjectData,
|
||||
slice_depth: Option<f64>,
|
||||
body_to_world: Option<&acadrust::types::Transform>,
|
||||
) -> Result<Option<cadkernel::brep::Body>, ()> {
|
||||
let Some((origin, tangent, vertical, viewing)) = section_frame(data) else {
|
||||
return Err(());
|
||||
};
|
||||
let front = section_plane(origin, tangent, viewing, body_to_world).ok_or(())?;
|
||||
let mut clipped = keep_section_positive(body, front)?;
|
||||
let Some(mut current) = clipped.take() else {
|
||||
return Ok(None);
|
||||
};
|
||||
if data.state == 1 && slice_depth.is_none() {
|
||||
return Ok(Some(current));
|
||||
}
|
||||
|
||||
let depth = slice_depth.unwrap_or_else(|| {
|
||||
data.vertices
|
||||
.first()
|
||||
.zip(data.back_line_vertices.first())
|
||||
.map_or(0.0, |(front, back)| {
|
||||
(glam::DVec3::new(back.x, back.y, back.z)
|
||||
- glam::DVec3::new(front.x, front.y, front.z))
|
||||
.dot(viewing)
|
||||
.abs()
|
||||
})
|
||||
});
|
||||
if depth <= 1e-12 {
|
||||
return Ok(Some(current));
|
||||
}
|
||||
let back = section_plane(
|
||||
origin + viewing * depth,
|
||||
tangent,
|
||||
-viewing,
|
||||
body_to_world,
|
||||
)
|
||||
.ok_or(())?;
|
||||
let Some(next) = keep_section_positive(¤t, back)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
current = next;
|
||||
|
||||
if data.state == 1 {
|
||||
return Ok(Some(current));
|
||||
}
|
||||
|
||||
let span = data
|
||||
.vertices
|
||||
.first()
|
||||
.zip(data.vertices.last())
|
||||
.map_or(0.0, |(first, last)| {
|
||||
(glam::DVec3::new(last.x, last.y, last.z)
|
||||
- glam::DVec3::new(first.x, first.y, first.z))
|
||||
.length()
|
||||
});
|
||||
if span <= 1e-12 {
|
||||
return Ok(Some(current));
|
||||
}
|
||||
|
||||
let first = data.vertices.first().map(|point| glam::DVec3::new(point.x, point.y, point.z)).ok_or(())?;
|
||||
let last = data.vertices.last().map(|point| glam::DVec3::new(point.x, point.y, point.z)).ok_or(())?;
|
||||
for plane in [
|
||||
section_plane(first, vertical, tangent, body_to_world),
|
||||
section_plane(last, vertical, -tangent, body_to_world),
|
||||
] {
|
||||
let Some(plane) = plane else {
|
||||
return Err(());
|
||||
};
|
||||
let Some(next) = keep_section_positive(¤t, plane)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
current = next;
|
||||
}
|
||||
|
||||
if data.state != 4 {
|
||||
return Ok(Some(current));
|
||||
}
|
||||
|
||||
for plane in [
|
||||
section_plane(
|
||||
origin - vertical * data.bottom_height,
|
||||
tangent,
|
||||
vertical,
|
||||
body_to_world,
|
||||
),
|
||||
section_plane(
|
||||
origin + vertical * data.top_height,
|
||||
tangent,
|
||||
-vertical,
|
||||
body_to_world,
|
||||
),
|
||||
] {
|
||||
let Some(plane) = plane else {
|
||||
return Err(());
|
||||
};
|
||||
let Some(next) = keep_section_positive(¤t, plane)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
current = next;
|
||||
}
|
||||
Ok(Some(current))
|
||||
}
|
||||
|
||||
/// True when `layer` is turned off or frozen — entities on it never render.
|
||||
fn layer_hidden(&self, layer: &str) -> bool {
|
||||
self.document
|
||||
@@ -7199,6 +7482,7 @@ impl Scene {
|
||||
annotation_scale_handle: Option<Handle>,
|
||||
all_visible: bool,
|
||||
viewport: Option<Handle>,
|
||||
live_section: Option<&LiveSection>,
|
||||
) -> Vec<MeshLodSet> {
|
||||
if self.block_meshes.is_empty() {
|
||||
return Vec::new();
|
||||
@@ -7225,9 +7509,37 @@ impl Scene {
|
||||
return;
|
||||
}
|
||||
let handle = entity.common().handle;
|
||||
let Some(set) = self.block_meshes.get(&handle) else {
|
||||
let Some(original) = self.block_meshes.get(&handle) else {
|
||||
return;
|
||||
};
|
||||
let sectioned;
|
||||
let set = if let Some((section, body)) = live_section.and_then(|section| {
|
||||
self.section_source_body(handle)
|
||||
.map(|body| (section, body))
|
||||
}) {
|
||||
match Self::section_body(
|
||||
&body,
|
||||
§ion.data,
|
||||
section.slice_depth,
|
||||
Some(&context.transform),
|
||||
) {
|
||||
Ok(Some(body)) => {
|
||||
if let Some((mut mesh, _, _)) =
|
||||
self.prepare_solid_model_display(handle, &body)
|
||||
{
|
||||
mesh.prepare_instance_source(handle);
|
||||
sectioned = mesh;
|
||||
§ioned
|
||||
} else {
|
||||
original
|
||||
}
|
||||
}
|
||||
Ok(None) => return,
|
||||
Err(()) => original,
|
||||
}
|
||||
} else {
|
||||
original
|
||||
};
|
||||
let inherit = self.mesh_inherit_for_path(&context.insert_path);
|
||||
let own_alpha = set.display_color().map_or(1.0, |color| color[3]);
|
||||
let mut transformed =
|
||||
@@ -7939,6 +8251,7 @@ impl Scene {
|
||||
wires: Arc<Vec<WireModel>>,
|
||||
aabb: [f64; 4],
|
||||
allow_pending_empty: bool,
|
||||
area_only: bool,
|
||||
) -> crate::scene::pick::interaction_index::InteractionCandidates {
|
||||
if let Some((base_epoch, base, changes)) = self.interaction_overlay_base() {
|
||||
let perf = crate::perf::enabled();
|
||||
@@ -7957,11 +8270,20 @@ impl Scene {
|
||||
);
|
||||
let local_ms = t_local.elapsed().as_secs_f64() * 1000.0;
|
||||
let t_remap = iced::time::Instant::now();
|
||||
let mut result =
|
||||
base.query_remapped_xy(Arc::clone(&local), &base_slots, aabb);
|
||||
result.extend_indexed(
|
||||
changed_index.query_remapped_xy(local, &changed_slots, aabb),
|
||||
);
|
||||
let mut result = if area_only {
|
||||
base.query_remapped_xy_area(Arc::clone(&local), &base_slots, aabb)
|
||||
} else {
|
||||
base.query_remapped_xy(Arc::clone(&local), &base_slots, aabb)
|
||||
};
|
||||
if area_only {
|
||||
result.extend_indexed_area(
|
||||
changed_index.query_remapped_xy_area(local, &changed_slots, aabb),
|
||||
);
|
||||
} else {
|
||||
result.extend_indexed(
|
||||
changed_index.query_remapped_xy(local, &changed_slots, aabb),
|
||||
);
|
||||
}
|
||||
let remap_ms = t_remap.elapsed().as_secs_f64() * 1000.0;
|
||||
let total_ms = t0.elapsed().as_secs_f64() * 1000.0;
|
||||
if perf && total_ms >= 50.0 {
|
||||
@@ -7979,7 +8301,11 @@ impl Scene {
|
||||
return result;
|
||||
}
|
||||
if let Some(index) = self.cached_interaction_index(&wires) {
|
||||
index.query_xy(wires, aabb)
|
||||
if area_only {
|
||||
index.query_xy_area(wires, aabb)
|
||||
} else {
|
||||
index.query_xy(wires, aabb)
|
||||
}
|
||||
} else if allow_pending_empty
|
||||
&& self.interaction_index_pending_key.get()
|
||||
== Some((self.geometry_epoch, Arc::as_ptr(&wires) as usize))
|
||||
@@ -8302,12 +8628,18 @@ impl Scene {
|
||||
cursor.x + radius,
|
||||
cursor.y + radius,
|
||||
];
|
||||
self.indexed_interaction_candidates_xy(wires, query, allow_pending_empty)
|
||||
self.indexed_interaction_candidates_xy(wires, query, allow_pending_empty, false)
|
||||
}
|
||||
|
||||
/// Shared rectangular broad phase for box/lasso/fence and command windows.
|
||||
/// Flat orthographic views query world XY; tilted/perspective views query
|
||||
/// projected 3D bounds using the supplied screen rectangle.
|
||||
/// Candidates for an area selection: box, crossing, fence and lasso.
|
||||
///
|
||||
/// Every caller feeds the result to the box/fence hit tests and to
|
||||
/// `interaction_candidate_handles`, none of which snap, so the flat-ortho
|
||||
/// path asks for the reduced category set. A caller that needs snapping
|
||||
/// belongs on `interaction_candidates_near` instead.
|
||||
pub fn interaction_candidates_in_aabb(
|
||||
&self,
|
||||
wires: Arc<Vec<WireModel>>,
|
||||
@@ -8344,6 +8676,7 @@ impl Scene {
|
||||
wires,
|
||||
[aabb[0] - pad, aabb[1] - pad, aabb[2] + pad, aabb[3] + pad],
|
||||
false,
|
||||
true,
|
||||
)
|
||||
} else {
|
||||
self.indexed_interaction_candidates_screen(
|
||||
@@ -8399,7 +8732,7 @@ impl Scene {
|
||||
|
||||
pub fn interaction_handles_in_world_aabb(&self, aabb: [f64; 4]) -> HashSet<Handle> {
|
||||
let wires = self.hit_test_wires();
|
||||
let candidates = self.indexed_interaction_candidates_xy(wires, aabb, false);
|
||||
let candidates = self.indexed_interaction_candidates_xy(wires, aabb, false, false);
|
||||
let mut handles: HashSet<Handle> = candidates
|
||||
.iter()
|
||||
.filter_map(|wire| Self::handle_from_wire_name(&wire.name))
|
||||
@@ -10577,6 +10910,169 @@ impl Default for Scene {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod section_tests {
|
||||
use super::*;
|
||||
use acadrust::entities::{
|
||||
EntityCommon, ExtendedEntity, ExtendedEntityData, SectionObjectData, Solid3D,
|
||||
};
|
||||
use acadrust::objects::ClassObjectData;
|
||||
use acadrust::types::{Color, Vector3};
|
||||
|
||||
fn section(state: i32, depth: f64) -> SectionObjectData {
|
||||
let vertices = vec![Vector3::new(5.0, 2.0, 5.0), Vector3::new(5.0, 8.0, 5.0)];
|
||||
SectionObjectData {
|
||||
state,
|
||||
flags: 5,
|
||||
name: "Section Plane (1)".to_string(),
|
||||
vertical_direction: Vector3::UNIT_Z,
|
||||
top_height: 1.0,
|
||||
bottom_height: 2.0,
|
||||
indicator_alpha: 70,
|
||||
indicator_color: Color::from_index(9),
|
||||
back_line_vertices: (depth > 0.0)
|
||||
.then(|| {
|
||||
vertices
|
||||
.iter()
|
||||
.map(|point| *point + Vector3::new(-depth, 0.0, 0.0))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
vertices,
|
||||
settings_handle: Handle::NULL,
|
||||
}
|
||||
}
|
||||
|
||||
fn bounds(body: Option<cadkernel::brep::Body>) -> ([f64; 3], [f64; 3]) {
|
||||
let body = body.expect("section should retain material");
|
||||
assert!(body.validate().is_empty());
|
||||
let bounds = cadkernel::brep::body_bounds(&body).expect("sectioned body should be bounded");
|
||||
(bounds.min, bounds.max)
|
||||
}
|
||||
|
||||
fn assert_near(actual: [f64; 3], expected: [f64; 3]) {
|
||||
for (actual, expected) in actual.into_iter().zip(expected) {
|
||||
assert!((actual - expected).abs() < 1e-8, "{actual} != {expected}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kernel_clipping_obeys_plane_slice_boundary_and_volume_limits() {
|
||||
let body = cadkernel::brep::make::cuboid([0.0; 3], [10.0; 3]).unwrap();
|
||||
|
||||
let (min, max) = bounds(Scene::section_body(&body, §ion(1, 0.0), None, None).unwrap());
|
||||
assert_near(min, [0.0, 0.0, 0.0]);
|
||||
assert_near(max, [5.0, 10.0, 10.0]);
|
||||
|
||||
let (min, max) = bounds(
|
||||
Scene::section_body(&body, §ion(1, 0.0), Some(2.0), None).unwrap(),
|
||||
);
|
||||
assert_near(min, [3.0, 0.0, 0.0]);
|
||||
assert_near(max, [5.0, 10.0, 10.0]);
|
||||
|
||||
let (min, max) = bounds(Scene::section_body(&body, §ion(2, 4.0), None, None).unwrap());
|
||||
assert_near(min, [1.0, 2.0, 0.0]);
|
||||
assert_near(max, [5.0, 8.0, 10.0]);
|
||||
|
||||
let (min, max) = bounds(Scene::section_body(&body, §ion(4, 4.0), None, None).unwrap());
|
||||
assert_near(min, [1.0, 2.0, 3.0]);
|
||||
assert_near(max, [5.0, 8.0, 6.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instance_transform_pulls_the_section_plane_into_body_space() {
|
||||
let body = cadkernel::brep::make::cuboid([0.0; 3], [10.0; 3]).unwrap();
|
||||
let transform = acadrust::types::Transform::from_scaling(
|
||||
acadrust::types::Vector3::new(2.0, 1.0, 1.0),
|
||||
)
|
||||
.then(&acadrust::types::Transform::from_translation(
|
||||
acadrust::types::Vector3::new(10.0, 0.0, 0.0),
|
||||
));
|
||||
let mut data = section(1, 0.0);
|
||||
for point in &mut data.vertices {
|
||||
point.x = 20.0;
|
||||
}
|
||||
|
||||
let (min, max) = bounds(
|
||||
Scene::section_body(&body, &data, None, Some(&transform)).unwrap(),
|
||||
);
|
||||
assert_near(min, [0.0, 0.0, 0.0]);
|
||||
assert_near(max, [5.0, 10.0, 10.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adding_and_erasing_a_section_keeps_its_object_graph_consistent() {
|
||||
let mut scene = Scene::new();
|
||||
let handle = scene.add_entity(EntityType::Extended(ExtendedEntity {
|
||||
common: EntityCommon::new(),
|
||||
data: ExtendedEntityData::SectionObject(section(1, 0.0)),
|
||||
}));
|
||||
let settings_handle = match scene.document.get_entity(handle).unwrap() {
|
||||
EntityType::Extended(ExtendedEntity {
|
||||
data: ExtendedEntityData::SectionObject(data),
|
||||
..
|
||||
}) => data.settings_handle,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
assert!(matches!(
|
||||
scene.document.objects.get(&settings_handle),
|
||||
Some(ObjectType::ClassObject(object))
|
||||
if object.owner == handle
|
||||
&& matches!(&object.data, ClassObjectData::SectionSettings(_))
|
||||
));
|
||||
|
||||
let root = scene.document.header.named_objects_dict_handle;
|
||||
let manager_handle = match scene.document.objects.get(&root).unwrap() {
|
||||
ObjectType::Dictionary(dictionary) => dictionary
|
||||
.entries
|
||||
.iter()
|
||||
.find(|(name, _)| name == "ACAD_SECTION_MANAGER")
|
||||
.map(|(_, handle)| *handle)
|
||||
.unwrap(),
|
||||
_ => panic!("root must be a dictionary"),
|
||||
};
|
||||
assert!(matches!(
|
||||
scene.document.objects.get(&manager_handle),
|
||||
Some(ObjectType::ClassObject(object))
|
||||
if matches!(&object.data, ClassObjectData::SectionManager(manager)
|
||||
if manager.sections == vec![handle])
|
||||
));
|
||||
|
||||
scene.erase_entities(&[handle]);
|
||||
assert!(!scene.document.objects.contains_key(&settings_handle));
|
||||
assert!(matches!(
|
||||
scene.document.objects.get(&manager_handle),
|
||||
Some(ObjectType::ClassObject(object))
|
||||
if matches!(&object.data, ClassObjectData::SectionManager(manager)
|
||||
if manager.sections.is_empty())
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_section_rebuilds_an_uncached_persisted_solid() {
|
||||
let body = cadkernel::brep::make::cuboid([0.0; 3], [10.0; 3]).unwrap();
|
||||
let sat = crate::scene::convert::acis_export::solid_to_sat(&body).unwrap();
|
||||
let mut solid = Solid3D::new();
|
||||
solid.set_sat_document(&sat);
|
||||
let mut scene = Scene::new();
|
||||
let handle = scene.add_entity(EntityType::Solid3D(solid));
|
||||
assert!(!scene.solid_models.contains_key(&handle));
|
||||
|
||||
let clipped = scene
|
||||
.sectioned_body(
|
||||
handle,
|
||||
&LiveSection {
|
||||
data: section(1, 0.0),
|
||||
slice_depth: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let (min, max) = bounds(clipped);
|
||||
assert_near(min, [0.0, 0.0, 0.0]);
|
||||
assert_near(max, [5.0, 10.0, 10.0]);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod journal_tests {
|
||||
use super::*;
|
||||
@@ -10874,6 +11370,8 @@ mod journal_tests {
|
||||
assert_eq!(folded.len(), before.len() + 1);
|
||||
}
|
||||
|
||||
// Differential oracle: the incrementally-patched entity index must always
|
||||
// equal a from-scratch rebuild after any add / move / erase.
|
||||
#[test]
|
||||
fn block_member_index_matches_the_full_scan() {
|
||||
use acadrust::entities::Line;
|
||||
@@ -12090,4 +12588,129 @@ mod layout_cache_tests {
|
||||
println!("Zoom Frame Overhead: {:.3} ms ({:.0} FPS)", per_zoom_ms, 1000.0 / per_zoom_ms.max(0.001));
|
||||
println!("============================================================================\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "benchmark"]
|
||||
fn bench_wide_and_tapered_arc_rendering() {
|
||||
use std::time::Instant;
|
||||
use acadrust::entities::LwPolyline;
|
||||
use acadrust::types::Vector2;
|
||||
|
||||
let n_wide_arcs = 2500;
|
||||
let n_tapered_arcs = 2500;
|
||||
let n_donuts = 2500;
|
||||
let total_entities = n_wide_arcs + n_tapered_arcs + n_donuts;
|
||||
|
||||
let mut scene = Scene::new();
|
||||
|
||||
// 1. Wide polyline arcs (constant width 6.0)
|
||||
for i in 0..n_wide_arcs {
|
||||
let x = (i % 50) as f64 * 80.0;
|
||||
let y = (i / 50) as f64 * 80.0;
|
||||
let mut pline = LwPolyline::new();
|
||||
pline.constant_width = 6.0;
|
||||
pline.vertices = vec![
|
||||
acadrust::entities::LwVertex {
|
||||
location: Vector2::new(x, y),
|
||||
bulge: 0.5,
|
||||
start_width: 0.0,
|
||||
end_width: 0.0,
|
||||
vertex_id: 0,
|
||||
},
|
||||
acadrust::entities::LwVertex {
|
||||
location: Vector2::new(x + 40.0, y),
|
||||
bulge: 0.0,
|
||||
start_width: 0.0,
|
||||
end_width: 0.0,
|
||||
vertex_id: 1,
|
||||
},
|
||||
];
|
||||
scene.add_entity(EntityType::LwPolyline(pline));
|
||||
}
|
||||
|
||||
// 2. Tapered arcs (width: 2.0 -> 12.0)
|
||||
for i in 0..n_tapered_arcs {
|
||||
let x = (i % 50) as f64 * 80.0;
|
||||
let y = (i / 50) as f64 * 80.0 + 4500.0;
|
||||
let mut pline = LwPolyline::new();
|
||||
pline.vertices = vec![
|
||||
acadrust::entities::LwVertex {
|
||||
location: Vector2::new(x, y),
|
||||
bulge: 0.7,
|
||||
start_width: 2.0,
|
||||
end_width: 12.0,
|
||||
vertex_id: 0,
|
||||
},
|
||||
acadrust::entities::LwVertex {
|
||||
location: Vector2::new(x + 35.0, y + 10.0),
|
||||
bulge: 0.0,
|
||||
start_width: 0.0,
|
||||
end_width: 0.0,
|
||||
vertex_id: 1,
|
||||
},
|
||||
];
|
||||
scene.add_entity(EntityType::LwPolyline(pline));
|
||||
}
|
||||
|
||||
// 3. Donuts (2 semicircular arc segments, constant width 16.0)
|
||||
for i in 0..n_donuts {
|
||||
let cx = (i % 50) as f64 * 80.0;
|
||||
let cy = (i / 50) as f64 * 80.0 + 9000.0;
|
||||
let donut = crate::modules::draw::draw::donut::make_donut(cx, cy, 0.0, 10.0, 26.0);
|
||||
scene.add_entity(donut);
|
||||
}
|
||||
|
||||
let cam = Camera::default();
|
||||
let t_tess_start = Instant::now();
|
||||
let wires = scene.model_tile_wires_arc(0, &cam, 1.0, 1000.0);
|
||||
let tess_duration = t_tess_start.elapsed();
|
||||
|
||||
let total_wires = wires.len();
|
||||
let mut chord_points = 0usize;
|
||||
let mut analytical_tangents = 0usize;
|
||||
for w in wires.iter() {
|
||||
chord_points += w.points.len();
|
||||
analytical_tangents += w.tangent_geoms.len();
|
||||
}
|
||||
|
||||
let depths = rustc_hash::FxHashMap::default();
|
||||
let t_part_start = Instant::now();
|
||||
let iters = 100;
|
||||
let mut part = None;
|
||||
for _ in 0..iters {
|
||||
part = Some(crate::scene::pipeline::wire_arena::partition_wires(&wires, &depths));
|
||||
}
|
||||
let part_duration = t_part_start.elapsed() / (iters as u32);
|
||||
let p = part.unwrap();
|
||||
|
||||
// Zoom simulation
|
||||
let zoom_levels = [0.1f32, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0, 50.0, 100.0, 1000.0];
|
||||
let t_zoom_start = Instant::now();
|
||||
let mut dynamic_cam = Camera::default();
|
||||
for &zoom in zoom_levels.iter().cycle().take(100) {
|
||||
dynamic_cam.distance = 1000.0 / zoom;
|
||||
let _w = scene.model_tile_wires_arc(0, &dynamic_cam, 1.0, 1000.0);
|
||||
}
|
||||
let zoom_100_duration = t_zoom_start.elapsed();
|
||||
let per_zoom_ms = zoom_100_duration.as_secs_f64() * 1000.0 / 100.0;
|
||||
|
||||
let line_vram_mb = (chord_points * 36) as f64 / (1024.0 * 1024.0);
|
||||
let inst_vram_mb = (p.circle_instances.len() * 128) as f64 / (1024.0 * 1024.0);
|
||||
|
||||
println!("\n=== BENCHMARK REPORT: THICK & TAPERED ANALYTICAL ARCS ===");
|
||||
println!("Thick & Tapered Entities: {total_entities}");
|
||||
println!("Initial Wire Build Time: {:.2?}", tess_duration);
|
||||
println!("Total Wires: {total_wires}");
|
||||
println!("Tessellated Chord Vertices: {chord_points}");
|
||||
println!("Analytical Tangent Geometries: {analytical_tangents}");
|
||||
println!("Partitioning Time (per frame): {:.3?}", part_duration);
|
||||
println!("Regular Line Wires: {}", p.regular.len());
|
||||
println!("GPU Circle/Arc Instances: {}", p.circle_instances.len());
|
||||
println!("Line Arena VRAM (chord lines): {:.2} MB", line_vram_mb);
|
||||
println!("GPU Analytical VRAM: {:.2} MB", inst_vram_mb);
|
||||
println!("VRAM Reduction Ratio: {:.1}x", if inst_vram_mb > 0.0 { line_vram_mb / inst_vram_mb } else { 0.0 });
|
||||
println!("100 Camera Zoom Navigations: {:.2?}", zoom_100_duration);
|
||||
println!("Zoom Frame Overhead: {:.3} ms ({:.0} FPS)", per_zoom_ms, 1000.0 / per_zoom_ms.max(0.001));
|
||||
println!("========================================================\n");
|
||||
}
|
||||
}
|
||||
@@ -1013,44 +1013,9 @@ fn brep_properties(
|
||||
history_flags(document, handle).unwrap_or((false, false, 1));
|
||||
let (show_history, show_history_editable) =
|
||||
displayed_history_state(object_show_history, show_history_mode);
|
||||
let mut geometry = vec![Property {
|
||||
label: t!("Solid type").into_owned(),
|
||||
field: "solid_history_type",
|
||||
value: PropValue::ReadOnly(t!("3D Solid").into_owned()),
|
||||
}];
|
||||
if let Some(position) = document
|
||||
.solid_history_operation(handle)
|
||||
.and_then(|operation| match operation {
|
||||
SolidHistoryOperation::Brep(value) => world_point(value.base.transform, [0.0; 3]),
|
||||
_ => None,
|
||||
})
|
||||
{
|
||||
geometry.extend([
|
||||
crate::entities::common::edit_prop(
|
||||
t!("Position X").as_ref(),
|
||||
PROP_POSITION_X,
|
||||
position.x,
|
||||
),
|
||||
crate::entities::common::edit_prop(
|
||||
t!("Position Y").as_ref(),
|
||||
PROP_POSITION_Y,
|
||||
position.y,
|
||||
),
|
||||
crate::entities::common::edit_prop(
|
||||
t!("Position Z").as_ref(),
|
||||
PROP_POSITION_Z,
|
||||
position.z,
|
||||
),
|
||||
]);
|
||||
}
|
||||
vec![
|
||||
PropSection {
|
||||
title: t!("Geometry").into_owned(),
|
||||
props: geometry,
|
||||
},
|
||||
PropSection {
|
||||
title: t!("Solid History").into_owned(),
|
||||
props: vec![
|
||||
vec![PropSection {
|
||||
title: t!("Solid History").into_owned(),
|
||||
props: vec![
|
||||
Property {
|
||||
label: t!("History").into_owned(),
|
||||
field: PROP_HISTORY,
|
||||
@@ -1072,8 +1037,7 @@ fn brep_properties(
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
}]
|
||||
}
|
||||
|
||||
fn cylinder_properties(
|
||||
|
||||
+117
-16
@@ -13,7 +13,7 @@ use iced::{Point, Rectangle};
|
||||
use crate::scene::model::hatch_model::HatchModel;
|
||||
use crate::scene::model::mesh_model::MeshModel;
|
||||
use crate::scene::model::wire_model::WireModel;
|
||||
use crate::scene::pick::interaction_index::WireSource;
|
||||
use crate::scene::pick::interaction_index::{SegmentRef, WireSource};
|
||||
|
||||
/// Pick radius for one wire, in screen pixels.
|
||||
///
|
||||
@@ -1247,13 +1247,14 @@ fn indexed_box_crossing_hits<'a, W: WireSource + ?Sized>(
|
||||
|| (0..4).any(|edge| segments_intersect(a, b, corners[edge], corners[(edge + 1) % 4]))
|
||||
};
|
||||
|
||||
for segment in wires.segments().unwrap_or_default() {
|
||||
let Some(wire) = wires.source_wire(segment.wire) else {
|
||||
continue;
|
||||
};
|
||||
// One projection pair per segment, reading nothing but the wire, the view
|
||||
// and the box. Hoisted into a closure so the sequential and the parallel
|
||||
// path below run identical code.
|
||||
let hit_name = |segment: &SegmentRef| -> Option<&'a str> {
|
||||
let wire = wires.source_wire(segment.wire)?;
|
||||
let start = segment.start as usize;
|
||||
if start + 1 >= wire.points.len() {
|
||||
continue;
|
||||
return None;
|
||||
}
|
||||
let a = world_to_screen(
|
||||
wire_point_world(wire, start, view_rot, eye),
|
||||
@@ -1267,8 +1268,30 @@ fn indexed_box_crossing_hits<'a, W: WireSource + ?Sized>(
|
||||
eye,
|
||||
bounds,
|
||||
);
|
||||
if segment_hits(a, b) && seen.insert(wire.name.as_str()) {
|
||||
out.push(wire.name.as_str());
|
||||
segment_hits(a, b).then_some(wire.name.as_str())
|
||||
};
|
||||
|
||||
let mut wire_hit: Vec<bool> = Vec::new();
|
||||
fn mark(wire_hit: &mut Vec<bool>, wire: u32) {
|
||||
let index = wire as usize;
|
||||
if index >= wire_hit.len() {
|
||||
wire_hit.resize(index + 1, false);
|
||||
}
|
||||
wire_hit[index] = true;
|
||||
}
|
||||
fn already(wire_hit: &[bool], wire: u32) -> bool {
|
||||
wire_hit.get(wire as usize).copied().unwrap_or(false)
|
||||
}
|
||||
|
||||
for segment in wires.segments().unwrap_or_default() {
|
||||
if already(&wire_hit, segment.wire) {
|
||||
continue;
|
||||
}
|
||||
if let Some(name) = hit_name(segment) {
|
||||
mark(&mut wire_hit, segment.wire);
|
||||
if seen.insert(name) {
|
||||
out.push(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
for wire in wires.iter().filter(|wire| wire.point_marker.is_some()) {
|
||||
@@ -1287,7 +1310,7 @@ fn indexed_box_crossing_hits<'a, W: WireSource + ?Sized>(
|
||||
let Some(wire) = wires.source_wire(triangle.wire) else {
|
||||
continue;
|
||||
};
|
||||
if seen.contains(wire.name.as_str()) {
|
||||
if already(&wire_hit, triangle.wire) {
|
||||
continue;
|
||||
}
|
||||
if projected_wire_triangle(
|
||||
@@ -1299,9 +1322,11 @@ fn indexed_box_crossing_hits<'a, W: WireSource + ?Sized>(
|
||||
bounds,
|
||||
)
|
||||
.is_some_and(|triangle| triangle_crosses_box(triangle, corners))
|
||||
&& seen.insert(wire.name.as_str())
|
||||
{
|
||||
out.push(wire.name.as_str());
|
||||
mark(&mut wire_hit, triangle.wire);
|
||||
if seen.insert(wire.name.as_str()) {
|
||||
out.push(wire.name.as_str());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1309,7 +1334,7 @@ fn indexed_box_crossing_hits<'a, W: WireSource + ?Sized>(
|
||||
let Some(wire) = wires.source_wire(glyph.wire) else {
|
||||
continue;
|
||||
};
|
||||
if seen.contains(wire.name.as_str()) {
|
||||
if already(&wire_hit, glyph.wire) {
|
||||
continue;
|
||||
}
|
||||
let Some(screen) =
|
||||
@@ -1330,11 +1355,11 @@ fn indexed_box_crossing_hits<'a, W: WireSource + ?Sized>(
|
||||
|
||||
// Degenerate point-only wires have no indexed segment or surface primitive.
|
||||
for wire in wires.iter() {
|
||||
if seen.contains(wire.name.as_str())
|
||||
|| wire.points.len() >= 2
|
||||
if wire.points.len() >= 2
|
||||
|| !wire.fill_tris.is_empty()
|
||||
|| !wire.pick_tris.is_empty()
|
||||
|| !wire.text_verts.is_empty()
|
||||
|| seen.contains(wire.name.as_str())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -1540,11 +1565,11 @@ fn indexed_polygon_crossing_hits<'a, W: WireSource + ?Sized>(
|
||||
}
|
||||
}
|
||||
for wire in wires.iter() {
|
||||
if seen.contains(wire.name.as_str())
|
||||
|| wire.points.len() >= 2
|
||||
if wire.points.len() >= 2
|
||||
|| !wire.fill_tris.is_empty()
|
||||
|| !wire.pick_tris.is_empty()
|
||||
|| !wire.text_verts.is_empty()
|
||||
|| seen.contains(wire.name.as_str())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -2712,3 +2737,79 @@ mod aabb_reject_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod parallel_selection_tests {
|
||||
use super::*;
|
||||
use crate::scene::pick::interaction_index::{InteractionCandidates, InteractionIndex};
|
||||
use std::sync::Arc;
|
||||
|
||||
fn many_wires_with_spans(count: usize, spans: usize) -> Vec<WireModel> {
|
||||
(0..count)
|
||||
.map(|i| {
|
||||
let t = i as f32 / count as f32;
|
||||
let x = -0.95 + 1.9 * t;
|
||||
let y = -0.95 + 1.9 * t;
|
||||
let points: Vec<[f32; 3]> = (0..=spans)
|
||||
.map(|s| {
|
||||
let f = s as f32 / spans as f32;
|
||||
[x + 0.01 * f, y + 0.01 * f, 0.0]
|
||||
})
|
||||
.collect();
|
||||
let mut w = WireModel::solid(
|
||||
(i as u64 + 1).to_string(),
|
||||
points,
|
||||
[1.0; 4],
|
||||
false,
|
||||
);
|
||||
w.aabb = [x, y, x + 0.01, y + 0.01];
|
||||
w
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "measurement, not a check"]
|
||||
fn selection_scaling_numbers() {
|
||||
use std::time::Instant;
|
||||
let wires = many_wires_with_spans(186_468, 24);
|
||||
let index = InteractionIndex::build(&wires);
|
||||
let arc = Arc::new(wires);
|
||||
let aabb = [-2.0, -2.0, 2.0, 2.0];
|
||||
|
||||
let t = Instant::now();
|
||||
let full = index.query_xy(Arc::clone(&arc), aabb);
|
||||
let full_ms = t.elapsed().as_secs_f64() * 1000.0;
|
||||
|
||||
let t = Instant::now();
|
||||
let area = index.query_xy_area(Arc::clone(&arc), aabb);
|
||||
let area_ms = t.elapsed().as_secs_f64() * 1000.0;
|
||||
|
||||
let bounds = Rectangle {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
width: 200.0,
|
||||
height: 200.0,
|
||||
};
|
||||
let run = |candidates: &InteractionCandidates| {
|
||||
let t = Instant::now();
|
||||
let hits = box_hit(
|
||||
Point::new(0.0, 0.0),
|
||||
Point::new(200.0, 200.0),
|
||||
true,
|
||||
candidates,
|
||||
Mat4::IDENTITY,
|
||||
glam::DVec3::ZERO,
|
||||
bounds,
|
||||
);
|
||||
(t.elapsed().as_secs_f64() * 1000.0, hits.len())
|
||||
};
|
||||
let (scan_ms, scan_n) = run(&area);
|
||||
|
||||
println!(
|
||||
"candidates: full={full_ms:.1}ms ({} wires) area={area_ms:.1}ms ({} wires)",
|
||||
full.len(),
|
||||
area.len(),
|
||||
);
|
||||
println!("crossing scan: {scan_ms:.1}ms ({scan_n} hits)");
|
||||
}
|
||||
}
|
||||
@@ -458,6 +458,62 @@ impl SpatialGrid {
|
||||
}
|
||||
}
|
||||
|
||||
fn query_unordered<T: Copy>(&self, entries: &[Entry3<T>], query: [f64; 4]) -> Vec<T> {
|
||||
let mut indices = Vec::new();
|
||||
self.oversized.query(entries, query, &mut indices);
|
||||
let mut out: Vec<T> = indices
|
||||
.into_iter()
|
||||
.filter_map(|idx| {
|
||||
let entry = entries.get(idx as usize)?;
|
||||
aabb_overlaps(entry_aabb2(entry), query).then_some(entry.value)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let grid_max_x = self.min[0] + self.cols as f64 * self.cell;
|
||||
let grid_max_y = self.min[1] + self.rows as f64 * self.cell;
|
||||
if query[2] < self.min[0]
|
||||
|| query[3] < self.min[1]
|
||||
|| query[0] > grid_max_x
|
||||
|| query[1] > grid_max_y
|
||||
{
|
||||
return out;
|
||||
}
|
||||
let col = |x: f64| {
|
||||
(((x - self.min[0]) / self.cell).floor()).clamp(0.0, (self.cols - 1) as f64) as u32
|
||||
};
|
||||
let row = |y: f64| {
|
||||
(((y - self.min[1]) / self.cell).floor()).clamp(0.0, (self.rows - 1) as f64) as u32
|
||||
};
|
||||
let (row_lo, row_hi) = (row(query[1]), row(query[3]));
|
||||
let (col_lo, col_hi) = (col(query[0]), col(query[2]));
|
||||
|
||||
// One growth instead of a chain of reallocations: a box over the whole
|
||||
// drawing lands nearly every entry here.
|
||||
let spanned: usize = (row_lo..=row_hi)
|
||||
.map(|r| {
|
||||
let base = r as usize * self.cols as usize;
|
||||
let start = self.cell_offsets[base + col_lo as usize] as usize;
|
||||
let end = self.cell_offsets[base + col_hi as usize + 1] as usize;
|
||||
end.saturating_sub(start)
|
||||
})
|
||||
.sum();
|
||||
out.reserve(spanned);
|
||||
|
||||
for r in row_lo..=row_hi {
|
||||
let base = r as usize * self.cols as usize;
|
||||
for c in col_lo..=col_hi {
|
||||
let cell_idx = base + c as usize;
|
||||
let start = self.cell_offsets[cell_idx] as usize;
|
||||
let end = self.cell_offsets[cell_idx + 1] as usize;
|
||||
out.extend(self.cell_entries[start..end].iter().filter_map(|&idx| {
|
||||
let entry = entries.get(idx as usize)?;
|
||||
aabb_overlaps(entry_aabb2(entry), query).then_some(entry.value)
|
||||
}));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn query<T: Copy + Ord>(&self, entries: &[Entry3<T>], query: [f64; 4]) -> Vec<T> {
|
||||
let mut entry_indices = Vec::new();
|
||||
self.oversized.query(entries, query, &mut entry_indices);
|
||||
@@ -520,6 +576,11 @@ impl<T: Copy + Ord + Sync> SpatialSet<T> {
|
||||
self.xy.query(&self.entries, aabb)
|
||||
}
|
||||
|
||||
/// See [`SpatialGrid::query_unordered`] — same set, no sort, duplicates in.
|
||||
fn query_xy_unordered(&self, aabb: [f64; 4]) -> Vec<T> {
|
||||
self.xy.query_unordered(&self.entries, aabb)
|
||||
}
|
||||
|
||||
fn prepare_screen(&self) {
|
||||
self.xyz
|
||||
.get_or_init(|| SpatialBvh3::build(&self.entries));
|
||||
@@ -839,17 +900,17 @@ impl InteractionIndex {
|
||||
let (
|
||||
((segment_entries, snap_point_entries), (key_vertex_entries, key_segment_entries)),
|
||||
((fill_triangle_entries, pick_triangle_entries), glyph_entries),
|
||||
) = rayon::join(
|
||||
) = crate::par::join(
|
||||
|| {
|
||||
rayon::join(
|
||||
crate::par::join(
|
||||
|| {
|
||||
rayon::join(
|
||||
crate::par::join(
|
||||
|| flatten_entry_parts(segment_parts),
|
||||
|| flatten_entry_parts(snap_point_parts),
|
||||
)
|
||||
},
|
||||
|| {
|
||||
rayon::join(
|
||||
crate::par::join(
|
||||
|| flatten_entry_parts(key_vertex_parts),
|
||||
|| flatten_entry_parts(key_segment_parts),
|
||||
)
|
||||
@@ -857,7 +918,7 @@ impl InteractionIndex {
|
||||
)
|
||||
},
|
||||
|| {
|
||||
let (fill, pick) = rayon::join(
|
||||
let (fill, pick) = crate::par::join(
|
||||
|| flatten_entry_parts(fill_triangle_parts),
|
||||
|| flatten_entry_parts(pick_triangle_parts),
|
||||
);
|
||||
@@ -891,17 +952,17 @@ impl InteractionIndex {
|
||||
let (
|
||||
((wires, segments), (snap_points, key_vertices)),
|
||||
((key_segments, fill_triangles), (pick_triangles, glyphs)),
|
||||
) = rayon::join(
|
||||
) = crate::par::join(
|
||||
|| {
|
||||
rayon::join(
|
||||
crate::par::join(
|
||||
|| {
|
||||
rayon::join(
|
||||
crate::par::join(
|
||||
|| SpatialSet::build(wire_entries),
|
||||
|| SpatialSet::build(segment_entries),
|
||||
)
|
||||
},
|
||||
|| {
|
||||
rayon::join(
|
||||
crate::par::join(
|
||||
|| SpatialSet::build(snap_point_entries),
|
||||
|| SpatialSet::build(key_vertex_entries),
|
||||
)
|
||||
@@ -909,15 +970,15 @@ impl InteractionIndex {
|
||||
)
|
||||
},
|
||||
|| {
|
||||
rayon::join(
|
||||
crate::par::join(
|
||||
|| {
|
||||
rayon::join(
|
||||
crate::par::join(
|
||||
|| SpatialSet::build(key_segment_entries),
|
||||
|| SpatialSet::build(fill_triangle_entries),
|
||||
)
|
||||
},
|
||||
|| {
|
||||
rayon::join(
|
||||
crate::par::join(
|
||||
|| SpatialSet::build(pick_triangle_entries),
|
||||
|| SpatialSet::build(glyph_entries),
|
||||
)
|
||||
@@ -979,12 +1040,12 @@ impl InteractionIndex {
|
||||
/// thread. Perspective/orbit hover must never pay this one-time cost.
|
||||
pub fn prepare_screen(&self) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
rayon::join(
|
||||
crate::par::join(
|
||||
|| {
|
||||
rayon::join(
|
||||
|| rayon::join(|| self.wires.prepare_screen(), || self.segments.prepare_screen()),
|
||||
crate::par::join(
|
||||
|| crate::par::join(|| self.wires.prepare_screen(), || self.segments.prepare_screen()),
|
||||
|| {
|
||||
rayon::join(
|
||||
crate::par::join(
|
||||
|| self.snap_points.prepare_screen(),
|
||||
|| self.key_vertices.prepare_screen(),
|
||||
)
|
||||
@@ -992,15 +1053,15 @@ impl InteractionIndex {
|
||||
)
|
||||
},
|
||||
|| {
|
||||
rayon::join(
|
||||
crate::par::join(
|
||||
|| {
|
||||
rayon::join(
|
||||
crate::par::join(
|
||||
|| self.key_segments.prepare_screen(),
|
||||
|| self.fill_triangles.prepare_screen(),
|
||||
)
|
||||
},
|
||||
|| {
|
||||
rayon::join(
|
||||
crate::par::join(
|
||||
|| self.pick_triangles.prepare_screen(),
|
||||
|| self.glyphs.prepare_screen(),
|
||||
)
|
||||
@@ -1136,6 +1197,52 @@ impl InteractionIndex {
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn query_remapped_xy_area(
|
||||
&self,
|
||||
wires: Arc<Vec<WireModel>>,
|
||||
slots: &rustc_hash::FxHashMap<(u64, u32), u32>,
|
||||
aabb: [f64; 4],
|
||||
) -> InteractionCandidates {
|
||||
InteractionCandidates {
|
||||
wires,
|
||||
wire_indices: Some(self.remap_wire_indices(
|
||||
self.wires.query_xy_unordered(aabb),
|
||||
slots,
|
||||
false,
|
||||
)),
|
||||
segments: Some(self.remap_refs(
|
||||
self.segments.query_xy_unordered(aabb),
|
||||
slots,
|
||||
|value| value.wire,
|
||||
|value, wire| value.wire = wire,
|
||||
)),
|
||||
snap_points: None,
|
||||
key_vertices: None,
|
||||
key_segments: None,
|
||||
fill_triangles: Some(self.remap_refs(
|
||||
self.fill_triangles.query_xy_unordered(aabb),
|
||||
slots,
|
||||
|value| value.wire,
|
||||
|value, wire| value.wire = wire,
|
||||
)),
|
||||
pick_triangles: Some(self.remap_refs(
|
||||
self.pick_triangles.query_xy_unordered(aabb),
|
||||
slots,
|
||||
|value| value.wire,
|
||||
|value, wire| value.wire = wire,
|
||||
)),
|
||||
glyphs: Some(self.remap_refs(
|
||||
self.glyphs.query_xy_unordered(aabb),
|
||||
slots,
|
||||
|value| value.wire,
|
||||
|value, wire| value.wire = wire,
|
||||
)),
|
||||
query_aabb: Some(aabb),
|
||||
screen_rect: None,
|
||||
screen_view: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn query_remapped_xy(
|
||||
&self,
|
||||
wires: Arc<Vec<WireModel>>,
|
||||
@@ -1269,6 +1376,53 @@ impl InteractionIndex {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn query_xy_area(
|
||||
&self,
|
||||
wires: Arc<Vec<WireModel>>,
|
||||
aabb: [f64; 4],
|
||||
) -> InteractionCandidates {
|
||||
let ((wire_indices, segments), ((fill_triangles, pick_triangles), glyphs)) =
|
||||
crate::par::join(
|
||||
|| {
|
||||
crate::par::join(
|
||||
|| {
|
||||
let mut indices = self.wires.query_xy_unordered(aabb);
|
||||
indices.extend_from_slice(&self.unbounded_wires);
|
||||
indices.sort_unstable();
|
||||
indices.dedup();
|
||||
indices
|
||||
},
|
||||
|| self.segments.query_xy_unordered(aabb),
|
||||
)
|
||||
},
|
||||
|| {
|
||||
crate::par::join(
|
||||
|| {
|
||||
crate::par::join(
|
||||
|| self.fill_triangles.query_xy_unordered(aabb),
|
||||
|| self.pick_triangles.query_xy_unordered(aabb),
|
||||
)
|
||||
},
|
||||
|| self.glyphs.query_xy_unordered(aabb),
|
||||
)
|
||||
},
|
||||
);
|
||||
InteractionCandidates {
|
||||
wires,
|
||||
wire_indices: Some(wire_indices),
|
||||
segments: Some(segments),
|
||||
snap_points: None,
|
||||
key_vertices: None,
|
||||
key_segments: None,
|
||||
fill_triangles: Some(fill_triangles),
|
||||
pick_triangles: Some(pick_triangles),
|
||||
glyphs: Some(glyphs),
|
||||
query_aabb: Some(aabb),
|
||||
screen_rect: None,
|
||||
screen_view: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn query_xy(&self, wires: Arc<Vec<WireModel>>, aabb: [f64; 4]) -> InteractionCandidates {
|
||||
let mut wire_indices = self.wires.query_xy(aabb);
|
||||
wire_indices.extend_from_slice(&self.unbounded_wires);
|
||||
@@ -1505,6 +1659,29 @@ impl InteractionCandidates {
|
||||
.map(|(rect, (view, eye, bounds))| (rect, view, eye, bounds))
|
||||
}
|
||||
|
||||
pub(crate) fn extend_indexed_area(&mut self, other: Self) {
|
||||
debug_assert!(Arc::ptr_eq(&self.wires, &other.wires));
|
||||
|
||||
fn append<T>(target: &mut Option<Vec<T>>, incoming: Option<Vec<T>>) {
|
||||
let (Some(target), Some(incoming)) = (target.as_mut(), incoming) else {
|
||||
return;
|
||||
};
|
||||
target.extend(incoming);
|
||||
}
|
||||
|
||||
if let (Some(target), Some(incoming)) =
|
||||
(self.wire_indices.as_mut(), other.wire_indices)
|
||||
{
|
||||
target.extend(incoming);
|
||||
target.sort_unstable();
|
||||
target.dedup();
|
||||
}
|
||||
append(&mut self.segments, other.segments);
|
||||
append(&mut self.fill_triangles, other.fill_triangles);
|
||||
append(&mut self.pick_triangles, other.pick_triangles);
|
||||
append(&mut self.glyphs, other.glyphs);
|
||||
}
|
||||
|
||||
pub(crate) fn extend_indexed(&mut self, other: Self) {
|
||||
debug_assert!(Arc::ptr_eq(&self.wires, &other.wires));
|
||||
|
||||
@@ -1830,3 +2007,138 @@ fn aabb3_projects_into(
|
||||
fn aabb_overlaps(a: [f64; 4], b: [f64; 4]) -> bool {
|
||||
a[2] >= b[0] && a[0] <= b[2] && a[3] >= b[1] && a[1] <= b[3]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod area_query_tests {
|
||||
use super::*;
|
||||
use iced::{Point, Rectangle};
|
||||
|
||||
fn wire(name: &str, pts: Vec<[f32; 3]>, aabb: [f32; 4]) -> WireModel {
|
||||
let mut w = WireModel::solid(name.to_string(), pts, [1.0; 4], false);
|
||||
w.aabb = aabb;
|
||||
w
|
||||
}
|
||||
|
||||
fn sample() -> Vec<WireModel> {
|
||||
vec![
|
||||
wire("5", vec![[-0.5, -0.5, 0.0], [0.5, 0.5, 0.0]], [-0.5, -0.5, 0.5, 0.5]),
|
||||
wire("9", vec![[0.1, -0.9, 0.0], [0.9, -0.1, 0.0]], [0.1, -0.9, 0.9, -0.1]),
|
||||
wire("13", vec![[-0.9, 0.2, 0.0], [-0.2, 0.9, 0.0]], [-0.9, 0.2, -0.2, 0.9]),
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn area_query_matches_full_query_where_it_is_read() {
|
||||
let wires = sample();
|
||||
let index = InteractionIndex::build(&wires);
|
||||
let arc = Arc::new(wires);
|
||||
let aabb = [-2.0, -2.0, 2.0, 2.0];
|
||||
|
||||
let full = index.query_xy(Arc::clone(&arc), aabb);
|
||||
let area = index.query_xy_area(Arc::clone(&arc), aabb);
|
||||
|
||||
fn normalise<T: Copy + Ord>(v: &Option<Vec<T>>) -> Option<Vec<T>> {
|
||||
v.as_ref().map(|items| {
|
||||
let mut items = items.clone();
|
||||
items.sort_unstable();
|
||||
items.dedup();
|
||||
items
|
||||
})
|
||||
}
|
||||
assert_eq!(area.wire_indices, full.wire_indices);
|
||||
assert_eq!(normalise(&area.segments), normalise(&full.segments));
|
||||
assert_eq!(
|
||||
normalise(&area.fill_triangles),
|
||||
normalise(&full.fill_triangles),
|
||||
);
|
||||
assert_eq!(
|
||||
normalise(&area.pick_triangles),
|
||||
normalise(&full.pick_triangles),
|
||||
);
|
||||
assert_eq!(normalise(&area.glyphs), normalise(&full.glyphs));
|
||||
assert_eq!(area.query_aabb, full.query_aabb);
|
||||
|
||||
// The sample carries no hatches or text, so the equality above is
|
||||
// thin for those three. What matters is that they stay indexed at all:
|
||||
// dropping one to `None` is what would silently unselect a hatch.
|
||||
assert!(area.fill_triangles.is_some());
|
||||
assert!(area.pick_triangles.is_some());
|
||||
assert!(area.glyphs.is_some());
|
||||
|
||||
assert!(area.snap_points.is_none());
|
||||
assert!(area.key_vertices.is_none());
|
||||
assert!(area.key_segments.is_none());
|
||||
assert!(full.snap_points.is_some(), "the full query still snaps");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_area_merge_dedups_wire_indices_and_nothing_else() {
|
||||
let arc = Arc::new(sample());
|
||||
let base = |indices: Vec<u32>, segments: Vec<SegmentRef>| InteractionCandidates {
|
||||
wires: Arc::clone(&arc),
|
||||
wire_indices: Some(indices),
|
||||
segments: Some(segments),
|
||||
snap_points: None,
|
||||
key_vertices: None,
|
||||
key_segments: None,
|
||||
fill_triangles: Some(Vec::new()),
|
||||
pick_triangles: Some(Vec::new()),
|
||||
glyphs: Some(Vec::new()),
|
||||
query_aabb: Some([-2.0, -2.0, 2.0, 2.0]),
|
||||
screen_rect: None,
|
||||
screen_view: None,
|
||||
};
|
||||
let seg = |wire: u32| SegmentRef { wire, start: 0 };
|
||||
|
||||
let mut merged = base(vec![2, 0], vec![seg(2), seg(0)]);
|
||||
merged.extend_indexed_area(base(vec![1, 0], vec![seg(1), seg(0)]));
|
||||
|
||||
assert_eq!(merged.wire_indices, Some(vec![0, 1, 2]));
|
||||
assert_eq!(
|
||||
merged.segments,
|
||||
Some(vec![seg(2), seg(0), seg(1), seg(0)]),
|
||||
"the other categories are appended as they come",
|
||||
);
|
||||
}
|
||||
|
||||
// World (x,y) → screen ((x+1)*100, (1-y)*100) over a 200×200 viewport, the
|
||||
// same identity ortho view the hit-test tests use.
|
||||
#[test]
|
||||
fn box_selection_picks_the_same_handles_from_either_query() {
|
||||
let wires = sample();
|
||||
let index = InteractionIndex::build(&wires);
|
||||
let arc = Arc::new(wires);
|
||||
let aabb = [-2.0, -2.0, 2.0, 2.0];
|
||||
let bounds = Rectangle {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
width: 200.0,
|
||||
height: 200.0,
|
||||
};
|
||||
let eye = glam::DVec3::ZERO;
|
||||
|
||||
for crossing in [false, true] {
|
||||
let full = index.query_xy(Arc::clone(&arc), aabb);
|
||||
let area = index.query_xy_area(Arc::clone(&arc), aabb);
|
||||
let hit = |c: &InteractionCandidates| {
|
||||
let mut names = crate::scene::pick::hit_test::box_hit(
|
||||
Point::new(0.0, 0.0),
|
||||
Point::new(200.0, 200.0),
|
||||
crossing,
|
||||
c,
|
||||
glam::Mat4::IDENTITY,
|
||||
eye,
|
||||
bounds,
|
||||
)
|
||||
.into_iter()
|
||||
.map(str::to_string)
|
||||
.collect::<Vec<_>>();
|
||||
names.sort();
|
||||
names
|
||||
};
|
||||
let from_full = hit(&full);
|
||||
assert!(!from_full.is_empty(), "the box covers the whole sample");
|
||||
assert_eq!(hit(&area), from_full, "crossing={crossing}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -1042,15 +1042,6 @@ pub fn document_block_uses(document: &CadDocument) -> Vec<BlockUse> {
|
||||
uses
|
||||
}
|
||||
|
||||
/// A MINSERT's row/column counts are parsed `u16`s whose unchecked product
|
||||
/// (`Insert::instance_count`) would otherwise drive this function's
|
||||
/// allocation and a full per-instance render-graph walk after it — a corrupt
|
||||
/// or adversarial 65535x65535 pair requests over four billion instances (tens
|
||||
/// of GiB) long before any GPU-side chunking limit could help. The primary
|
||||
/// defense is the load-time filter in `io::is_entity_corrupt` (kept in sync
|
||||
/// with this constant), which drops such an insert before it ever reaches a
|
||||
/// `Scene`; this is the failsafe for an insert built or edited some other way
|
||||
/// (API/scripting) that skips that path.
|
||||
const MAX_MINSERT_INSTANCES: usize = 20_000;
|
||||
|
||||
pub fn array_offsets(insert: &Insert) -> Vec<[f64; 3]> {
|
||||
@@ -1093,16 +1084,6 @@ pub fn insert_instance_transform(
|
||||
if offset == [0.0; 3] {
|
||||
transform
|
||||
} else {
|
||||
// Row/column spacing is defined in the insert's rotated/OCS-oriented
|
||||
// space but is NOT subject to the block's x/y/z scale — this matches
|
||||
// the pinned CAD model's own `Insert::array_points`, which rotates
|
||||
// the offset but never scales it. Composing the raw offset ahead of
|
||||
// the full (scaled) transform, as this used to do, let the block
|
||||
// scale multiply the spacing too (e.g. an x-scale-2 insert with
|
||||
// column spacing 10 would render 20 units between columns). Instead,
|
||||
// orient the offset by rotation + OCS only, then translate the
|
||||
// already-composed (scaled) instance transform by that world-space
|
||||
// vector, applied after — never scaled.
|
||||
let orientation = Transform::from_matrix(Matrix4::rotation_z(insert.rotation))
|
||||
.then(&Transform::from_matrix(Matrix4::from_matrix3(Matrix3::arbitrary_axis(insert.normal))));
|
||||
let world_offset = orientation.apply_rotation(Vector3::new(offset[0], offset[1], offset[2]));
|
||||
@@ -1201,10 +1182,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn array_offsets_clamps_pathological_minsert_counts() {
|
||||
// A corrupt or adversarial row/column pair (parsed as u16, so this is
|
||||
// reachable from a malformed file) must not drive a multi-billion-
|
||||
// element allocation; the primary defense is the load-time filter in
|
||||
// `io::is_entity_corrupt`, this is the render-graph-side failsafe.
|
||||
let mut insert = Insert::new("BLOCK", Vector3::ZERO);
|
||||
insert.row_count = u16::MAX;
|
||||
insert.column_count = u16::MAX;
|
||||
@@ -1230,10 +1207,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn insert_instance_transform_does_not_scale_the_row_column_spacing() {
|
||||
// github discussion-derived report: an INSERT with x-scale 2 and
|
||||
// column spacing 10 was rendering 20 drawing units between columns
|
||||
// instead of 10, because the offset translation was composed ahead
|
||||
// of (and thus multiplied by) the block scale.
|
||||
let document = CadDocument::new();
|
||||
let mut insert = Insert::new("BLOCK", Vector3::new(100.0, 0.0, 0.0));
|
||||
insert.set_x_scale(2.0);
|
||||
|
||||
+249
-16
@@ -4,32 +4,96 @@ impl Scene {
|
||||
// ── Selection ─────────────────────────────────────────────────────────
|
||||
/// Treat a classic LEADER and its attached annotation as one logical object.
|
||||
/// Clicking/copying/deleting either side expands to the complete pair.
|
||||
pub(crate) fn handles_expanded_for_leader_annotations(
|
||||
/// Every LEADER that points at `annotation`, resolved once per
|
||||
/// `geometry_epoch` rather than by walking the document per handle.
|
||||
fn leaders_by_annotation(
|
||||
&self,
|
||||
handles: &[Handle],
|
||||
) -> Vec<Handle> {
|
||||
let mut expanded = handles.to_vec();
|
||||
) -> std::cell::Ref<'_, (u64, HashMap<Handle, Vec<Handle>>)> {
|
||||
{
|
||||
let cache = self.leaders_by_annotation_cache.borrow();
|
||||
if cache.as_ref().is_some_and(|(epoch, _)| *epoch == self.geometry_epoch) {
|
||||
drop(cache);
|
||||
return std::cell::Ref::map(
|
||||
self.leaders_by_annotation_cache.borrow(),
|
||||
|c| c.as_ref().unwrap(),
|
||||
);
|
||||
}
|
||||
}
|
||||
let mut by_annotation: HashMap<Handle, Vec<Handle>> = HashMap::default();
|
||||
for entity in self.document.entities() {
|
||||
if let EntityType::Leader(leader) = entity {
|
||||
if !leader.annotation_handle.is_null() {
|
||||
by_annotation
|
||||
.entry(leader.annotation_handle)
|
||||
.or_default()
|
||||
.push(entity.common().handle);
|
||||
}
|
||||
}
|
||||
}
|
||||
*self.leaders_by_annotation_cache.borrow_mut() =
|
||||
Some((self.geometry_epoch, by_annotation));
|
||||
std::cell::Ref::map(self.leaders_by_annotation_cache.borrow(), |c| {
|
||||
c.as_ref().unwrap()
|
||||
})
|
||||
}
|
||||
|
||||
fn expanded_with_leaders(&self, handles: &[Handle]) -> Vec<Handle> {
|
||||
let leaders = self.leaders_by_annotation();
|
||||
let mut expanded = Vec::with_capacity(handles.len());
|
||||
for &handle in handles {
|
||||
// LEADER -> annotation.
|
||||
let start = expanded.len();
|
||||
expanded.push(handle);
|
||||
if let Some(EntityType::Leader(leader)) = self.document.get_entity(handle) {
|
||||
if !leader.annotation_handle.is_null() {
|
||||
expanded.push(leader.annotation_handle);
|
||||
}
|
||||
}
|
||||
|
||||
// Annotation -> LEADER.
|
||||
expanded.extend(self.document.entities().filter_map(|entity| match entity {
|
||||
EntityType::Leader(leader)
|
||||
if !leader.annotation_handle.is_null()
|
||||
&& leader.annotation_handle == handle =>
|
||||
{
|
||||
Some(entity.common().handle)
|
||||
}
|
||||
_ => None,
|
||||
}));
|
||||
if let Some(pointing) = leaders.1.get(&handle) {
|
||||
expanded.extend(pointing.iter().copied());
|
||||
}
|
||||
expanded[start..].sort_unstable_by_key(Handle::value);
|
||||
}
|
||||
expanded
|
||||
}
|
||||
|
||||
pub fn select_entities(&mut self, handles: &[Handle]) {
|
||||
if handles.is_empty() {
|
||||
return;
|
||||
}
|
||||
let expanded = self.expanded_with_leaders(handles);
|
||||
let mut changed = false;
|
||||
for handle in expanded {
|
||||
if self.selected.insert(handle) {
|
||||
self.selected_order.push(handle);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
self.bump_selection_set();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn deselect_entities(&mut self, handles: &[Handle]) {
|
||||
if handles.is_empty() {
|
||||
return;
|
||||
}
|
||||
let doomed: HashSet<Handle> =
|
||||
self.expanded_with_leaders(handles).into_iter().collect();
|
||||
let mut changed = false;
|
||||
for handle in &doomed {
|
||||
changed |= self.selected.remove(handle);
|
||||
}
|
||||
if changed {
|
||||
self.selected_order.retain(|handle| !doomed.contains(handle));
|
||||
self.bump_selection_set();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn handles_expanded_for_leader_annotations(
|
||||
&self,
|
||||
handles: &[Handle],
|
||||
) -> Vec<Handle> {
|
||||
let mut expanded = self.expanded_with_leaders(handles);
|
||||
expanded.sort_unstable_by_key(|handle| handle.value());
|
||||
expanded.dedup();
|
||||
expanded
|
||||
@@ -874,12 +938,57 @@ impl Scene {
|
||||
if respect_layer_locks && self.is_layer_locked(h) {
|
||||
continue;
|
||||
}
|
||||
let settings_handle = self.document.get_entity(h).and_then(|entity| match entity {
|
||||
EntityType::Extended(acadrust::entities::ExtendedEntity {
|
||||
data: acadrust::entities::ExtendedEntityData::SectionObject(data),
|
||||
..
|
||||
}) if !data.settings_handle.is_null() => Some(data.settings_handle),
|
||||
_ => None,
|
||||
});
|
||||
let section_managers: Vec<Handle> = self
|
||||
.document
|
||||
.objects
|
||||
.iter()
|
||||
.filter_map(|(handle, object)| match object {
|
||||
ObjectType::ClassObject(object) => match &object.data {
|
||||
acadrust::objects::ClassObjectData::SectionManager(manager)
|
||||
if manager.sections.contains(&h) =>
|
||||
{
|
||||
Some(*handle)
|
||||
}
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
// Delta-undo: capture the removed entity so an undo can re-insert it.
|
||||
if self.is_recording_undo() {
|
||||
let before = self.document.get_entity_arc(h);
|
||||
self.record_undo_before(h, before);
|
||||
if let Some(settings_handle) = settings_handle {
|
||||
let before = self.document.objects.get(&settings_handle).cloned();
|
||||
self.record_undo_object_before(settings_handle, before);
|
||||
}
|
||||
for &manager_handle in §ion_managers {
|
||||
let before = self.document.objects.get(&manager_handle).cloned();
|
||||
self.record_undo_object_before(manager_handle, before);
|
||||
}
|
||||
}
|
||||
self.delete_solid_history(h);
|
||||
if let Some(settings_handle) = settings_handle {
|
||||
self.document.objects.remove(&settings_handle);
|
||||
}
|
||||
for manager_handle in section_managers {
|
||||
if let Some(ObjectType::ClassObject(object)) =
|
||||
self.document.objects.get_mut(&manager_handle)
|
||||
{
|
||||
if let acadrust::objects::ClassObjectData::SectionManager(manager) =
|
||||
&mut object.data
|
||||
{
|
||||
manager.sections.retain(|section| *section != h);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.remember_removed_cache_categories(h);
|
||||
self.document.remove_entity_arc(h);
|
||||
selection_changed |= self.selected.remove(&h);
|
||||
@@ -1003,6 +1112,130 @@ impl Scene {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn bulk_selection_matches_selecting_one_at_a_time() {
|
||||
use acadrust::entities::Line;
|
||||
use acadrust::types::Vector3;
|
||||
|
||||
let build = || {
|
||||
let mut scene = Scene::new();
|
||||
let handles: Vec<_> = (0..40)
|
||||
.map(|k| {
|
||||
let x = k as f64;
|
||||
scene.add_entity(EntityType::Line(Line::from_points(
|
||||
Vector3::new(x, 0.0, 0.0),
|
||||
Vector3::new(x + 1.0, 0.0, 0.0),
|
||||
)))
|
||||
})
|
||||
.collect();
|
||||
(scene, handles)
|
||||
};
|
||||
|
||||
// Picked in an order that is neither creation nor handle order.
|
||||
let picked = |handles: &[Handle]| -> Vec<Handle> {
|
||||
let mut order: Vec<_> = handles.iter().copied().collect();
|
||||
order.reverse();
|
||||
order.retain(|h| h.value() % 3 != 0);
|
||||
order
|
||||
};
|
||||
|
||||
let (mut one_at_a_time, handles) = build();
|
||||
let order = picked(&handles);
|
||||
for &handle in &order {
|
||||
one_at_a_time.select_entity(handle, false);
|
||||
}
|
||||
|
||||
let (mut in_bulk, handles) = build();
|
||||
in_bulk.select_entities(&picked(&handles));
|
||||
|
||||
assert_eq!(
|
||||
in_bulk.selected_handles_in_order(),
|
||||
one_at_a_time.selected_handles_in_order(),
|
||||
"the bulk path must select the same entities in the same order",
|
||||
);
|
||||
|
||||
// And removing a subset must agree too.
|
||||
let drop: Vec<_> = order.iter().copied().take(7).collect();
|
||||
for &handle in &drop {
|
||||
one_at_a_time.deselect_entity(handle);
|
||||
}
|
||||
in_bulk.deselect_entities(&drop);
|
||||
assert_eq!(
|
||||
in_bulk.selected_handles_in_order(),
|
||||
one_at_a_time.selected_handles_in_order(),
|
||||
"the bulk removal must leave the same selection in the same order",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_leader_index_matches_a_document_walk() {
|
||||
use acadrust::entities::Leader;
|
||||
use acadrust::types::Vector3;
|
||||
|
||||
let mut scene = Scene::new();
|
||||
let line = |x: f64| {
|
||||
EntityType::Line(acadrust::entities::Line::from_points(
|
||||
Vector3::new(x, 0.0, 0.0),
|
||||
Vector3::new(x + 1.0, 0.0, 0.0),
|
||||
))
|
||||
};
|
||||
let annotation = scene.add_entity(line(0.0));
|
||||
let unrelated = scene.add_entity(line(5.0));
|
||||
let mut leader_on = |target: Handle| {
|
||||
let mut leader = Leader::default();
|
||||
leader.annotation_handle = target;
|
||||
scene.add_entity(EntityType::Leader(leader))
|
||||
};
|
||||
// Two leaders share one annotation; a third points nowhere.
|
||||
let first = leader_on(annotation);
|
||||
let second = leader_on(annotation);
|
||||
let dangling = leader_on(Handle::NULL);
|
||||
|
||||
// The walk this replaced, written out so the index is compared against
|
||||
// behaviour rather than against itself.
|
||||
let by_walk = |handle: Handle| -> Vec<Handle> {
|
||||
let mut out = vec![handle];
|
||||
if let Some(EntityType::Leader(leader)) = scene.document.get_entity(handle) {
|
||||
if !leader.annotation_handle.is_null() {
|
||||
out.push(leader.annotation_handle);
|
||||
}
|
||||
}
|
||||
out.extend(scene.document.entities().filter_map(|entity| match entity {
|
||||
EntityType::Leader(leader)
|
||||
if !leader.annotation_handle.is_null()
|
||||
&& leader.annotation_handle == handle =>
|
||||
{
|
||||
Some(entity.common().handle)
|
||||
}
|
||||
_ => None,
|
||||
}));
|
||||
out.sort_unstable_by_key(Handle::value);
|
||||
out.dedup();
|
||||
out
|
||||
};
|
||||
|
||||
for handle in [annotation, unrelated, first, second, dangling] {
|
||||
assert_eq!(
|
||||
scene.handles_expanded_for_leader_annotations(&[handle]),
|
||||
by_walk(handle),
|
||||
"the index must answer exactly what the walk answered",
|
||||
);
|
||||
}
|
||||
let expanded = scene.handles_expanded_for_leader_annotations(&[annotation]);
|
||||
assert!(
|
||||
expanded.contains(&first) && expanded.contains(&second),
|
||||
"both leaders on one annotation must come back, not just one",
|
||||
);
|
||||
for handle in [first, annotation, second] {
|
||||
scene.deselect_all();
|
||||
scene.select_entity(handle, false);
|
||||
let expected = scene.selected_handles_in_order();
|
||||
scene.deselect_all();
|
||||
scene.select_entities(&[handle]);
|
||||
assert_eq!(scene.selected_handles_in_order(), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adding_a_type_already_present_reuses_the_list() {
|
||||
use acadrust::entities::{Circle, EntityType, Line};
|
||||
|
||||
+12
-15
@@ -246,23 +246,16 @@ impl Camera {
|
||||
/// A symmetric range gives the bias half the depth buffer of headroom on
|
||||
/// each side; ortho permits a negative near.
|
||||
fn ortho_depth_range(&self) -> (f32, f32) {
|
||||
// Prefer a drawing-sized, zoom-independent half-range so depth-buffer
|
||||
// precision stays constant as the user zooms. A distance-scaled range
|
||||
// (the `else`) balloons the near/far span when zoomed out — at large
|
||||
// `distance` the f32 depth buffer can no longer separate coincident
|
||||
// solids / meshes / wires, so they flip draw order (issue: meshes drew
|
||||
// in front of solids only when zoomed out).
|
||||
// Generous headroom based on the current screen size so that rotating any
|
||||
// geometry visible on screen in 3D never penetrates the near/far planes.
|
||||
let view_extent = (self.ortho_size() * 3.0).max(10.0);
|
||||
|
||||
let r = if let Some((min, max)) = self.model_bounds {
|
||||
// Depth extent along the CURRENT eye direction, recomputed each
|
||||
// frame so orbiting a 3-D drawing never clips it (#473): as the
|
||||
// view tilts off top, the box's width rotates onto the eye axis and
|
||||
// the span grows to match. Same tight, zoom-independent precision as
|
||||
// a fitted scalar, but always oriented to the live view.
|
||||
self.depth_extent_in_view(min, max)
|
||||
self.depth_extent_in_view(min, max).max(view_extent)
|
||||
} else if self.depth_half_range > 0.0 {
|
||||
self.depth_half_range
|
||||
self.depth_half_range.max(view_extent)
|
||||
} else {
|
||||
(self.distance * 1000.0).max(1.0)
|
||||
(self.distance * 1000.0).max(view_extent).max(10.0)
|
||||
};
|
||||
(self.distance - r, self.distance + r)
|
||||
}
|
||||
@@ -590,7 +583,11 @@ impl Camera {
|
||||
.bounds_in_view(min, max)
|
||||
.iter()
|
||||
.fold(0.0_f32, |m, c| m.max(c.z.abs()));
|
||||
(depth_r * 1.05).max(1.0)
|
||||
let diag = (max - min).length() as f32;
|
||||
// Generous headroom (1.5x depth extent + 25% of model diagonal + minimum clearance)
|
||||
// so wide strokes, lineweights, camera orbit dynamics, and draw-order depth bias
|
||||
// never penetrate the near clipping plane in orthographic 3D view.
|
||||
(depth_r * 1.5 + diag * 0.25).max(10.0)
|
||||
}
|
||||
|
||||
/// Fit the camera to `min..max` — pose and depth both.
|
||||
|
||||
Reference in new issue
Block a user