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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
Felix RiestraandClaude Sonnet 5 committed 2026-09-09 20:44:38 +02:00
commit d675244fb8
89 files changed
+5700 -1101

No files matched your search

+46 -17
View File
@@ -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
View File
@@ -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).
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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, &section.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(&current, 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(&current, 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(&current, 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,
&section.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;
&sectioned
} 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, &section(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, &section(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, &section(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, &section(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");
}
}
+4 -40
View File
@@ -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
View File
@@ -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)");
}
}
+331 -19
View File
@@ -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}");
}
}
}
+131 -9
View File
@@ -11,7 +11,7 @@ use iced::wgpu;
#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
pub struct CircleInstance {
pub center_high: [f32; 3],
pub _pad0: f32,
pub start_width: f32,
pub center_low: [f32; 3],
pub radius: f32,
pub axis_x: [f32; 3],
@@ -19,7 +19,7 @@ pub struct CircleInstance {
pub axis_y: [f32; 3],
pub end_angle: f32,
pub color: [f32; 4],
/// `[half_width, pattern_length, draw_depth, padding]`
/// `[half_width, pattern_length, draw_depth, end_width]`
pub params: [f32; 4],
pub pat0: [f32; 4],
pub pat1: [f32; 4],
@@ -145,7 +145,7 @@ pub fn create_pipelines(
},
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth24PlusStencil8,
depth_write_enabled: Some(true),
depth_write_enabled: Some(false),
depth_compare: Some(wgpu::CompareFunction::LessEqual),
stencil: content_stencil.clone(),
bias: wgpu::DepthBiasState::default(),
@@ -204,6 +204,16 @@ pub fn extract_circle_instance_from_geom(
geom: &crate::scene::model::wire_model::TangentGeom,
wire: &crate::scene::WireModel,
draw_depth: f32,
) -> Option<CircleInstance> {
extract_circle_instance_from_geom_indexed(geom, wire, draw_depth, 0)
}
/// Helper to extract an analytical circle or circular arc instance at a specific segment index.
pub fn extract_circle_instance_from_geom_indexed(
geom: &crate::scene::model::wire_model::TangentGeom,
wire: &crate::scene::WireModel,
draw_depth: f32,
geom_index: usize,
) -> Option<CircleInstance> {
let (center, axis_x, axis_y, radius, start_angle, end_angle) = match *geom {
crate::scene::model::wire_model::TangentGeom::Circle { center, radius } => (
@@ -246,9 +256,25 @@ pub fn extract_circle_instance_from_geom(
let pat0 = [wire.pattern[0], wire.pattern[1], wire.pattern[2], wire.pattern[3]];
let pat1 = [wire.pattern[4], wire.pattern[5], wire.pattern[6], wire.pattern[7]];
let (start_width, end_width) = if !wire.taper_widths.is_empty() {
if wire.taper_widths.len() >= 2 && wire.tangent_geoms.len() == 1 {
(wire.taper_widths[0], wire.taper_widths[1])
} else if wire.taper_widths.len() > geom_index {
let sw = wire.taper_widths[geom_index];
let ew = wire.taper_widths.get(geom_index + 1).copied().unwrap_or(sw);
(sw, ew)
} else {
(wire.world_width, wire.world_width)
}
} else if wire.world_width > 0.0 {
(wire.world_width, wire.world_width)
} else {
(0.0, 0.0)
};
Some(CircleInstance {
center_high: ch,
_pad0: 0.0,
start_width,
center_low: cl,
radius: radius as f32,
axis_x: [axis_x[0] as f32, axis_x[1] as f32, axis_x[2] as f32],
@@ -256,7 +282,7 @@ pub fn extract_circle_instance_from_geom(
axis_y: [axis_y[0] as f32, axis_y[1] as f32, axis_y[2] as f32],
end_angle,
color: wire.color,
params: [hw, wire.pattern_length, draw_depth, 0.0],
params: [hw, wire.pattern_length, draw_depth, end_width],
pat0,
pat1,
})
@@ -265,14 +291,14 @@ pub fn extract_circle_instance_from_geom(
/// Helper to extract all analytical circle/arc instances from a `WireModel`.
///
/// Returns `Some(instances)` if the wire is non-empty, contains solely analytical
/// circle/arc tangent geometries, and has no mesh/pick fills, text, or block instance.
/// circle/arc tangent geometries, and has no mesh fills, text, or block instance.
pub fn extract_circle_instances(
wire: &crate::scene::WireModel,
draw_depth: f32,
) -> Option<Vec<CircleInstance>> {
if wire.tangent_geoms.is_empty()
|| !wire.fill_tris.is_empty()
|| !wire.pick_tris.is_empty()
|| wire.fill_is_3d
|| !wire.text_verts.is_empty()
|| wire.render_instance.is_some()
{
@@ -280,8 +306,8 @@ pub fn extract_circle_instances(
}
let mut instances = Vec::with_capacity(wire.tangent_geoms.len());
for geom in &wire.tangent_geoms {
if let Some(inst) = extract_circle_instance_from_geom(geom, wire, draw_depth) {
for (i, geom) in wire.tangent_geoms.iter().enumerate() {
if let Some(inst) = extract_circle_instance_from_geom_indexed(geom, wire, draw_depth, i) {
instances.push(inst);
} else {
return None;
@@ -410,4 +436,100 @@ mod tests {
};
assert!(extract_circle_instance(&wire, 0.0).is_none());
}
#[test]
fn extract_circle_instance_constant_wide_arc() {
let mut wire = crate::scene::WireModel::default();
wire.tangent_geoms.push(crate::scene::model::wire_model::TangentGeom::Arc {
center: [100.0, 50.0, 0.0],
axis_x: [1.0, 0.0, 0.0],
axis_y: [0.0, 1.0, 0.0],
radius: 40.0,
start_angle: 0.0,
end_angle: std::f64::consts::PI,
});
wire.world_width = 12.0;
wire.color = [1.0, 0.5, 0.0, 1.0];
let inst = extract_circle_instance(&wire, 0.0).expect("extract constant wide arc");
assert_eq!(inst.start_width, 12.0);
assert_eq!(inst.params[3], 12.0); // end_width
assert_eq!(inst.radius, 40.0);
}
#[test]
fn extract_circle_instance_tapered_arc() {
let mut wire = crate::scene::WireModel::default();
wire.tangent_geoms.push(crate::scene::model::wire_model::TangentGeom::Arc {
center: [0.0, 0.0, 0.0],
axis_x: [1.0, 0.0, 0.0],
axis_y: [0.0, 1.0, 0.0],
radius: 30.0,
start_angle: 0.2,
end_angle: 1.8,
});
wire.world_width = 20.0;
wire.taper_widths = vec![4.0, 20.0];
wire.color = [0.2, 0.8, 1.0, 1.0];
let inst = extract_circle_instance(&wire, 0.0).expect("extract tapered arc");
assert_eq!(inst.start_width, 4.0);
assert_eq!(inst.params[3], 20.0); // end_width
assert_eq!(inst.radius, 30.0);
}
#[test]
fn extract_circle_instance_accepts_pick_tris() {
let mut wire = crate::scene::WireModel::default();
wire.tangent_geoms.push(crate::scene::model::wire_model::TangentGeom::Circle {
center: [5.0, 5.0, 0.0],
radius: 15.0,
});
wire.world_width = 6.0;
wire.pick_tris = vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]];
wire.pick_tris_low = vec![[0.0; 3]; 3];
let insts = extract_circle_instances(&wire, 0.0).expect("wide curve with pick_tris should extract");
assert_eq!(insts.len(), 1);
assert_eq!(insts[0].start_width, 6.0);
assert_eq!(insts[0].params[3], 6.0);
}
#[test]
fn test_donut_polyline_tessellates_to_analytical_arcs() {
use acadrust::CadDocument;
let mut doc = CadDocument::new();
let donut = crate::modules::draw::draw::donut::make_donut(10.0, 20.0, 0.0, 10.0, 30.0);
let h = doc.add_entity(donut).unwrap();
let entity = doc.get_entity(h).unwrap();
let selected = rustc_hash::FxHashSet::default();
let wires = crate::scene::convert::tess::tessellate_entity(
&doc,
&selected,
None,
[0.0, 0.0, 0.0, 1.0],
1.0,
None,
entity,
None,
None,
None,
false,
);
assert_eq!(wires.len(), 2, "donut should split into 2 analytical arc wires");
for w in &wires {
assert_eq!(w.world_width, 20.0);
assert_eq!(w.tangent_geoms.len(), 1);
assert!(matches!(w.tangent_geoms[0], crate::scene::model::wire_model::TangentGeom::Arc { .. }));
}
let depth_map = rustc_hash::FxHashMap::default();
let partitioned = crate::scene::pipeline::wire_arena::partition_wires(&wires, &depth_map);
assert_eq!(partitioned.circle_instances.len(), 2);
assert_eq!(partitioned.regular.len(), 0);
assert_eq!(partitioned.circle_instances[0].start_width, 20.0);
assert_eq!(partitioned.circle_instances[0].params[3], 20.0);
assert_eq!(partitioned.circle_instances[1].start_width, 20.0);
assert_eq!(partitioned.circle_instances[1].params[3], 20.0);
}
}
+2 -2
View File
@@ -145,7 +145,7 @@ pub fn create_pipelines(
},
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth24PlusStencil8,
depth_write_enabled: Some(true),
depth_write_enabled: Some(false),
depth_compare: Some(wgpu::CompareFunction::LessEqual),
stencil: content_stencil.clone(),
bias: wgpu::DepthBiasState::default(),
@@ -254,7 +254,7 @@ pub fn extract_ellipse_instances(
) -> Option<Vec<EllipseInstance>> {
if wire.tangent_geoms.is_empty()
|| !wire.fill_tris.is_empty()
|| !wire.pick_tris.is_empty()
|| wire.fill_is_3d
|| !wire.text_verts.is_empty()
|| wire.render_instance.is_some()
{
+193
View File
@@ -521,6 +521,199 @@ fn test_selected_circle_arc_ellipse_highlight_overlay() {
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
}
#[test]
#[ignore = "requires a GPU adapter"]
fn test_thick_and_tapered_arc_gpu_rendering() {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let adapter = block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default()))
.expect("GPU adapter");
let (device, queue) = block_on(adapter.request_device(&wgpu::DeviceDescriptor {
required_limits: adapter.limits(),
..Default::default()
}))
.expect("GPU device");
let mut pipeline = Pipeline::new(&device, &queue, wgpu::TextureFormat::Bgra8UnormSrgb);
// 1. Wide circular arc with pick triangles
let mut wide_arc = WireModel::default();
wide_arc.name = "wide_arc".into();
wide_arc.tangent_geoms.push(crate::scene::model::wire_model::TangentGeom::Arc {
center: [50.0, 50.0, 0.0],
axis_x: [1.0, 0.0, 0.0],
axis_y: [0.0, 1.0, 0.0],
radius: 30.0,
start_angle: 0.0,
end_angle: std::f64::consts::PI,
});
wide_arc.world_width = 10.0;
wide_arc.color = [1.0, 0.2, 0.2, 1.0];
wide_arc.pick_tris = vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]];
wide_arc.pick_tris_low = vec![[0.0; 3]; 3];
// 2. Tapered circular arc
let mut tapered_arc = WireModel::default();
tapered_arc.name = "tapered_arc".into();
tapered_arc.tangent_geoms.push(crate::scene::model::wire_model::TangentGeom::Arc {
center: [150.0, 50.0, 0.0],
axis_x: [1.0, 0.0, 0.0],
axis_y: [0.0, 1.0, 0.0],
radius: 40.0,
start_angle: 0.2,
end_angle: 2.8,
});
tapered_arc.world_width = 16.0;
tapered_arc.taper_widths = vec![2.0, 16.0];
tapered_arc.color = [0.2, 0.8, 1.0, 1.0];
let wires = vec![wide_arc, tapered_arc];
let depth_map = rustc_hash::FxHashMap::default();
let circles = pipeline.upload_circles(&device, &queue, &wires, &depth_map);
// Both curves must be routed to GPU analytical circle instances!
assert!(!circles.is_empty(), "analytical circles must be uploaded");
assert_eq!(circles[0].instance_count, 2);
pipeline.gpu_circles = std::sync::Arc::new(circles);
assert_eq!(pipeline.gpu_circles.len(), 1);
assert_eq!(pipeline.gpu_circles[0].instance_count, 2);
pipeline.ensure_depth_texture(&device, iced::Size::new(512, 512));
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("thick_arc_target"),
size: wgpu::Extent3d {
width: 512,
height: 512,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Bgra8UnormSrgb,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
view_formats: &[],
});
let target = texture.create_view(&wgpu::TextureViewDescriptor::default());
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("thick_arc_encoder"),
});
pipeline.render(
&mut encoder,
&target,
iced::Size::new(512, 512),
iced::Rectangle { x: 0, y: 0, width: 512, height: 512 },
[0.0, 0.0, 0.0, 1.0],
false,
false,
false,
);
queue.submit(Some(encoder.finish()));
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
}
#[test]
#[ignore = "requires a GPU adapter"]
fn test_tilted_3d_donut_and_thick_arc_gpu_rendering() {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let adapter = block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default()))
.expect("GPU adapter");
let (device, queue) = block_on(adapter.request_device(&wgpu::DeviceDescriptor {
required_limits: adapter.limits(),
..Default::default()
}))
.expect("GPU device");
let mut pipeline = Pipeline::new(&device, &queue, wgpu::TextureFormat::Bgra8UnormSrgb);
// Setup 3D perspective camera with acute tilt
let mut camera = crate::scene::view::camera::Camera::default();
camera.projection = crate::scene::view::camera::Projection::Perspective;
camera.distance = 200.0;
camera.target = glam::DVec3::new(50.0, 50.0, 0.0);
camera.rotation = glam::Quat::from_axis_angle(glam::Vec3::X, 1.1); // ~63 degree tilt (acute perspective)
let bounds = iced::Rectangle::new(iced::Point::ORIGIN, iced::Size::new(512.0, 512.0));
let uniforms = Uniforms::new(&camera, bounds, false);
pipeline.upload_uniforms(&device, &queue, &uniforms);
// Donut circular arc segments
let mut donut = WireModel::default();
donut.name = "donut".into();
donut.tangent_geoms.push(crate::scene::model::wire_model::TangentGeom::Arc {
center: [50.0, 50.0, 0.0],
axis_x: [1.0, 0.0, 0.0],
axis_y: [0.0, 1.0, 0.0],
radius: 30.0,
start_angle: 0.0,
end_angle: std::f64::consts::PI,
});
donut.tangent_geoms.push(crate::scene::model::wire_model::TangentGeom::Arc {
center: [50.0, 50.0, 0.0],
axis_x: [1.0, 0.0, 0.0],
axis_y: [0.0, 1.0, 0.0],
radius: 30.0,
start_angle: std::f64::consts::PI,
end_angle: std::f64::consts::TAU,
});
donut.world_width = 12.0;
donut.color = [0.2, 0.9, 0.3, 1.0];
let wires = vec![donut];
let depth_map = rustc_hash::FxHashMap::default();
let circles = pipeline.upload_circles(&device, &queue, &wires, &depth_map);
assert_eq!(circles.len(), 1);
assert_eq!(circles[0].instance_count, 2);
pipeline.gpu_circles = std::sync::Arc::new(circles);
// Wide straight polyline segment on the 3D plane
let mut straight_wide = WireModel::default();
straight_wide.name = "wide_straight".into();
straight_wide.points = vec![[0.0, 0.0, 0.0], [50.0, 50.0, 0.0]];
straight_wide.world_width = 10.0;
straight_wide.color = [0.9, 0.2, 0.3, 1.0];
let wide_wires = vec![straight_wide];
let gpu_wires = wire_gpu::WireGpu::from_run(
&device,
&queue,
&wide_wires,
&depth_map,
false,
pipeline.wire_const_bgl.as_ref(),
);
assert!(!gpu_wires.is_empty());
pipeline.gpu_wires = std::sync::Arc::new(gpu_wires);
pipeline.ensure_depth_texture(&device, iced::Size::new(512, 512));
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("tilted_donut_target"),
size: wgpu::Extent3d {
width: 512,
height: 512,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Bgra8UnormSrgb,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
view_formats: &[],
});
let target = texture.create_view(&wgpu::TextureViewDescriptor::default());
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("tilted_donut_encoder"),
});
pipeline.render(
&mut encoder,
&target,
iced::Size::new(512, 512),
iced::Rectangle { x: 0, y: 0, width: 512, height: 512 },
[0.0, 0.0, 0.0, 1.0],
false,
false,
false,
);
queue.submit(Some(encoder.finish()));
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
}
#[test]
#[ignore = "requires a GPU adapter"]
fn test_selected_ellipse_overlay() {
+18 -11
View File
@@ -568,12 +568,15 @@ struct MeshSurfaceParams {
flags: [u32; 4],
}
/// Downscale a decoded RGBA buffer so neither dimension exceeds `limit`,
/// preserving aspect ratio (scaled by whichever dimension overshoots more).
/// `None` only for genuinely malformed input (`rgba`'s length doesn't match
/// `width * height * 4`) — the caller falls back to the 1x1 material color
/// in that case, same as it already does for a missing image entirely.
fn valid_rgba_size(width: u32, height: u32, pixels: &[u8]) -> bool {
width > 0 && height > 0
&& (width as usize).checked_mul(height as usize).and_then(|size| size.checked_mul(4)) == Some(pixels.len())
}
fn downscale_rgba_to_limit(width: u32, height: u32, rgba: &[u8], limit: u32) -> Option<(u32, u32, Vec<u8>)> {
if limit == 0 || !valid_rgba_size(width, height, rgba) {
return None;
}
let buffer = image::RgbaImage::from_raw(width, height, rgba.to_vec())?;
let scale = (limit as f64 / width.max(height) as f64).min(1.0);
let new_width = ((width as f64 * scale).round() as u32).clamp(1, limit);
@@ -590,14 +593,9 @@ fn upload_rgba_texture(
fallback: [u8; 4],
srgb: bool,
) -> wgpu::TextureView {
// A material map's decoded dimensions come straight from the source file
// (`material_model::load_map_image`), uncapped — an oversized image would
// otherwise fail `wgpu` texture-size validation and leave the material
// unusable (audit: raster-image rendering already tiles to this same
// limit in `image_gpu.rs`; a mesh material sampled by UV can't use that
// per-quad tiling trick, so downscale instead of tiling).
let limit = device.limits().max_texture_dimension_2d.max(1);
let resized;
let image = image.filter(|image| valid_rgba_size(image.width, image.height, &image.rgba));
let (width, height, pixels): (u32, u32, &[u8]) = match image {
Some(image) if image.width > limit || image.height > limit => {
match downscale_rgba_to_limit(image.width, image.height, &image.rgba, limit) {
@@ -2494,4 +2492,13 @@ mod texture_limit_tests {
let rgba = vec![0u8; 10];
assert!(downscale_rgba_to_limit(1000, 1000, &rgba, 512).is_none());
}
#[test]
fn malformed_material_images_are_rejected_even_below_the_texture_limit() {
for (width, height, pixels) in [(0, 1, vec![]), (1, 0, vec![]), (1, 1, vec![0; 3]), (1, 1, vec![0; 5])] {
assert!(!super::valid_rgba_size(width, height, &pixels));
assert!(downscale_rgba_to_limit(width, height, &pixels, 16).is_none());
}
assert!(downscale_rgba_to_limit(1, 1, &[0; 4], 0).is_none());
}
}
+205 -78
View File
@@ -2449,11 +2449,17 @@ impl Pipeline {
if instances.is_empty() {
vec![]
} else {
let mut sorted = instances.to_vec();
sorted.sort_by(|a, b| {
a.params[2]
.partial_cmp(&b.params[2])
.unwrap_or(std::cmp::Ordering::Equal)
});
vec![CircleGpu::from_instances(
device,
queue,
"viewer.circles",
instances,
&sorted,
)]
}
}
@@ -2489,11 +2495,17 @@ impl Pipeline {
if instances.is_empty() {
vec![]
} else {
let mut sorted = instances.to_vec();
sorted.sort_by(|a, b| {
a.params[2]
.partial_cmp(&b.params[2])
.unwrap_or(std::cmp::Ordering::Equal)
});
vec![EllipseGpu::from_instances(
device,
queue,
"viewer.ellipses",
instances,
&sorted,
)]
}
}
@@ -2612,6 +2624,72 @@ impl Pipeline {
/// refreshes just this overlay instead of re-tessellating the model. The
/// xray pass applies neither scissor nor mesh-edge skip, so everything
/// merges into one order-preserving run.
/// Split highlighted wires into analytic instances and the wires that need
/// real geometry, in one extraction pass per wire.
#[allow(clippy::type_complexity)]
fn classify_highlight_wires<'a>(
wires: &[&'a WireModel],
color: Option<[f32; 4]>,
depth_map: &rustc_hash::FxHashMap<u64, [f32; 2]>,
) -> (
Vec<CircleInstance>,
Vec<EllipseInstance>,
Vec<&'a WireModel>,
Vec<&'a WireModel>,
) {
use crate::par::prelude::*;
// Pure per-wire work, so it fans out; `collect` on an indexed
// parallel iterator keeps the order, and the order is what decides
// the instance layout.
let per: Vec<(Option<Vec<CircleInstance>>, Option<Vec<EllipseInstance>>, bool)> =
wires
.par_iter()
.map(|&wire| {
if wire.render_instance.is_some() {
return (None, None, true);
}
let depth = wire_gpu::wire_draw_depth(wire, depth_map);
let mut circles = circle_gpu::extract_circle_instances(wire, depth);
let mut ellipses = ellipse_gpu::extract_ellipse_instances(wire, depth);
if let Some(color) = color {
if let Some(instances) = circles.as_mut() {
for instance in instances {
instance.color = color;
}
}
if let Some(instances) = ellipses.as_mut() {
for instance in instances {
instance.color = color;
}
}
}
(circles, ellipses, false)
})
.collect();
let mut circles: Vec<CircleInstance> = Vec::new();
let mut ellipses: Vec<EllipseInstance> = Vec::new();
let mut regular: Vec<&'a WireModel> = Vec::new();
let mut blocks: Vec<&'a WireModel> = Vec::new();
for (&wire, (wire_circles, wire_ellipses, is_block)) in wires.iter().zip(per) {
if is_block {
blocks.push(wire);
continue;
}
let shaped = wire_circles.is_some() || wire_ellipses.is_some();
if let Some(instances) = wire_circles {
circles.extend(instances);
}
if let Some(instances) = wire_ellipses {
ellipses.extend(instances);
}
if !shaped {
regular.push(wire);
}
}
(circles, ellipses, regular, blocks)
}
pub fn upload_selected_wires(
&mut self,
device: &wgpu::Device,
@@ -2637,9 +2715,11 @@ impl Pipeline {
let mut hover_wires: Vec<&WireModel> = Vec::new();
for h in selected {
if let Some(idxs) = self.wire_handle_index.get(&h.value()) {
let mut slots = idxs.clone();
slots.sort_unstable();
for &i in &slots {
debug_assert!(
idxs.windows(2).all(|pair| pair[0] <= pair[1]),
"wire_handle_index slots must stay ascending",
);
for &i in idxs {
if let Some(w) = wires.get(i as usize) {
if !w.display_visible {
continue;
@@ -2651,9 +2731,7 @@ impl Pipeline {
}
for h in hovered.iter().filter(|handle| !selected.contains(handle)) {
if let Some(idxs) = self.wire_handle_index.get(&h.value()) {
let mut slots = idxs.clone();
slots.sort_unstable();
for &i in &slots {
for &i in idxs {
if let Some(w) = wires.get(i as usize) {
if !w.display_visible {
continue;
@@ -2673,26 +2751,23 @@ impl Pipeline {
hover_wires.push(wire);
}
}
let mut selected_circles: Vec<CircleInstance> = Vec::new();
let circle_sel_tint = selected_tint.unwrap_or(WireModel::SELECTED);
for &wire in &selected_wires {
let depth = wire_gpu::wire_draw_depth(wire, depth_map);
if let Some(insts) = circle_gpu::extract_circle_instances(wire, depth) {
for mut inst in insts {
inst.color = circle_sel_tint;
selected_circles.push(inst);
}
}
}
for &wire in &hover_wires {
let depth = wire_gpu::wire_draw_depth(wire, depth_map);
if let Some(insts) = circle_gpu::extract_circle_instances(wire, depth) {
for mut inst in insts {
inst.color = WireModel::HOVER;
selected_circles.push(inst);
}
}
}
let t_gather = perf_started.map(|t| t.elapsed().as_secs_f64() * 1000.0);
let (mut selected_circles, mut selected_ellipses, selected_regular, selected_blocks) =
Self::classify_highlight_wires(
&selected_wires,
Some(selected_tint.unwrap_or(WireModel::SELECTED)),
depth_map,
);
let (hover_circles, hover_ellipses, hover_regular, hover_blocks) =
Self::classify_highlight_wires(
&hover_wires,
Some(WireModel::HOVER),
depth_map,
);
selected_circles.extend(hover_circles);
selected_ellipses.extend(hover_ellipses);
let t_shapes = perf_started.map(|t| t.elapsed().as_secs_f64() * 1000.0);
self.gpu_selected_circles = if selected_circles.is_empty() {
vec![]
} else {
@@ -2703,27 +2778,6 @@ impl Pipeline {
&selected_circles,
)]
};
let mut selected_ellipses: Vec<EllipseInstance> = Vec::new();
let ellipse_sel_tint = selected_tint.unwrap_or(WireModel::SELECTED);
for &wire in &selected_wires {
let depth = wire_gpu::wire_draw_depth(wire, depth_map);
if let Some(insts) = ellipse_gpu::extract_ellipse_instances(wire, depth) {
for mut inst in insts {
inst.color = ellipse_sel_tint;
selected_ellipses.push(inst);
}
}
}
for &wire in &hover_wires {
let depth = wire_gpu::wire_draw_depth(wire, depth_map);
if let Some(insts) = ellipse_gpu::extract_ellipse_instances(wire, depth) {
for mut inst in insts {
inst.color = WireModel::HOVER;
selected_ellipses.push(inst);
}
}
}
self.gpu_selected_ellipses = if selected_ellipses.is_empty() {
vec![]
} else {
@@ -2734,35 +2788,7 @@ impl Pipeline {
&selected_ellipses,
)]
};
let selected_regular: Vec<&WireModel> = selected_wires
.iter()
.copied()
.filter(|wire| {
wire.render_instance.is_none()
&& circle_gpu::extract_circle_instances(wire, 0.0).is_none()
&& ellipse_gpu::extract_ellipse_instances(wire, 0.0).is_none()
})
.collect();
let hover_regular: Vec<&WireModel> = hover_wires
.iter()
.copied()
.filter(|wire| {
wire.render_instance.is_none()
&& circle_gpu::extract_circle_instances(wire, 0.0).is_none()
&& ellipse_gpu::extract_ellipse_instances(wire, 0.0).is_none()
})
.collect();
let selected_blocks: Vec<&WireModel> = selected_wires
.iter()
.copied()
.filter(|wire| wire.render_instance.is_some())
.collect();
let hover_blocks: Vec<&WireModel> = hover_wires
.iter()
.copied()
.filter(|wire| wire.render_instance.is_some())
.collect();
let t_split = perf_started.map(|t| t.elapsed().as_secs_f64() * 1000.0);
let mut gpu = if let Some(tint) = selected_tint {
WireGpu::from_highlight_refs(
device,
@@ -2783,6 +2809,7 @@ impl Pipeline {
depth_map,
self.wire_const_bgl.as_ref(),
));
let t_regular = perf_started.map(|t| t.elapsed().as_secs_f64() * 1000.0);
self.gpu_selected_wires = gpu;
let mut block_gpu = if let Some(tint) = selected_tint {
BlockWireGpu::from_wires(
@@ -2816,6 +2843,18 @@ impl Pipeline {
if let Some(started) = perf_started {
let elapsed_ms = started.elapsed().as_secs_f64() * 1000.0;
if elapsed_ms >= 1.0 {
let gather = t_gather.unwrap_or(0.0);
let classify = t_shapes.unwrap_or(0.0);
let analytic = t_split.unwrap_or(0.0);
let regular = t_regular.unwrap_or(0.0);
crate::perf_record!(
"[perf] wire-highlight-detail gather={gather:.1} classify={:.1} \
analytic={:.1} regular={:.1} blocks={:.1}",
classify - gather,
analytic - classify,
regular - analytic,
elapsed_ms - regular,
);
crate::perf_record!(
"[perf] wire-highlight {:>7.1}ms handles={} wires={}",
elapsed_ms,
@@ -5627,3 +5666,91 @@ impl iced::widget::shader::Pipeline for MultiPipeline {
}
}
}
#[cfg(test)]
mod highlight_classification_tests {
use super::*;
use crate::scene::model::wire_model::TangentGeom;
fn plain(name: &str) -> WireModel {
WireModel::solid(
name.to_string(),
vec![[0.0, 0.0, 0.0], [1.0, 1.0, 0.0]],
[1.0; 4],
false,
)
}
fn circle(name: &str) -> WireModel {
let mut wire = plain(name);
wire.tangent_geoms.push(TangentGeom::PlanarCircle {
center: [0.0, 0.0, 0.0],
axis_x: [1.0, 0.0, 0.0],
axis_y: [0.0, 1.0, 0.0],
radius: 2.0,
});
wire
}
fn ellipse(name: &str) -> WireModel {
let mut wire = plain(name);
wire.tangent_geoms.push(TangentGeom::PlanarEllipse {
center: [0.0, 0.0, 0.0],
major_axis: [2.0, 0.0, 0.0],
normal: [0.0, 0.0, 1.0],
minor_axis_ratio: 0.5,
start_param: 0.0,
end_param: std::f64::consts::TAU,
});
wire
}
#[test]
fn each_wire_lands_in_the_bucket_the_old_predicate_chose() {
let wires = vec![plain("1"), circle("2"), ellipse("3"), plain("4")];
let refs: Vec<&WireModel> = wires.iter().collect();
let depth_map = rustc_hash::FxHashMap::default();
let (circles, ellipses, regular, blocks) =
Pipeline::classify_highlight_wires(&refs, None, &depth_map);
for wire in &refs {
let was_regular = wire.render_instance.is_none()
&& circle_gpu::extract_circle_instances(wire, 0.0).is_none()
&& ellipse_gpu::extract_ellipse_instances(wire, 0.0).is_none();
assert_eq!(
was_regular,
regular.iter().any(|kept| std::ptr::eq(*kept, *wire)),
"wire {} disagrees with the old predicate",
wire.name,
);
}
assert_eq!(regular.len(), 2, "two plain lines");
assert_eq!(circles.len(), 1, "one circle instance");
assert_eq!(ellipses.len(), 1, "one ellipse instance");
assert!(blocks.is_empty(), "no block wires in this sample");
}
// Hover recolours every instance; a selection only recolours when a tint is
// configured. Both go through the same argument, so it has to actually
// reach the instances.
#[test]
fn the_colour_override_reaches_the_instances() {
let wires = vec![circle("2"), ellipse("3")];
let refs: Vec<&WireModel> = wires.iter().collect();
let depth_map = rustc_hash::FxHashMap::default();
let (tinted_circles, tinted_ellipses, _, _) =
Pipeline::classify_highlight_wires(&refs, Some(WireModel::HOVER), &depth_map);
assert_eq!(tinted_circles[0].color, WireModel::HOVER);
assert_eq!(tinted_ellipses[0].color, WireModel::HOVER);
let (plain_circles, _, _, _) =
Pipeline::classify_highlight_wires(&refs, None, &depth_map);
assert_ne!(
plain_circles[0].color, WireModel::HOVER,
"without a tint the extractor's own colour stands",
);
}
}
+45 -2
View File
@@ -146,7 +146,7 @@ pub fn classify_wire(wire: &WireModel) -> WireKind {
}
if !wire.tangent_geoms.is_empty()
&& wire.fill_tris.is_empty()
&& wire.pick_tris.is_empty()
&& !wire.fill_is_3d
&& wire.text_verts.is_empty()
{
if super::circle_gpu::extract_circle_instances(wire, 0.0).is_some() {
@@ -219,7 +219,7 @@ pub fn partition_wires<'a>(
// Resolve draw depth only for wires eligible for analytical extraction.
if !wire.tangent_geoms.is_empty()
&& wire.fill_tris.is_empty()
&& wire.pick_tris.is_empty()
&& !wire.fill_is_3d
&& wire.text_verts.is_empty()
{
let depth = super::wire_gpu::wire_draw_depth(wire, depth_map);
@@ -1709,4 +1709,47 @@ mod tests {
assert_eq!(partitioned.ellipse_instances.len(), 1);
assert_eq!(partitioned.regular.len(), 1);
}
#[test]
fn partition_wires_partitions_thick_and_tapered_arcs() {
use crate::scene::model::wire_model::TangentGeom;
// Wide arc with pick triangles
let mut wide_arc = WireModel::default();
wide_arc.tangent_geoms.push(TangentGeom::Arc {
center: [10.0, 20.0, 0.0],
axis_x: [1.0, 0.0, 0.0],
axis_y: [0.0, 1.0, 0.0],
radius: 25.0,
start_angle: 0.0,
end_angle: std::f64::consts::PI,
});
wide_arc.world_width = 8.0;
wide_arc.pick_tris = vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]];
wide_arc.pick_tris_low = vec![[0.0; 3]; 3];
// Tapered arc
let mut tapered_arc = WireModel::default();
tapered_arc.tangent_geoms.push(TangentGeom::Arc {
center: [50.0, 60.0, 0.0],
axis_x: [1.0, 0.0, 0.0],
axis_y: [0.0, 1.0, 0.0],
radius: 15.0,
start_angle: 0.5,
end_angle: 2.5,
});
tapered_arc.world_width = 10.0;
tapered_arc.taper_widths = vec![2.0, 10.0];
let wires = vec![wide_arc, tapered_arc];
let depth_map = rustc_hash::FxHashMap::default();
let partitioned = partition_wires(&wires, &depth_map);
assert_eq!(partitioned.circle_instances.len(), 2);
assert_eq!(partitioned.regular.len(), 0);
assert_eq!(partitioned.circle_instances[0].start_width, 8.0);
assert_eq!(partitioned.circle_instances[0].params[3], 8.0);
assert_eq!(partitioned.circle_instances[1].start_width, 2.0);
assert_eq!(partitioned.circle_instances[1].params[3], 10.0);
}
}
+57 -2
View File
@@ -532,7 +532,35 @@ fn finite3(p: [f32; 3]) -> bool {
fn marker_metadata(wire: &WireModel) -> ([f32; 4], [f32; 4], [f32; 4]) {
let Some(marker) = wire.point_marker else {
return ([0.0; 4], [0.0; 4], [0.0; 4]);
for tg in &wire.tangent_geoms {
match tg {
crate::scene::model::wire_model::TangentGeom::Arc { axis_x, axis_y, .. }
| crate::scene::model::wire_model::TangentGeom::PlanarCircle { axis_x, axis_y, .. } => {
let ax = glam::DVec3::from_array(*axis_x);
let ay = glam::DVec3::from_array(*axis_y);
let n = ax.cross(ay).normalize_or(glam::DVec3::Z);
return ([0.0; 4], [0.0; 4], [n.x as f32, n.y as f32, n.z as f32, 0.0]);
}
crate::scene::model::wire_model::TangentGeom::PlanarEllipse { normal, .. } => {
let n = glam::DVec3::from_array(*normal).normalize_or(glam::DVec3::Z);
return ([0.0; 4], [0.0; 4], [n.x as f32, n.y as f32, n.z as f32, 0.0]);
}
_ => {}
}
}
if wire.points.len() >= 3 {
let p0 = glam::Vec3::from_array(wire.points[0]);
for i in 1..wire.points.len() - 1 {
let v1 = glam::Vec3::from_array(wire.points[i]) - p0;
let v2 = glam::Vec3::from_array(wire.points[i + 1]) - p0;
let cross = v1.cross(v2);
if cross.length_squared() > 1e-6 {
let n = cross.normalize();
return ([0.0; 4], [0.0; 4], [n.x, n.y, n.z, 0.0]);
}
}
}
return ([0.0; 4], [0.0; 4], [0.0, 0.0, 1.0, 0.0]);
};
let origin = marker.origin;
let (hx, lx) = WireModel::split_ds(origin.x);
@@ -1148,8 +1176,9 @@ impl WireGpu {
let _ = const_bgl;
let max_packed_instances = super::gpu_budget::max_elements::<PackedWireInstance>(device);
use crate::par::prelude::*;
let per: Vec<Vec<PackedWireInstance>> = wires
.iter()
.par_iter()
.map(|wire| {
emit_wire_packed(wire, color, wire_draw_depth(wire, depth_map))
})
@@ -1463,4 +1492,30 @@ mod block_wire_storage_tests {
};
assert_eq!(names(&packed), names(&storage));
}
#[test]
fn wire_shader_validates_with_naga() {
let source = include_str!("../../shaders/wire.wgsl");
let module = naga::front::wgsl::parse_str(source).expect("wire.wgsl parses cleanly");
let mut validator = naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
);
validator
.validate(&module)
.expect("wire.wgsl validates cleanly with naga");
}
#[test]
fn wire_indexed_shader_validates_with_naga() {
let source = include_str!("../../shaders/wire_indexed.wgsl");
let module = naga::front::wgsl::parse_str(source).expect("wire_indexed.wgsl parses cleanly");
let mut validator = naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
);
validator
.validate(&module)
.expect("wire_indexed.wgsl validates cleanly with naga");
}
}
-27
View File
@@ -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
View File
@@ -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 &section_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
View File
@@ -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.