Merge remote-tracking branch 'origin/main' into codex/shell-parity

# Conflicts:
#	Cargo.lock
#	Cargo.toml
#	crates/ocs_doc_api/Cargo.toml
#	src/modules/model/mod.rs
This commit is contained in:
ramox81 committed 2026-09-08 19:49:35 +03:00
commit 7913f8e2da
68 files changed
+5496 -548

No files matched your search

+51
View File
@@ -21,3 +21,54 @@ pub fn solid_to_sat(body: &Body) -> Option<SatDocument> {
let binary = SabWriter::write(&document);
valid(&SabReader::read(&binary).ok()?).then_some(document)
}
#[cfg(test)]
mod tests {
use super::*;
use cadkernel::geom2d::{Arc, Curve};
use cadkernel::space::Plane;
fn circle_section(z: f64, radius: f64) -> (Plane, Vec<Curve>) {
let curves = (0..4)
.map(|part| {
let start = std::f64::consts::FRAC_PI_2 * part as f64;
Curve::Arc(Arc {
centre: [0.0, 0.0],
radius,
start_angle: start,
end_angle: start + std::f64::consts::FRAC_PI_2,
})
})
.collect();
(
Plane::from_axes([0.0, 0.0, z], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]),
curves,
)
}
#[test]
fn a_curved_loft_round_trips_through_text_and_binary_acis() {
let body = cadkernel::brep::loft(&[
circle_section(0.0, 5.0),
circle_section(10.0, 2.0),
])
.unwrap();
assert!(body.validate().is_empty());
let mut document = SatDocument::new();
cadkernel::acis::append(&body, &mut document).unwrap();
let text = document.to_sat_string();
let parsed = SatDocument::parse(&text).unwrap();
let (restored, text_loss) = cadkernel::acis::lift(&parsed);
assert!(text_loss.is_empty(), "{text_loss:?}");
assert_eq!(restored.len(), 1);
assert!(restored[0].validate().is_empty());
let binary = SabWriter::write(&parsed);
let parsed_binary = SabReader::read(&binary).unwrap();
let (restored, binary_loss) = cadkernel::acis::lift(&parsed_binary);
assert!(binary_loss.is_empty(), "{binary_loss:?}");
assert_eq!(restored.len(), 1);
assert!(restored[0].validate().is_empty());
}
}
+29 -3
View File
@@ -219,7 +219,33 @@ fn split_mixed_polyline(
// Emit each arc segment as an analytical wire (CircleGpu target)
for tg in tangent_geoms {
if matches!(tg, TangentGeom::Arc { .. }) {
if let TangentGeom::Arc {
center,
axis_x,
axis_y,
radius,
start_angle,
end_angle,
} = *tg
{
let mut arc_pts = Vec::with_capacity(17);
let n = 16;
let sweep = if end_angle >= start_angle {
end_angle - start_angle
} else {
end_angle - start_angle + std::f64::consts::TAU
};
for s in 0..=n {
let frac = s as f64 / n as f64;
let ang = start_angle + frac * sweep;
let p = [
center[0] + radius * (ang.cos() * axis_x[0] + ang.sin() * axis_y[0]),
center[1] + radius * (ang.cos() * axis_x[1] + ang.sin() * axis_y[1]),
center[2] + radius * (ang.cos() * axis_x[2] + ang.sin() * axis_y[2]),
];
arc_pts.push(p);
}
let (points, points_low) = points_to_ds(arc_pts);
out.push(WireModel {
bg_adapt: None,
point_marker: None,
@@ -238,8 +264,8 @@ fn split_mixed_polyline(
dash_align_end: None,
text_verts: Vec::new(),
name: name.to_string(),
points: Vec::new(),
points_low: Vec::new(),
points,
points_low,
color,
selected,
pattern_length,
+2 -2
View File
@@ -596,8 +596,8 @@ impl Scene {
.filter_map(|&handle| {
let from_history = self
.document
.solid_history_operation(handle)
.and_then(|operation| cadkernel::acis::rebuild_body(operation).ok());
.solid_history_operations(handle)
.and_then(|operations| cadkernel::acis::rebuild_history(&operations).ok());
let body = from_history.or_else(|| match self.document.get_entity(handle) {
Some(EntityType::Solid3D(solid)) => {
crate::scene::convert::solid3d_tess::kernel_body(solid)
+463 -89
View File
@@ -437,6 +437,8 @@ pub struct PreparedOpenGeometry {
pub tess_guard: u64,
/// Loader block definitions; the receiving scene supplies its own epoch.
pub block_defns: Vec<(u64, Arc<cache::block_cache::BlockCache>)>,
/// Loader-computed draw-order map, re-stamped by the receiving scene.
pub(crate) draw_depths: Option<DrawDepthCache>,
}
impl std::fmt::Debug for PreparedOpenGeometry {
@@ -557,7 +559,8 @@ fn inserted_draw_depth_label(order: &[DrawDepthEntry], position: usize) -> Optio
}
}
struct DrawDepthCache {
#[derive(Clone)]
pub(crate) struct DrawDepthCache {
epoch: u64,
depths: Arc<HashMap<u64, [f32; 2]>>,
/// Per-block stable order labels, including deleted tombstones retained for
@@ -1026,6 +1029,7 @@ pub fn prepare_open_geometry(
// Taken, not cloned: the scene is discarded on the next line.
let tess_memo = std::mem::take(&mut *scene.resident_tess_memo.borrow_mut());
let tess_guard = scene.resident_tess_guard.get();
let draw_depths = scene.draw_depth_cache.borrow_mut().take();
let block_defns: Vec<(u64, Arc<cache::block_cache::BlockCache>)> =
std::mem::take(&mut *scene.block_defn_cache.borrow_mut())
.into_iter()
@@ -1040,6 +1044,7 @@ pub fn prepare_open_geometry(
tess_memo,
tess_guard,
block_defns,
draw_depths,
},
)
}
@@ -1432,6 +1437,8 @@ pub(crate) enum NavPerfOp {
Pan,
Zoom,
Rotate,
/// A message that changed geometry before the next rendered frame.
Edit,
}
impl NavPerfOp {
@@ -1440,6 +1447,7 @@ impl NavPerfOp {
Self::Pan => "pan",
Self::Zoom => "zoom",
Self::Rotate => "rotate",
Self::Edit => "edit",
}
}
}
@@ -1447,6 +1455,8 @@ impl NavPerfOp {
#[derive(Clone, Copy, Debug)]
pub(in crate::scene) struct NavPerfSample {
pub(in crate::scene) op: NavPerfOp,
/// Message label that caused the sample.
pub(in crate::scene) cause: &'static str,
pub(in crate::scene) space: &'static str,
pub(in crate::scene) mode: &'static str,
pub(in crate::scene) started: iced::time::Instant,
@@ -1699,6 +1709,9 @@ pub struct Scene {
/// constants. `[depth, half]` retains a fixed child sub-range for block
/// composition. Full sort/layout/block changes rebuild the labels.
draw_depth_cache: RefCell<Option<DrawDepthCache>>,
/// Bumped when a full draw-order rebuild can move existing depths.
/// Cached uploads compare it before reusing baked depth values.
draw_depth_generation: std::cell::Cell<u64>,
/// Shared empty map for 3-D wireframe, where true depth wins instead of
/// the entity submission order used by the optimized 2-D style.
no_draw_depths: Arc<HashMap<u64, [f32; 2]>>,
@@ -1847,11 +1860,16 @@ pub struct Scene {
block_members_cache: RefCell<Option<BlockMembers>>,
/// Insert/Viewport/Block/BlockEnd handles omitted by the spatial index.
unindexable_cache: RefCell<Option<(u64, Vec<Handle>)>>,
/// Distinct entity type names present in a layout, keyed by
/// (geometry_epoch, layout block). The status bar asks for this on every
/// frame to populate the selection-filter menu, but the answer only
/// changes when entities are added or removed.
layout_type_names_cache: RefCell<Option<(u64, Handle, std::sync::Arc<Vec<String>>)>>,
/// `(epoch, layout block, present type names, list exposed to the UI)`.
/// The set lets pure additions update without rebuilding the list.
layout_type_names_cache: RefCell<
Option<(
u64,
Handle,
std::collections::BTreeSet<String>,
std::sync::Arc<Vec<String>>,
)>,
>,
/// Reverse dependencies from layer/style/block definitions to the top-level
/// entities whose resident wire runs actually change. Kept independent from
/// `geometry_epoch`: a layer colour toggle can reuse the index, invalidate
@@ -2046,6 +2064,7 @@ impl Scene {
interaction_handle_index_cache: RefCell::new(None),
sort_cache: RefCell::new(None),
draw_depth_cache: RefCell::new(None),
draw_depth_generation: std::cell::Cell::new(0),
no_draw_depths: Arc::new(HashMap::default()),
hatch_cache: RefCell::new(HashMap::default()),
wipeout_cache: RefCell::new(HashMap::default()),
@@ -2250,6 +2269,11 @@ impl Scene {
*self.resident_tess_memo.borrow_mut() = prepared.tess_memo;
self.resident_tess_guard.set(prepared.tess_guard);
}
// Reuse the loader's draw-order map with this scene's epoch.
if let Some(mut depths) = prepared.draw_depths {
depths.epoch = self.geometry_epoch;
*self.draw_depth_cache.borrow_mut() = Some(depths);
}
// Same for the block definitions, stamped with this scene's block
// epoch: the document has not been touched between the loader
// finishing and this call, so they describe it exactly.
@@ -2875,6 +2899,15 @@ impl Scene {
}
pub(crate) fn record_nav_perf(&self, op: NavPerfOp, started: iced::time::Instant) {
self.record_nav_perf_caused(op, "-", started);
}
pub(crate) fn record_nav_perf_caused(
&self,
op: NavPerfOp,
cause: &'static str,
started: iced::time::Instant,
) {
if !crate::perf::enabled() {
return;
}
@@ -2887,6 +2920,7 @@ impl Scene {
};
self.nav_perf_pending.set(Some(NavPerfSample {
op,
cause,
space,
mode,
started,
@@ -5982,6 +6016,11 @@ impl Scene {
/// A full build assigns sparse labels in effective draw order. Incremental
/// Add/Remove then changes only the named handle: existing siblings retain
/// their depth, avoiding an O(all entities) map rewrite and GPU const upload.
/// See [`Self::draw_depth_generation`].
pub(in crate::scene) fn draw_depth_generation(&self) -> u64 {
self.draw_depth_generation.get()
}
pub(super) fn draw_depth_map(&self) -> Arc<HashMap<u64, [f32; 2]>> {
{
let cache = self.draw_depth_cache.borrow();
@@ -6111,6 +6150,11 @@ impl Scene {
}
}
// From here the labels are assigned afresh, so an existing entity's
// depth can move. Anything holding a baked depth has to rebuild.
self.draw_depth_generation
.set(self.draw_depth_generation.get().wrapping_add(1));
use acadrust::objects::ObjectType;
// Per-block SortEntitiesTable overrides: block -> (entity_val -> sort_val).
let mut overrides: HashMap<Handle, HashMap<u64, u64>> = HashMap::default();
@@ -8298,24 +8342,33 @@ impl Scene {
let Some(indices) = lookup.get(&handle) else {
continue;
};
if indices
.iter()
.filter_map(|&index| meshes.get(index as usize))
.any(|set| {
set.geometry_lods().first().is_some_and(|mesh| {
!pick::hit_test::mesh_box_hit(
a,
b,
crossing,
std::iter::once((handle, mesh, set.instance_transform)),
view_rot,
eye,
bounds,
)
.is_empty()
})
let test = |set: &MeshLodSet| {
set.geometry_lods().first().is_some_and(|mesh| {
!pick::hit_test::mesh_box_hit(
a,
b,
crossing,
std::iter::once((handle, mesh, set.instance_transform)),
view_rot,
eye,
bounds,
)
.is_empty()
})
{
};
let mut sets = indices
.iter()
.filter_map(|&index| meshes.get(index as usize));
let hit = if crossing {
sets.any(test)
} else {
let mut count = 0;
sets.all(|set| {
count += 1;
test(set)
}) && count > 0
};
if hit {
out.push(handle);
}
}
@@ -8466,23 +8519,32 @@ impl Scene {
let Some(indices) = lookup.get(&handle) else {
continue;
};
if indices
.iter()
.filter_map(|&index| meshes.get(index as usize))
.any(|set| {
set.geometry_lods().first().is_some_and(|mesh| {
!pick::hit_test::mesh_poly_hit(
poly,
crossing,
std::iter::once((handle, mesh, set.instance_transform)),
view_rot,
eye,
bounds,
)
.is_empty()
})
let test = |set: &MeshLodSet| {
set.geometry_lods().first().is_some_and(|mesh| {
!pick::hit_test::mesh_poly_hit(
poly,
crossing,
std::iter::once((handle, mesh, set.instance_transform)),
view_rot,
eye,
bounds,
)
.is_empty()
})
{
};
let mut sets = indices
.iter()
.filter_map(|&index| meshes.get(index as usize));
let hit = if crossing {
sets.any(test)
} else {
let mut count = 0;
sets.all(|set| {
count += 1;
test(set)
}) && count > 0
};
if hit {
out.push(handle);
}
}
@@ -8724,23 +8786,32 @@ impl Scene {
let Some(indices) = lookup.get(&handle) else {
continue;
};
let hit = indices
let test = |set: &MeshLodSet| {
set.geometry_lods().first().is_some_and(|mesh| {
!pick::hit_test::mesh_box_hit(
a,
b,
crossing,
std::iter::once((handle, mesh, set.instance_transform)),
view_rot,
eye,
bounds,
)
.is_empty()
})
};
let mut sets = indices
.iter()
.filter_map(|&index| meshes.get(index as usize))
.any(|set| {
set.geometry_lods().first().is_some_and(|mesh| {
!pick::hit_test::mesh_box_hit(
a,
b,
crossing,
std::iter::once((handle, mesh, set.instance_transform)),
view_rot,
eye,
bounds,
)
.is_empty()
})
});
.filter_map(|&index| meshes.get(index as usize));
let hit = if crossing {
sets.any(test)
} else {
let mut count = 0;
sets.all(|set| {
count += 1;
test(set)
}) && count > 0
};
if hit {
out.push(handle);
}
@@ -8775,22 +8846,31 @@ impl Scene {
let Some(indices) = lookup.get(&handle) else {
continue;
};
let hit = indices
let test = |set: &MeshLodSet| {
set.geometry_lods().first().is_some_and(|mesh| {
!pick::hit_test::mesh_poly_hit(
poly,
crossing,
std::iter::once((handle, mesh, set.instance_transform)),
view_rot,
eye,
bounds,
)
.is_empty()
})
};
let mut sets = indices
.iter()
.filter_map(|&index| meshes.get(index as usize))
.any(|set| {
set.geometry_lods().first().is_some_and(|mesh| {
!pick::hit_test::mesh_poly_hit(
poly,
crossing,
std::iter::once((handle, mesh, set.instance_transform)),
view_rot,
eye,
bounds,
)
.is_empty()
})
});
.filter_map(|&index| meshes.get(index as usize));
let hit = if crossing {
sets.any(test)
} else {
let mut count = 0;
sets.all(|set| {
count += 1;
test(set)
}) && count > 0
};
if hit {
out.push(handle);
}
@@ -8810,6 +8890,27 @@ impl Scene {
frozen_layers: Option<&HashSet<Handle>>,
annotation_scale_handle: Option<Handle>,
all_visible: bool,
) -> bool {
let layer = self.document.layers.get(&e.common().layer);
self.resident_entity_visible_with_layer(
e,
block_handle,
frozen_layers,
annotation_scale_handle,
all_visible,
layer,
)
}
/// Visibility predicate with the layer resolved by the caller.
fn resident_entity_visible_with_layer(
&self,
e: &EntityType,
block_handle: Handle,
frozen_layers: Option<&HashSet<Handle>>,
annotation_scale_handle: Option<Handle>,
all_visible: bool,
layer: Option<&acadrust::tables::layer::Layer>,
) -> bool {
let c = e.common();
if c.invisible {
@@ -8823,7 +8924,6 @@ impl Scene {
if matches!(e, EntityType::Block(_) | EntityType::BlockEnd(_)) {
return false;
}
let layer = self.document.layers.get(&c.layer);
if layer
.map(|l| l.flags.off || l.flags.frozen)
.unwrap_or(false)
@@ -8850,8 +8950,8 @@ impl Scene {
self.belongs_to_visible_block(c.handle, c.owner_handle, block_handle)
}
fn wires_for_block_culled(
&self,
fn wires_for_block_culled<'doc>(
&'doc self,
block_handle: Handle,
view_aabb: Option<[f32; 4]>,
wpp: Option<f32>,
@@ -8905,13 +9005,25 @@ impl Scene {
// Visibility test reused by both paths below — and by the resident
// incremental patch, so a changed entity is included/excluded exactly as
// a from-scratch build would (no divergence).
let visibility_ok = |e: &EntityType| {
self.resident_entity_visible(
// Resolve each distinct layer name once for this candidate pass.
type LayerRef<'a> = Option<&'a acadrust::tables::layer::Layer>;
let layer_of: RefCell<rustc_hash::FxHashMap<&'doc str, LayerRef<'doc>>> =
RefCell::new(rustc_hash::FxHashMap::default());
let visibility_ok = |e: &'doc EntityType| {
let name = e.common().layer.as_str();
let layer = match layer_of.borrow_mut().entry(name) {
std::collections::hash_map::Entry::Occupied(hit) => *hit.get(),
std::collections::hash_map::Entry::Vacant(slot) => {
*slot.insert(self.document.layers.get(name))
}
};
self.resident_entity_visible_with_layer(
e,
block_handle,
frozen_layers,
annotation_scale_handle,
all_visible,
layer,
)
};
@@ -8923,6 +9035,9 @@ impl Scene {
// version of this diagnostic report a constant.
let visible_path: &str;
let mut visible_candidates: usize;
// PERF-only breakdown for index maintenance and handle resolution.
let mut visible_index_ms = 0.0f64;
let mut visible_probe_ms = 0.0f64;
// Phase 2.1 — quadtree-driven candidate selection. When a view
// AABB exists (Model layout with a settled camera), only iterate
@@ -8990,16 +9105,12 @@ impl Scene {
.filter(|e| visibility_ok(e))
.collect()
} else {
// Ask the per-epoch membership index for this block's candidates
// instead of testing every entity in the document. For a paper
// sheet holding 43 entities in a 1.17 M-entity drawing that is the
// difference between 313 ms and nothing measurable.
//
// The index is filled by walking `document.entities()`, so each
// block's list is already in document order and the wires come out
// in exactly the order the full scan produced.
// The per-epoch index preserves document order for each block.
visible_path = "block-index";
// Time index maintenance separately from the candidate loop.
let t_members = perf.then(iced::time::Instant::now);
let members = self.block_members();
visible_index_ms = crate::perf::elapsed_ms(t_members);
let (_, by_block) = &*members;
let claimed = by_block.get(&block_handle).map(Vec::as_slice).unwrap_or(&[]);
visible_candidates = claimed.len();
@@ -9011,6 +9122,18 @@ impl Scene {
}
}
}
if perf {
// PERF-only lower bound for warm handle resolution.
let t_probe = iced::time::Instant::now();
let mut resolved = 0usize;
for &handle in claimed {
if self.document.get_entity(handle).is_some() {
resolved += 1;
}
}
std::hint::black_box(resolved);
visible_probe_ms = t_probe.elapsed().as_secs_f64() * 1000.0;
}
out
};
@@ -9255,7 +9378,8 @@ impl Scene {
"[perf] wires-build total={:.1}ms sort_cache={:.1} visible={:.1} blk_cache={:.1} colors={:.1} \
build={:.1} [classify={:.1} hits={:.1} tess={:.1} materialize={:.1}] sort={:.1}({}) \
entities={} memo_hit={} memo_miss={} wires={} memo={} visible_path={} candidates={} \
guard={:016x} guard_stale={} avp={} anno={:.4} anno_h={} all_vis={} sdf_gen={}",
guard={:016x} guard_stale={} avp={} anno={:.4} anno_h={} all_vis={} sdf_gen={} \
vis_index={:.1} visible_probe={:.1}",
crate::perf::elapsed_ms(t_fn),
sort_cache_ms,
visible_ms,
@@ -9282,6 +9406,8 @@ guard={:016x} guard_stale={} avp={} anno={:.4} anno_h={} all_vis={} sdf_gen={}",
annotation_scale_handle.map(|h| h.value()).unwrap_or(0),
all_visible,
crate::scene::text::sdf_atlas::generation(),
visible_index_ms,
visible_probe_ms,
);
}
wires
@@ -9308,15 +9434,50 @@ guard={:016x} guard_stale={} avp={} anno={:.4} anno_h={} all_vis={} sdf_gen={}",
std::cell::Ref::map(self.unindexable_cache.borrow(), |c| c.as_ref().unwrap())
}
/// Resolve owner handles and block-record membership once per geometry epoch.
/// Resolve block membership once per geometry epoch. A single addition is
/// appended safely; any other delta rebuilds the document-ordered index.
fn block_members(&self) -> std::cell::Ref<'_, BlockMembers> {
let mut cached_epoch = None;
{
let cache = self.block_members_cache.borrow();
if cache.as_ref().is_some_and(|(epoch, _)| *epoch == self.geometry_epoch) {
drop(cache);
return std::cell::Ref::map(self.block_members_cache.borrow(), |c| {
c.as_ref().unwrap()
if let Some((epoch, _)) = cache.as_ref() {
if *epoch == self.geometry_epoch {
drop(cache);
return std::cell::Ref::map(self.block_members_cache.borrow(), |c| {
c.as_ref().unwrap()
});
}
cached_epoch = Some(*epoch);
}
}
if let Some(since) = cached_epoch {
let lone_add = self.replay_since(since).filter(|deltas| {
deltas.len() == 1 && deltas[0].1 == ChangeKind::Added
});
if let Some(deltas) = lone_add {
let handle = deltas[0].0;
// Resolve the owner before touching the cache: both lookups
// borrow, and `entity_block_map` is its own `RefCell`.
let owner = self.document.get_entity(handle).and_then(|entity| {
let owner = entity.common().owner_handle;
if !owner.is_null() {
Some(owner)
} else {
self.entity_block_map().get(&handle).copied()
}
});
let mut cache = self.block_members_cache.borrow_mut();
if let Some((epoch, members)) = cache.as_mut() {
if let Some(owner) = owner {
members.entry(owner).or_default().push(handle);
}
*epoch = self.geometry_epoch;
drop(cache);
return std::cell::Ref::map(self.block_members_cache.borrow(), |c| {
c.as_ref().unwrap()
});
}
}
}
let mut members: HashMap<Handle, Vec<Handle>> = HashMap::default();
@@ -10491,6 +10652,116 @@ mod journal_tests {
// Differential oracle: the incrementally-patched entity index must always
// equal a from-scratch rebuild after any add / move / erase.
/// A line has tangent geometry but does not feed the analytical uploads.
#[test]
fn the_partition_membership_test_separates_lines_from_curves() {
use acadrust::entities::{Circle, Line};
use acadrust::types::Vector3;
use crate::scene::pipeline::wire_arena::feeds_analytical_uploads;
let mut scene = Scene::new();
let feeds = |scene: &Scene, handle: Handle| {
let entity = scene.document.get_entity(handle).unwrap().clone();
scene
.tessellate_one(&entity)
.iter()
.any(feeds_analytical_uploads)
};
let line = scene.add_entity(EntityType::Line(Line::from_points(
Vector3::new(0.0, 0.0, 0.0),
Vector3::new(10.0, 5.0, 0.0),
)));
assert!(
!feeds(&scene, line),
"a drawn line must not force the partition walk",
);
let mut circle = Circle::new();
circle.radius = 4.0;
let circle = scene.add_entity(EntityType::Circle(circle));
assert!(
feeds(&scene, circle),
"a drawn circle feeds the analytical uploads and must force the walk",
);
}
/// Incremental additions preserve existing depths; full rebuilds invalidate them.
#[test]
fn adding_an_entity_leaves_existing_draw_depths_alone() {
use acadrust::entities::Line;
use acadrust::types::Vector3;
fn add(scene: &mut Scene, x: f64) -> Handle {
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 mut scene = Scene::new();
let first = add(&mut scene, 0.0);
let second = add(&mut scene, 1.0);
let before = scene.draw_depth_map();
let generation = scene.draw_depth_generation();
add(&mut scene, 2.0);
let after = scene.draw_depth_map();
assert_eq!(
scene.draw_depth_generation(),
generation,
"an incremental add must not invalidate baked depths",
);
for handle in [first, second] {
assert_eq!(
after.get(&handle.value()),
before.get(&handle.value()),
"an existing entity's depth must not move when another is added",
);
}
// A rebuild from scratch reassigns labels, so it has to say so.
scene.draw_depth_cache.borrow_mut().take();
let _ = scene.draw_depth_map();
assert_ne!(
scene.draw_depth_generation(),
generation,
"a full rebuild must invalidate anything holding a baked depth",
);
}
/// A folded addition must match the full document-order scan.
#[test]
fn folding_one_addition_matches_the_full_scan() {
use acadrust::entities::Line;
use acadrust::types::Vector3;
let mut scene = Scene::new();
fn add(scene: &mut Scene, x: f64) -> Handle {
scene.add_entity(EntityType::Line(Line::from_points(
Vector3::new(x, 0.0, 0.0),
Vector3::new(x + 1.0, 0.0, 0.0),
)))
}
add(&mut scene, 0.0);
add(&mut scene, 1.0);
// Build the index, then add one more so the next call folds.
let block = scene.model_space_block_handle();
let before = scene.block_members().1.get(&block).cloned().unwrap_or_default();
let third = add(&mut scene, 2.0);
let folded = scene.block_members().1.get(&block).cloned().unwrap_or_default();
scene.block_members_cache.borrow_mut().take();
let rebuilt = scene.block_members().1.get(&block).cloned().unwrap_or_default();
assert_eq!(folded, rebuilt, "the folded index must equal a full rebuild");
assert_eq!(
folded.last(),
Some(&third),
"an appended entity belongs at the end of its block's list",
);
assert_eq!(folded.len(), before.len() + 1);
}
#[test]
fn block_member_index_matches_the_full_scan() {
use acadrust::entities::Line;
@@ -10543,6 +10814,109 @@ mod journal_tests {
assert_eq!(indexed(&s, block), scanned(&s, block), "after modify");
}
/// Raw layer spellings used as memo keys must resolve through the
/// case-insensitive table.
#[test]
fn the_layer_memo_respects_case_insensitive_layer_names() {
use acadrust::entities::Line;
use acadrust::tables::layer::Layer;
use acadrust::types::Vector3;
let mut scene = Scene::new();
let mut hidden = Layer::new("Walls");
hidden.flags.off = true;
scene.document.layers.add_or_replace(hidden);
let mut on_layer = |layer: &str| {
let mut line = Line::from_points(
Vector3::new(0.0, 0.0, 0.0),
Vector3::new(1.0, 0.0, 0.0),
);
line.common.layer = layer.to_string();
scene.add_entity(EntityType::Line(line))
};
// Spellings of the switched-off layer must all remain hidden.
on_layer("Walls");
on_layer("WALLS");
on_layer("walls");
on_layer("Doors");
let block = scene.model_space_block_handle();
let visible: Vec<_> = scene
.document
.entities()
.filter(|e| scene.resident_entity_visible(e, block, None, None, true))
.map(|e| e.common().layer.clone())
.collect();
assert_eq!(
visible,
vec!["Doors".to_string()],
"every spelling of a switched-off layer must be hidden",
);
// Each spelling resolves to the same table entry.
let resolved: Vec<_> = ["Walls", "WALLS", "walls"]
.iter()
.map(|name| scene.document.layers.get(name).map(|l| l.name.clone()))
.collect();
assert_eq!(
resolved,
vec![Some("Walls".to_string()); 3],
"the table is case-insensitive, which is what lets the memo key on \
the raw name",
);
}
/// The loader's handed-over draw depths must equal a local rebuild.
#[test]
fn the_handed_over_draw_depth_map_matches_a_recompute() {
use acadrust::entities::{Circle, Line};
use acadrust::types::Vector3;
let mut scene = Scene::new();
scene.add_entity(EntityType::Line(Line::from_points(
Vector3::new(0.0, 0.0, 0.0),
Vector3::new(1.0, 0.0, 0.0),
)));
scene.add_entity(EntityType::Circle(Circle::new()));
scene.add_entity(EntityType::Line(Line::from_points(
Vector3::new(2.0, 0.0, 0.0),
Vector3::new(3.0, 0.0, 0.0),
)));
// Capture the map the loader would hand over.
let expected = scene.draw_depth_map();
let handed_over = scene
.draw_depth_cache
.borrow_mut()
.take()
.expect("the map was just computed, so it is cached");
// Install it under the receiving scene's epoch.
scene.bump_geometry();
scene.install_prepared_open_geometry(PreparedOpenGeometry {
wires: Arc::new(Vec::new()),
interaction_index: None,
tess_memo: HashMap::default(),
tess_guard: 0,
block_defns: Vec::new(),
draw_depths: Some(handed_over),
});
assert_eq!(
*scene.draw_depth_map(),
*expected,
"the handed-over map must be served as-is",
);
// A local rebuild must agree.
*scene.draw_depth_cache.borrow_mut() = None;
assert_eq!(
*scene.draw_depth_map(),
*expected,
"a recompute must agree with what was handed over",
);
}
#[test]
fn entity_index_incremental_matches_full_rebuild() {
use acadrust::entities::Line;
+210 -6
View File
@@ -1,12 +1,13 @@
use acadrust::entities::{EmbeddedEntity, Solid3D};
use acadrust::objects::{
DynamicBlockData, ObjectType, SolidHistoryBox, SolidHistoryBrep, SolidHistoryCone,
SolidHistoryCylinder, SolidHistoryLoft, SolidHistoryLoftParameters, SolidHistoryNodeBase, SolidHistoryOperation,
DynamicBlockData, ObjectType, SolidHistoryBox, SolidHistoryBrep, SolidHistoryChamfer,
SolidHistoryCone, SolidHistoryCylinder, SolidHistoryFillet, SolidHistoryLoft,
SolidHistoryLoftParameters, SolidHistoryNodeBase, SolidHistoryOperation,
SolidHistoryPyramid, SolidHistoryRevolve, SolidHistorySphere, SolidHistorySweep,
SolidHistoryTorus,
};
use acadrust::EntityType;
use cadkernel::brep::Body;
use cadkernel::brep::{Body, Surface};
use crate::command::EntityTransform;
use crate::entities::traits::EntityTypeOps;
@@ -32,6 +33,9 @@ pub const GRIP_SWEEP_PATH_FIRST: usize = 30_000;
pub const GRIP_LOFT_SECTION_FIRST: usize = 40_000;
pub const GRIP_REVOLVE_PROFILE_FIRST: usize = 50_000;
pub const GRIP_REVOLVE_AXIS: usize = 10_013;
pub const GRIP_FILLET_RADIUS: usize = 10_014;
pub const GRIP_CHAMFER_DISTANCE1: usize = 10_015;
pub const GRIP_CHAMFER_DISTANCE2: usize = 10_016;
pub const GRIP_BOX_CORNER_FIRST: usize = 10_100;
pub const GRIP_BOX_FACE_X_MIN: usize = 10_110;
pub const GRIP_BOX_FACE_X_MAX: usize = 10_111;
@@ -197,7 +201,7 @@ pub fn has_specialized_primitive_properties(
handle: acadrust::Handle,
) -> bool {
matches!(
document.solid_history_operation(handle),
primitive_property_operation(document, handle).as_ref(),
Some(
SolidHistoryOperation::Box(_)
| SolidHistoryOperation::Wedge(_)
@@ -214,6 +218,19 @@ pub fn has_specialized_primitive_properties(
)
}
/// Return the primitive that owns the public Geometry rows. Later edge
/// operations refine that primitive without replacing its editable type,
/// position, or dimensions.
pub fn primitive_property_operation(
document: &acadrust::CadDocument,
handle: acadrust::Handle,
) -> Option<SolidHistoryOperation> {
document
.solid_history_operations(handle)
.and_then(|operations| operations.into_iter().next())
.or_else(|| document.solid_history_operation(handle).cloned())
}
/// Solid history results whose public Properties palette is fully described by
/// the common entity rows plus [`primitive_properties`]. A generic B-rep has
/// no stable primitive dimensions to expose, but it still uses the compact
@@ -1474,10 +1491,10 @@ pub fn primitive_properties(
document: &acadrust::CadDocument,
handle: acadrust::Handle,
) -> Vec<PropSection> {
let Some(operation) = document.solid_history_operation(handle) else {
let Some(operation) = primitive_property_operation(document, handle) else {
return brep_properties(document, handle);
};
match operation {
match &operation {
SolidHistoryOperation::Box(value) => {
rectangular_properties(document, handle, value, "Box")
}
@@ -3161,6 +3178,149 @@ fn grip(
}
}
pub fn fillet_radius_grip(body: &Body, radius: f64) -> Option<GripDef> {
if !radius.is_finite() || radius <= 0.0 {
return None;
}
for (face_key, face) in body.faces.iter() {
let Some(Surface::Cylinder(cylinder)) = body.surfaces.get(face.surface) else {
continue;
};
if (cylinder.radius.abs() - radius).abs() > radius.max(1.0) * 1.0e-7 {
continue;
}
let axis = glam::DVec3::from_array(cylinder.base.normal()?).try_normalize()?;
let origin = glam::DVec3::from_array(cylinder.base.origin);
let points = body
.face_coedges(face_key)
.into_iter()
.filter_map(|coedge| body.coedges.get(coedge))
.filter_map(|coedge| body.edges.get(coedge.edge))
.flat_map(|edge| [edge.start, edge.end])
.filter_map(|vertex| body.vertices.get(vertex))
.map(|vertex| glam::DVec3::from_array(vertex.point))
.collect::<Vec<_>>();
let minimum = points
.iter()
.map(|point| (*point - origin).dot(axis))
.reduce(f64::min)?;
let maximum = points
.iter()
.map(|point| (*point - origin).dot(axis))
.reduce(f64::max)?;
let center = origin + axis * ((minimum + maximum) * 0.5);
let radial = points
.iter()
.map(|point| *point - center - axis * (*point - center).dot(axis))
.find_map(|radial| radial.try_normalize())?;
return Some(grip(
GRIP_FILLET_RADIUS,
center + radial * radius,
GripShape::Square,
Some(radial),
));
}
None
}
pub fn chamfer_distance_grips(
document: &acadrust::CadDocument,
handle: acadrust::Handle,
value: &SolidHistoryChamfer,
) -> Vec<GripDef> {
let Some(operations) = document.solid_history_operations(handle) else {
return Vec::new();
};
let Some(chamfer_index) = operations
.iter()
.rposition(|operation| matches!(operation, SolidHistoryOperation::Chamfer(_)))
else {
return Vec::new();
};
let Ok(source) = cadkernel::acis::rebuild_history(&operations[..chamfer_index]) else {
return Vec::new();
};
let edges = source.edge_keys().collect::<Vec<_>>();
let faces = source.face_keys().collect::<Vec<_>>();
let Some(edge) = value
.edges
.first()
.and_then(|ordinal| usize::try_from(*ordinal).ok())
.and_then(|ordinal| edges.get(ordinal).copied())
.and_then(|edge| source.edges.get(edge))
else {
return Vec::new();
};
let Some(base_face) = usize::try_from(value.base_face)
.ok()
.and_then(|ordinal| faces.get(ordinal).copied())
else {
return Vec::new();
};
let adjacent = edge
.coedges
.iter()
.filter_map(|coedge| source.coedges.get(*coedge))
.filter_map(|coedge| source.loops.get(coedge.owner))
.map(|edge_loop| edge_loop.owner)
.collect::<Vec<_>>();
let Some(other_face) = adjacent.iter().copied().find(|face| *face != base_face) else {
return Vec::new();
};
let outward = |face_key| {
let face = source.faces.get(face_key)?;
let Surface::Plane(plane) = source.surfaces.get(face.surface)? else {
return None;
};
let normal = glam::DVec3::from_array(plane.normal()?).try_normalize()?;
Some(if face.forward { normal } else { -normal })
};
let Some(base_normal) = outward(base_face) else {
return Vec::new();
};
let Some(other_normal) = outward(other_face) else {
return Vec::new();
};
let dot = base_normal.dot(other_normal);
let Some(base_inward) = (-(other_normal - base_normal * dot)).try_normalize() else {
return Vec::new();
};
let Some(other_inward) = (-(base_normal - other_normal * dot)).try_normalize() else {
return Vec::new();
};
let Some(start) = source.vertices.get(edge.start) else {
return Vec::new();
};
let Some(end) = source.vertices.get(edge.end) else {
return Vec::new();
};
let midpoint =
(glam::DVec3::from_array(start.point) + glam::DVec3::from_array(end.point)) * 0.5;
[
(
GRIP_CHAMFER_DISTANCE1,
value.base_distance,
base_inward,
),
(
GRIP_CHAMFER_DISTANCE2,
value.other_distance,
other_inward,
),
]
.into_iter()
.filter(|(_, distance, _)| distance.is_finite() && *distance > 0.0)
.map(|(id, distance, axis)| {
grip(
id,
midpoint + axis * distance,
GripShape::Square,
Some(axis),
)
})
.collect()
}
pub fn primitive_grips(
document: &acadrust::CadDocument,
handle: acadrust::Handle,
@@ -3168,6 +3328,21 @@ pub fn primitive_grips(
let Some(operation) = document.solid_history_operation(handle) else {
return Vec::new();
};
if let SolidHistoryOperation::Fillet(value) = operation {
let Some(radius) = value.radii.first().copied() else {
return Vec::new();
};
let Some(EntityType::Solid3D(solid)) = document.get_entity(handle) else {
return Vec::new();
};
return crate::scene::convert::solid3d_tess::kernel_body(solid)
.and_then(|body| fillet_radius_grip(&body, radius))
.into_iter()
.collect();
}
if let SolidHistoryOperation::Chamfer(value) = operation {
return chamfer_distance_grips(document, handle, value);
}
let mut grips = Vec::new();
let mut add = |id, transform, point, shape, axis: Option<[f64; 3]>| {
if let Some(world) = world_point(transform, point) {
@@ -3788,3 +3963,32 @@ pub fn brep_op(body: &Body) -> SolidHistoryOperation {
..SolidHistoryBrep::default()
})
}
pub fn fillet_op(edges: Vec<i32>, radius: f64) -> SolidHistoryOperation {
SolidHistoryOperation::Fillet(SolidHistoryFillet {
base: base(glam::DMat4::IDENTITY.to_cols_array()),
operation_major: 1,
method: 0,
edges,
radii: vec![radius],
..SolidHistoryFillet::default()
})
}
pub fn chamfer_op(
edges: Vec<i32>,
base_face: i32,
base_distance: f64,
other_distance: f64,
) -> SolidHistoryOperation {
SolidHistoryOperation::Chamfer(SolidHistoryChamfer {
base: base(glam::DMat4::IDENTITY.to_cols_array()),
operation_major: 1,
method: 0,
base_distance,
other_distance,
edges,
base_face,
..SolidHistoryChamfer::default()
})
}
+316
View File
@@ -58,6 +58,201 @@ pub enum TangentGeom {
},
}
impl TangentGeom {
pub fn translated(&self, delta: glam::DVec3) -> Self {
match self {
Self::Line { p1, p2 } => Self::Line {
p1: [p1[0] + delta.x as f32, p1[1] + delta.y as f32, p1[2] + delta.z as f32],
p2: [p2[0] + delta.x as f32, p2[1] + delta.y as f32, p2[2] + delta.z as f32],
},
Self::Circle { center, radius } => Self::Circle {
center: [center[0] + delta.x as f32, center[1] + delta.y as f32, center[2] + delta.z as f32],
radius: *radius,
},
Self::PlanarCircle { center, axis_x, axis_y, radius } => Self::PlanarCircle {
center: [center[0] + delta.x, center[1] + delta.y, center[2] + delta.z],
axis_x: *axis_x,
axis_y: *axis_y,
radius: *radius,
},
Self::Arc { center, axis_x, axis_y, radius, start_angle, end_angle } => Self::Arc {
center: [center[0] + delta.x, center[1] + delta.y, center[2] + delta.z],
axis_x: *axis_x,
axis_y: *axis_y,
radius: *radius,
start_angle: *start_angle,
end_angle: *end_angle,
},
Self::PlanarEllipse { center, major_axis, normal, minor_axis_ratio, start_param, end_param } => Self::PlanarEllipse {
center: [center[0] + delta.x, center[1] + delta.y, center[2] + delta.z],
major_axis: *major_axis,
normal: *normal,
minor_axis_ratio: *minor_axis_ratio,
start_param: *start_param,
end_param: *end_param,
},
}
}
pub fn rotated(&self, center: glam::DVec3, axis: glam::DVec3, angle_rad: f64) -> Self {
let quat = glam::DQuat::from_axis_angle(axis.normalize_or(glam::DVec3::Z), angle_rad);
let rot_pt = |p: [f64; 3]| (center + quat * (glam::DVec3::from_array(p) - center)).to_array();
let rot_vec = |v: [f64; 3]| (quat * glam::DVec3::from_array(v)).to_array();
match self {
Self::Line { p1, p2 } => {
let p1_d = [p1[0] as f64, p1[1] as f64, p1[2] as f64];
let p2_d = [p2[0] as f64, p2[1] as f64, p2[2] as f64];
let r1 = rot_pt(p1_d);
let r2 = rot_pt(p2_d);
Self::Line {
p1: [r1[0] as f32, r1[1] as f32, r1[2] as f32],
p2: [r2[0] as f32, r2[1] as f32, r2[2] as f32],
}
}
Self::Circle { center: c, radius } => {
let c_d = [c[0] as f64, c[1] as f64, c[2] as f64];
let rc = rot_pt(c_d);
Self::Circle {
center: [rc[0] as f32, rc[1] as f32, rc[2] as f32],
radius: *radius,
}
}
Self::PlanarCircle { center: c, axis_x, axis_y, radius } => Self::PlanarCircle {
center: rot_pt(*c),
axis_x: rot_vec(*axis_x),
axis_y: rot_vec(*axis_y),
radius: *radius,
},
Self::Arc { center: c, axis_x, axis_y, radius, start_angle, end_angle } => Self::Arc {
center: rot_pt(*c),
axis_x: rot_vec(*axis_x),
axis_y: rot_vec(*axis_y),
radius: *radius,
start_angle: *start_angle,
end_angle: *end_angle,
},
Self::PlanarEllipse { center: c, major_axis, normal, minor_axis_ratio, start_param, end_param } => Self::PlanarEllipse {
center: rot_pt(*c),
major_axis: rot_vec(*major_axis),
normal: rot_vec(*normal),
minor_axis_ratio: *minor_axis_ratio,
start_param: *start_param,
end_param: *end_param,
},
}
}
pub fn scaled(&self, center: glam::DVec3, factor: f64) -> Self {
let scale_pt = |p: [f64; 3]| (center + (glam::DVec3::from_array(p) - center) * factor).to_array();
match self {
Self::Line { p1, p2 } => {
let p1_d = [p1[0] as f64, p1[1] as f64, p1[2] as f64];
let p2_d = [p2[0] as f64, p2[1] as f64, p2[2] as f64];
let s1 = scale_pt(p1_d);
let s2 = scale_pt(p2_d);
Self::Line {
p1: [s1[0] as f32, s1[1] as f32, s1[2] as f32],
p2: [s2[0] as f32, s2[1] as f32, s2[2] as f32],
}
}
Self::Circle { center: c, radius } => {
let c_d = [c[0] as f64, c[1] as f64, c[2] as f64];
let sc = scale_pt(c_d);
Self::Circle {
center: [sc[0] as f32, sc[1] as f32, sc[2] as f32],
radius: *radius * factor.abs() as f32,
}
}
Self::PlanarCircle { center: c, axis_x, axis_y, radius } => Self::PlanarCircle {
center: scale_pt(*c),
axis_x: *axis_x,
axis_y: *axis_y,
radius: *radius * factor.abs(),
},
Self::Arc { center: c, axis_x, axis_y, radius, start_angle, end_angle } => Self::Arc {
center: scale_pt(*c),
axis_x: *axis_x,
axis_y: *axis_y,
radius: *radius * factor.abs(),
start_angle: *start_angle,
end_angle: *end_angle,
},
Self::PlanarEllipse { center: c, major_axis, normal, minor_axis_ratio, start_param, end_param } => Self::PlanarEllipse {
center: scale_pt(*c),
major_axis: (glam::DVec3::from_array(*major_axis) * factor.abs()).to_array(),
normal: *normal,
minor_axis_ratio: *minor_axis_ratio,
start_param: *start_param,
end_param: *end_param,
},
}
}
pub fn mirrored(&self, p1: glam::DVec3, normal: glam::DVec3) -> Self {
let refl_pt = |p: [f64; 3]| {
let pt = glam::DVec3::from_array(p);
(pt - 2.0 * normal.dot(pt - p1) * normal).to_array()
};
let refl_vec = |v: [f64; 3]| {
let vec = glam::DVec3::from_array(v);
(vec - 2.0 * normal.dot(vec) * normal).to_array()
};
match self {
Self::Line { p1: lp1, p2: lp2 } => {
let p1_d = [lp1[0] as f64, lp1[1] as f64, lp1[2] as f64];
let p2_d = [lp2[0] as f64, lp2[1] as f64, lp2[2] as f64];
let m1 = refl_pt(p1_d);
let m2 = refl_pt(p2_d);
Self::Line {
p1: [m1[0] as f32, m1[1] as f32, m1[2] as f32],
p2: [m2[0] as f32, m2[1] as f32, m2[2] as f32],
}
}
Self::Circle { center: c, radius } => {
let c_d = [c[0] as f64, c[1] as f64, c[2] as f64];
let mc = refl_pt(c_d);
Self::Circle {
center: [mc[0] as f32, mc[1] as f32, mc[2] as f32],
radius: *radius,
}
}
Self::PlanarCircle { center: c, axis_x, axis_y, radius } => {
let ax = refl_vec(*axis_x);
let ay = refl_vec(*axis_y);
Self::PlanarCircle {
center: refl_pt(*c),
axis_x: ax,
axis_y: [-ay[0], -ay[1], -ay[2]],
radius: *radius,
}
}
Self::Arc { center: c, axis_x, axis_y, radius, start_angle, end_angle } => {
let ax = refl_vec(*axis_x);
let ay = refl_vec(*axis_y);
Self::Arc {
center: refl_pt(*c),
axis_x: ax,
axis_y: [-ay[0], -ay[1], -ay[2]],
radius: *radius,
start_angle: -end_angle,
end_angle: -start_angle,
}
}
Self::PlanarEllipse { center: c, major_axis, normal: n, minor_axis_ratio, start_param, end_param } => {
let norm = refl_vec(*n);
Self::PlanarEllipse {
center: refl_pt(*c),
major_axis: refl_vec(*major_axis),
normal: [-norm[0], -norm[1], -norm[2]],
minor_axis_ratio: *minor_axis_ratio,
start_param: *start_param,
end_param: *end_param,
}
}
}
}
}
/// Viewport-relative display transform for a point marker.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct PointMarker {
@@ -413,6 +608,11 @@ impl WireModel {
out.text_verts =
map_text_verts(&self.text_verts, |x, y, z| (x + dx, y + dy, z + dz));
}
out.tangent_geoms = self
.tangent_geoms
.iter()
.map(|tg| tg.translated(delta.as_dvec3()))
.collect();
out
}
@@ -465,6 +665,11 @@ impl WireModel {
(mapped.x, mapped.y, mapped.z)
});
}
out.tangent_geoms = self
.tangent_geoms
.iter()
.map(|tg| tg.rotated(center.as_dvec3(), axis.as_dvec3(), angle_rad as f64))
.collect();
out
}
@@ -499,6 +704,11 @@ impl WireModel {
(cx + (x - cx) * f, cy + (y - cy) * f, cz + (z - cz) * f)
});
}
out.tangent_geoms = self
.tangent_geoms
.iter()
.map(|tg| tg.scaled(center.as_dvec3(), factor as f64))
.collect();
out
}
@@ -639,6 +849,11 @@ pub fn stretched_windows(
(mapped.x, mapped.y, mapped.z)
});
}
out.tangent_geoms = self
.tangent_geoms
.iter()
.map(|tg| tg.mirrored(p1.as_dvec3(), plane_normal.as_dvec3()))
.collect();
out
}
@@ -764,3 +979,104 @@ impl Default for WireModel {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn preview_transformations_preserve_and_update_tangent_geoms() {
let mut wire = WireModel::default();
wire.tangent_geoms.push(TangentGeom::PlanarCircle {
center: [10.0, 20.0, 0.0],
axis_x: [1.0, 0.0, 0.0],
axis_y: [0.0, 1.0, 0.0],
radius: 5.0,
});
wire.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: 5.0,
start_angle: 0.0,
end_angle: std::f64::consts::FRAC_PI_2,
});
wire.tangent_geoms.push(TangentGeom::PlanarEllipse {
center: [10.0, 20.0, 0.0],
major_axis: [10.0, 0.0, 0.0],
normal: [0.0, 0.0, 1.0],
minor_axis_ratio: 0.5,
start_param: 0.0,
end_param: std::f64::consts::PI,
});
// Test translated
let trans = wire.translated(glam::Vec3::new(5.0, -5.0, 1.0));
assert_eq!(trans.tangent_geoms.len(), 3);
if let TangentGeom::PlanarCircle { center, radius, .. } = trans.tangent_geoms[0] {
assert!((center[0] - 15.0).abs() < 1e-5);
assert!((center[1] - 15.0).abs() < 1e-5);
assert!((center[2] - 1.0).abs() < 1e-5);
assert!((radius - 5.0).abs() < 1e-5);
} else {
panic!("Expected PlanarCircle");
}
if let TangentGeom::Arc { center, radius, start_angle, end_angle, .. } = trans.tangent_geoms[1] {
assert!((center[0] - 15.0).abs() < 1e-5);
assert!((center[1] - 15.0).abs() < 1e-5);
assert!((center[2] - 1.0).abs() < 1e-5);
assert!((radius - 5.0).abs() < 1e-5);
assert_eq!(start_angle, 0.0);
assert_eq!(end_angle, std::f64::consts::FRAC_PI_2);
} else {
panic!("Expected Arc");
}
if let TangentGeom::PlanarEllipse { center, major_axis, minor_axis_ratio, .. } = trans.tangent_geoms[2] {
assert!((center[0] - 15.0).abs() < 1e-5);
assert!((center[1] - 15.0).abs() < 1e-5);
assert!((center[2] - 1.0).abs() < 1e-5);
assert_eq!(major_axis, [10.0, 0.0, 0.0]);
assert_eq!(minor_axis_ratio, 0.5);
} else {
panic!("Expected PlanarEllipse");
}
// Test scaled
let scaled = wire.scaled(glam::Vec3::new(10.0, 20.0, 0.0), 2.0);
if let TangentGeom::PlanarCircle { radius, .. } = scaled.tangent_geoms[0] {
assert!((radius - 10.0).abs() < 1e-5);
} else {
panic!("Expected PlanarCircle");
}
if let TangentGeom::Arc { radius, .. } = scaled.tangent_geoms[1] {
assert!((radius - 10.0).abs() < 1e-5);
} else {
panic!("Expected Arc");
}
if let TangentGeom::PlanarEllipse { major_axis, .. } = scaled.tangent_geoms[2] {
assert!((major_axis[0] - 20.0).abs() < 1e-5);
} else {
panic!("Expected PlanarEllipse");
}
// Test rotated
let rot = wire.rotated(glam::Vec3::ZERO, std::f32::consts::FRAC_PI_2);
if let TangentGeom::PlanarCircle { center, axis_x, axis_y, .. } = rot.tangent_geoms[0] {
assert!((center[0] - -20.0).abs() < 1e-4);
assert!((center[1] - 10.0).abs() < 1e-4);
assert!((axis_x[1] - 1.0).abs() < 1e-4);
assert!((axis_y[0] - -1.0).abs() < 1e-4);
} else {
panic!("Expected PlanarCircle");
}
// Test mirrored across Y=0 plane (normal = Y)
let mirr = wire.mirrored(glam::Vec3::new(0.0, 0.0, 0.0), glam::Vec3::new(1.0, 0.0, 0.0));
if let TangentGeom::PlanarCircle { center, .. } = mirr.tangent_geoms[0] {
assert!((center[0] - 10.0).abs() < 1e-4);
assert!((center[1] - -20.0).abs() < 1e-4);
} else {
panic!("Expected PlanarCircle");
}
}
}
+135 -6
View File
@@ -802,11 +802,39 @@ impl Scene {
true
}
pub fn append_solid_history(
&mut self,
handle: Handle,
operation: acadrust::objects::SolidHistoryOperation,
) -> bool {
let previous = self
.document
.solid_history_graph(handle)
.map(|graph| graph.nodes)
.unwrap_or_default();
self.record_solid_history_before(handle);
let Some(graph) = self.document.append_solid_history(handle, operation) else {
return false;
};
for node in graph.nodes {
if !previous.contains(&node) {
self.record_undo_object_before(node, None);
}
}
self.sync_solid_reference_point(handle);
true
}
pub(crate) fn sync_solid_reference_point(&mut self, handle: Handle) {
let reference = self
.document
.solid_history_operation(handle)
.and_then(crate::scene::model::solid_history::reference_point);
.solid_history_operations(handle)
.and_then(|operations| {
operations
.iter()
.rev()
.find_map(crate::scene::model::solid_history::reference_point)
});
let Some(reference) = reference else {
return;
};
@@ -855,8 +883,31 @@ impl Scene {
(EntityType::Surface(_), SolidHistoryOperation::Extrusion(value)) => {
cadkernel::acis::rebuild_extrusion_with_mode(value, true).ok()
}
(EntityType::Surface(_), SolidHistoryOperation::Loft(_))
| (EntityType::Solid3D(_), _) => cadkernel::acis::rebuild_body(operation).ok(),
(EntityType::Surface(_), SolidHistoryOperation::Loft(_)) => {
cadkernel::acis::rebuild_body(operation).ok()
}
(EntityType::Solid3D(_), _) => {
let mut operations = self.document.solid_history_operations(handle)?;
let replacement_id = operation.base().map(|base| {
if base.eval.node_id > 0 {
base.eval.node_id
} else {
base.step_id
}
})?;
let target = operations.iter_mut().find(|candidate| {
candidate.base().is_some_and(|base| {
let node_id = if base.eval.node_id > 0 {
base.eval.node_id
} else {
base.step_id
};
node_id == replacement_id
})
})?;
*target = operation.clone();
cadkernel::acis::rebuild_history(&operations).ok()
}
_ => None,
}
}
@@ -909,7 +960,7 @@ impl Scene {
self.record_solid_history_before(handle);
if self
.document
.update_solid_history(handle, operation)
.update_solid_history_step(handle, operation)
.is_none()
{
return false;
@@ -1122,6 +1173,79 @@ impl Scene {
let Some(mut operation) = self.document.solid_history_operation(handle).cloned() else {
return false;
};
if grip_id == crate::scene::model::solid_history::GRIP_FILLET_RADIUS {
let acadrust::objects::SolidHistoryOperation::Fillet(value) = &mut operation else {
return false;
};
let Some(radius) = value.radii.first().copied() else {
return false;
};
self.restore_solid_models(&[handle]);
let Some(definition) = self
.solid_models
.get(&handle)
.and_then(|body| {
crate::scene::model::solid_history::fillet_radius_grip(body, radius)
})
else {
return false;
};
let Some(axis) = definition.axis else {
return false;
};
let change = match apply {
GripApply::Absolute(world) => (world - definition.world).dot(axis),
GripApply::Translate(delta) => delta.dot(axis),
};
let radius = radius + change;
if !radius.is_finite() || radius <= 1.0e-6 {
return false;
}
value.radii.clear();
value.radii.push(radius);
return self.preview_solid_history(handle, operation);
}
if matches!(
grip_id,
crate::scene::model::solid_history::GRIP_CHAMFER_DISTANCE1
| crate::scene::model::solid_history::GRIP_CHAMFER_DISTANCE2
) {
let definitions = {
let acadrust::objects::SolidHistoryOperation::Chamfer(value) = &operation else {
return false;
};
crate::scene::model::solid_history::chamfer_distance_grips(
&self.document,
handle,
value,
)
};
let Some(definition) = definitions.into_iter().find(|grip| grip.id == grip_id) else {
return false;
};
let Some(axis) = definition.axis else {
return false;
};
let change = match apply {
GripApply::Absolute(world) => (world - definition.world).dot(axis),
GripApply::Translate(delta) => delta.dot(axis),
};
let acadrust::objects::SolidHistoryOperation::Chamfer(value) = &mut operation else {
return false;
};
let distance = if grip_id
== crate::scene::model::solid_history::GRIP_CHAMFER_DISTANCE1
{
&mut value.base_distance
} else {
&mut value.other_distance
};
*distance += change;
if !distance.is_finite() || *distance <= 1.0e-6 {
return false;
}
return self.preview_solid_history(handle, operation);
}
if !crate::scene::model::solid_history::apply_primitive_grip(
&mut operation,
grip_id,
@@ -1138,7 +1262,12 @@ impl Scene {
field: &str,
value: &str,
) -> bool {
let Some(mut operation) = self.document.solid_history_operation(handle).cloned() else {
let Some(mut operation) =
crate::scene::model::solid_history::primitive_property_operation(
&self.document,
handle,
)
else {
return false;
};
if !crate::scene::model::solid_history::apply_primitive_property(
+287 -87
View File
@@ -1615,10 +1615,10 @@ pub fn box_hit<'a, W: WireSource + ?Sized>(
);
}
// Q: lazy projection — accumulate screen points without allocating per-wire Vec.
wires
.iter()
.filter_map(|wire| {
if crossing {
let mut out = Vec::new();
let mut seen = HashSet::default();
for wire in wires.iter() {
// Fallback: when wire has no line geometry (e.g. greek text emits
// only fill_tris) treat the AABB rectangle as the hit-test shape
// so low-LOD text stays selectable. See #19.
@@ -1639,7 +1639,7 @@ pub fn box_hit<'a, W: WireSource + ?Sized>(
];
&aabb_pts
} else {
return None;
continue;
};
// Low residual parallel to `pts` (empty for the AABB fallback,
@@ -1650,6 +1650,85 @@ pub fn box_hit<'a, W: WireSource + ?Sized>(
&[]
};
let mut hit = false;
let mut prev: Option<Point> = None;
for (i, &[px, py, pz]) in pts.iter().enumerate() {
if px.is_nan() {
prev = None;
continue;
}
let world = if !wire.points.is_empty() {
wire_point_world(wire, i, view_rot, eye)
} else {
wp64([px, py, pz], low, i)
};
let sp = world_to_screen(world, view_rot, eye, bounds);
if inside(sp) {
hit = true;
}
if let Some(p0) = prev {
if !hit {
hit = segments_intersect(p0, sp, box_tl, box_tr)
|| segments_intersect(p0, sp, box_tr, box_br)
|| segments_intersect(p0, sp, box_br, box_bl)
|| segments_intersect(p0, sp, box_bl, box_tl);
}
}
prev = Some(sp);
}
let mut glyph_crosses = false;
for start in (0..wire.text_verts.len()).step_by(6) {
let Some(screen) = projected_text_quad(wire, start, view_rot, eye, bounds) else {
continue;
};
if [0usize, 3].into_iter().any(|offset| {
triangle_crosses_box(
[screen[offset], screen[offset + 1], screen[offset + 2]],
box_corners,
)
}) {
glyph_crosses = true;
break;
}
}
if (hit || glyph_crosses) && seen.insert(wire.name.as_str()) {
out.push(wire.name.as_str());
}
}
out
} else {
let mut qualified = Vec::new();
let mut disqualified = HashSet::default();
let mut seen = HashSet::default();
for wire in wires.iter() {
let aabb_pts: Vec<[f32; 3]>;
let empty_pts: [[f32; 3]; 0] = [];
let pts: &[[f32; 3]] = if !wire.points.is_empty() {
&wire.points
} else if !wire.text_verts.is_empty() {
&empty_pts
} else if wire.aabb != WireModel::UNBOUNDED_AABB {
let [ax, ay, bx, by] = wire.aabb;
aabb_pts = vec![
[ax, ay, 0.0],
[bx, ay, 0.0],
[bx, by, 0.0],
[ax, by, 0.0],
[ax, ay, 0.0],
];
&aabb_pts
} else {
continue;
};
let low: &[[f32; 3]] = if !wire.points.is_empty() {
&wire.points_low
} else {
&[]
};
let mut all_inside = true;
let mut prev: Option<Point> = None;
@@ -1664,61 +1743,42 @@ pub fn box_hit<'a, W: WireSource + ?Sized>(
wp64([px, py, pz], low, i)
};
let sp = world_to_screen(world, view_rot, eye, bounds);
if crossing {
if inside(sp) {
hit = true;
}
if let Some(p0) = prev {
if !hit {
hit = segments_intersect(p0, sp, box_tl, box_tr)
|| segments_intersect(p0, sp, box_tr, box_br)
|| segments_intersect(p0, sp, box_br, box_bl)
|| segments_intersect(p0, sp, box_bl, box_tl);
}
}
} else {
if !inside(sp) {
all_inside = false;
}
if !inside(sp) {
all_inside = false;
}
prev = Some(sp);
}
let glyphs_present = !wire.text_verts.is_empty();
let mut glyph_crosses = false;
let mut glyphs_inside = true;
for start in (0..wire.text_verts.len()).step_by(6) {
let Some(screen) = projected_text_quad(wire, start, view_rot, eye, bounds) else {
continue;
};
if crossing {
if [0usize, 3].into_iter().any(|offset| {
triangle_crosses_box(
[screen[offset], screen[offset + 1], screen[offset + 2]],
box_corners,
)
}) {
glyph_crosses = true;
break;
}
} else if !screen.iter().copied().all(inside) {
if !screen.iter().copied().all(inside) {
glyphs_inside = false;
break;
}
}
let result = if crossing {
hit || glyph_crosses
} else {
all_inside && glyphs_inside && (prev.is_some() || glyphs_present)
};
if result {
Some(wire.name.as_str())
} else {
None
let has_geom = prev.is_some() || glyphs_present;
if !has_geom {
continue;
}
})
.collect()
let name = wire.name.as_str();
if all_inside && glyphs_inside {
if seen.insert(name) {
qualified.push(name);
}
} else {
disqualified.insert(name);
}
}
qualified.retain(|name| !disqualified.contains(name));
qualified
}
}
// ── Polygon / lasso selection ─────────────────────────────────────────────
@@ -1743,10 +1803,10 @@ pub fn poly_hit<'a, W: WireSource + ?Sized>(
return indexed_polygon_crossing_hits(wires, poly, view_rot, eye, bounds);
}
// Q: lazy projection — no Vec allocation per wire.
wires
.iter()
.filter_map(|wire| {
if crossing {
let mut out = Vec::new();
let mut seen = HashSet::default();
for wire in wires.iter() {
// Same AABB fallback as `box_hit`: when a wire has no line
// geometry (e.g. greek-LOD text emits only fill_tris) treat the
// AABB rectangle as the hit-test shape so low-LOD text stays
@@ -1768,7 +1828,7 @@ pub fn poly_hit<'a, W: WireSource + ?Sized>(
];
&aabb_pts
} else {
return None;
continue;
};
let low: &[[f32; 3]] = if !wire.points.is_empty() {
@@ -1777,6 +1837,88 @@ pub fn poly_hit<'a, W: WireSource + ?Sized>(
&[]
};
let mut hit = false;
let mut prev: Option<Point> = None;
for (i, &[px, py, pz]) in pts.iter().enumerate() {
if px.is_nan() {
prev = None;
continue;
}
let world = if !wire.points.is_empty() {
wire_point_world(wire, i, view_rot, eye)
} else {
wp64([px, py, pz], low, i)
};
let sp = world_to_screen(world, view_rot, eye, bounds);
if sp.x < 0.0 || sp.x > bounds.width || sp.y < 0.0 || sp.y > bounds.height {
prev = None;
continue;
}
if point_in_polygon(sp, poly) {
hit = true;
}
if !hit {
if let Some(p0) = prev {
if segment_crosses_polygon(p0, sp, poly) {
hit = true;
}
}
}
prev = Some(sp);
}
let mut glyph_crosses = false;
for start in (0..wire.text_verts.len()).step_by(6) {
let Some(screen) = projected_text_quad(wire, start, view_rot, eye, bounds) else {
continue;
};
if [0usize, 3].into_iter().any(|offset| {
triangle_crosses_polygon(
[screen[offset], screen[offset + 1], screen[offset + 2]],
poly,
)
}) {
glyph_crosses = true;
break;
}
}
if (hit || glyph_crosses) && seen.insert(wire.name.as_str()) {
out.push(wire.name.as_str());
}
}
out
} else {
let mut qualified = Vec::new();
let mut disqualified = HashSet::default();
let mut seen = HashSet::default();
for wire in wires.iter() {
let aabb_pts: Vec<[f32; 3]>;
let empty_pts: [[f32; 3]; 0] = [];
let pts: &[[f32; 3]] = if !wire.points.is_empty() {
&wire.points
} else if !wire.text_verts.is_empty() {
&empty_pts
} else if wire.aabb != WireModel::UNBOUNDED_AABB {
let [ax, ay, bx, by] = wire.aabb;
aabb_pts = vec![
[ax, ay, 0.0],
[bx, ay, 0.0],
[bx, by, 0.0],
[ax, by, 0.0],
[ax, ay, 0.0],
];
&aabb_pts
} else {
continue;
};
let low: &[[f32; 3]] = if !wire.points.is_empty() {
&wire.points_low
} else {
&[]
};
let mut all_inside = true;
let mut prev: Option<Point> = None;
@@ -1798,43 +1940,19 @@ pub fn poly_hit<'a, W: WireSource + ?Sized>(
prev = None;
continue;
}
if crossing {
if point_in_polygon(sp, poly) {
hit = true;
}
if !hit {
if let Some(p0) = prev {
if segment_crosses_polygon(p0, sp, poly) {
hit = true;
}
}
}
} else {
if !point_in_polygon(sp, poly) {
all_inside = false;
}
if !point_in_polygon(sp, poly) {
all_inside = false;
}
prev = Some(sp);
}
let glyphs_present = !wire.text_verts.is_empty();
let mut glyph_crosses = false;
let mut glyphs_inside = true;
for start in (0..wire.text_verts.len()).step_by(6) {
let Some(screen) = projected_text_quad(wire, start, view_rot, eye, bounds) else {
continue;
};
if crossing {
if [0usize, 3].into_iter().any(|offset| {
triangle_crosses_polygon(
[screen[offset], screen[offset + 1], screen[offset + 2]],
poly,
)
}) {
glyph_crosses = true;
break;
}
} else if !screen.iter().copied().all(|point| {
if !screen.iter().copied().all(|point| {
point.x >= 0.0
&& point.x <= bounds.width
&& point.y >= 0.0
@@ -1846,18 +1964,24 @@ pub fn poly_hit<'a, W: WireSource + ?Sized>(
}
}
let result = if crossing {
hit || glyph_crosses
} else {
all_inside && glyphs_inside && (prev.is_some() || glyphs_present)
};
if result {
Some(wire.name.as_str())
} else {
None
let has_geom = prev.is_some() || glyphs_present;
if !has_geom {
continue;
}
})
.collect()
let name = wire.name.as_str();
if all_inside && glyphs_inside {
if seen.insert(name) {
qualified.push(name);
}
} else {
disqualified.insert(name);
}
}
qualified.retain(|name| !disqualified.contains(name));
qualified
}
}
// ── Helpers ───────────────────────────────────────────────────────────────
@@ -2511,4 +2635,80 @@ mod aabb_reject_tests {
vec!["479"],
);
}
#[test]
fn window_selection_multi_wire_entity_behavior() {
let bounds = Rectangle {
x: 0.0,
y: 0.0,
width: 200.0,
height: 200.0,
};
// In identity ortho view:
// world (0.0, 0.0) -> screen (100.0, 100.0)
// world (0.1, 0.1) -> screen (110.0, 90.0)
// world (0.8, 0.8) -> screen (180.0, 20.0)
let wire_arc = wire(
"4F",
vec![[0.0, 0.0, 0.0], [0.1, 0.1, 0.0]],
[0.0, 0.0, 0.1, 0.1],
);
let wire_lines = wire(
"4F",
vec![[0.1, 0.1, 0.0], [0.8, 0.8, 0.0]],
[0.1, 0.1, 0.8, 0.8],
);
let wires = [wire_arc, wire_lines];
// Box enclosing ONLY wire_arc (screen 95..115, 85..105):
let box_a = Point::new(95.0, 85.0);
let box_b = Point::new(115.0, 105.0);
let poly_small = [
Point::new(95.0, 85.0),
Point::new(115.0, 85.0),
Point::new(115.0, 105.0),
Point::new(95.0, 105.0),
];
// Crossing mode selects "4F" because wire_arc is inside:
assert_eq!(
box_hit(box_a, box_b, true, &wires, Mat4::IDENTITY, glam::DVec3::ZERO, bounds),
vec!["4F"]
);
assert_eq!(
poly_hit(&poly_small, true, &wires, Mat4::IDENTITY, glam::DVec3::ZERO, bounds),
vec!["4F"]
);
// Window mode MUST NOT select "4F" because wire_lines has a vertex at (0.8, 0.8) -> (180.0, 20.0) outside:
assert_eq!(
box_hit(box_a, box_b, false, &wires, Mat4::IDENTITY, glam::DVec3::ZERO, bounds),
Vec::<&str>::new()
);
assert_eq!(
poly_hit(&poly_small, false, &wires, Mat4::IDENTITY, glam::DVec3::ZERO, bounds),
Vec::<&str>::new()
);
// Big box enclosing BOTH wire_arc and wire_lines (screen 50..190, 10..110):
let big_a = Point::new(50.0, 10.0);
let big_b = Point::new(190.0, 110.0);
let poly_big = [
Point::new(50.0, 10.0),
Point::new(190.0, 10.0),
Point::new(190.0, 110.0),
Point::new(50.0, 110.0),
];
// Window mode now selects "4F" once (deduplicated):
assert_eq!(
box_hit(big_a, big_b, false, &wires, Mat4::IDENTITY, glam::DVec3::ZERO, bounds),
vec!["4F"]
);
assert_eq!(
poly_hit(&poly_big, false, &wires, Mat4::IDENTITY, glam::DVec3::ZERO, bounds),
vec!["4F"]
);
}
}
+1 -1
View File
@@ -237,7 +237,7 @@ pub fn extract_circle_instance_from_geom(
),
_ => return None,
};
if radius <= 0.0 || !radius.is_finite() || !start_angle.is_finite() || !end_angle.is_finite() {
if radius <= 0.0 || !radius.is_finite() || !start_angle.is_finite() || !end_angle.is_finite() || radius > 1e6 {
return None;
}
+1 -1
View File
@@ -218,7 +218,7 @@ pub fn extract_ellipse_instance_from_geom(
let major_len_sq = major_axis[0] * major_axis[0] + major_axis[1] * major_axis[1] + major_axis[2] * major_axis[2];
let norm_len_sq = normal[0] * normal[0] + normal[1] * normal[1] + normal[2] * normal[2];
if major_len_sq <= 1e-12 || !major_len_sq.is_finite()
if major_len_sq <= 1e-12 || !major_len_sq.is_finite() || major_len_sq > 1e12
|| norm_len_sq <= 1e-12 || !norm_len_sq.is_finite()
|| minor_axis_ratio <= 1e-6 || !minor_axis_ratio.is_finite()
|| !start_param.is_finite() || !end_param.is_finite()
+300
View File
@@ -408,3 +408,303 @@ fn late_device_errors_invalidate_uploaded_content_and_targets() {
"the same error must not invalidate twice"
);
}
#[test]
#[ignore = "requires a GPU adapter"]
fn test_selected_circle_arc_ellipse_highlight_overlay() {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let adapter = block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default()))
.expect("GPU adapter");
let (device, queue) = block_on(adapter.request_device(&wgpu::DeviceDescriptor {
required_limits: adapter.limits(),
..Default::default()
}))
.expect("GPU device");
let mut pipeline = Pipeline::new(&device, &queue, wgpu::TextureFormat::Bgra8UnormSrgb);
let depth = rustc_hash::FxHashMap::default();
let mut circle_wire = WireModel::default();
circle_wire.name = "circle_wire".into();
circle_wire.tangent_geoms.push(crate::scene::model::wire_model::TangentGeom::PlanarCircle {
center: [0.0, 0.0, 0.0],
axis_x: [1.0, 0.0, 0.0],
axis_y: [0.0, 1.0, 0.0],
radius: 10.0,
});
let handle = acadrust::Handle::new(100);
let mut selected_handles = rustc_hash::FxHashSet::default();
selected_handles.insert(handle);
let hover_handles = rustc_hash::FxHashSet::default();
let mut index = rustc_hash::FxHashMap::default();
index.insert(handle.value(), vec![0]);
pipeline.wire_handle_index = std::sync::Arc::new(index);
let wires = vec![circle_wire];
pipeline.upload_selected_wires(
&device,
&queue,
&wires,
&selected_handles,
&hover_handles,
&[],
&depth,
Some([0.0, 1.0, 1.0, 1.0]),
);
assert_eq!(pipeline.gpu_selected_circles.len(), 1);
assert_eq!(pipeline.gpu_selected_circles[0].instance_count, 1);
assert!(pipeline.gpu_selected_wires.is_empty());
// Verify classic highlight mode (SELECTIONEFFECT 0 / selected_tint: None)
pipeline.upload_selected_wires(
&device,
&queue,
&wires,
&selected_handles,
&hover_handles,
&[],
&depth,
None,
);
assert_eq!(pipeline.gpu_selected_circles.len(), 1);
assert_eq!(pipeline.gpu_selected_circles[0].instance_count, 1);
// Verify hover highlight
let mut circle_hover = rustc_hash::FxHashSet::default();
circle_hover.insert(handle);
let empty_selected = rustc_hash::FxHashSet::default();
pipeline.upload_selected_wires(
&device,
&queue,
&wires,
&empty_selected,
&circle_hover,
&[],
&depth,
None,
);
assert_eq!(pipeline.gpu_selected_circles.len(), 1);
assert_eq!(pipeline.gpu_selected_circles[0].instance_count, 1);
pipeline.ensure_depth_texture(&device, iced::Size::new(512, 512));
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("test_target"),
size: wgpu::Extent3d {
width: 512,
height: 512,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Bgra8UnormSrgb,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
view_formats: &[],
});
let target = texture.create_view(&wgpu::TextureViewDescriptor::default());
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("test_encoder"),
});
pipeline.render(
&mut encoder,
&target,
iced::Size::new(512, 512),
iced::Rectangle { x: 0, y: 0, width: 512, height: 512 },
[0.0, 0.0, 0.0, 1.0],
false,
false,
false,
);
queue.submit(Some(encoder.finish()));
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
}
#[test]
#[ignore = "requires a GPU adapter"]
fn test_selected_ellipse_overlay() {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let adapter = block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default()))
.expect("GPU adapter");
let (device, queue) = block_on(adapter.request_device(&wgpu::DeviceDescriptor {
required_limits: adapter.limits(),
..Default::default()
}))
.expect("GPU device");
let mut pipeline = Pipeline::new(&device, &queue, wgpu::TextureFormat::Bgra8UnormSrgb);
let depth = rustc_hash::FxHashMap::default();
let mut ellipse_wire = WireModel::default();
ellipse_wire.name = "ellipse_wire".into();
ellipse_wire.tangent_geoms.push(crate::scene::model::wire_model::TangentGeom::PlanarEllipse {
center: [0.0, 0.0, 0.0],
major_axis: [10.0, 0.0, 0.0],
normal: [0.0, 0.0, 1.0],
minor_axis_ratio: 0.5,
start_param: 0.0,
end_param: std::f64::consts::TAU,
});
let handle = acadrust::Handle::new(200);
let mut selected_handles = rustc_hash::FxHashSet::default();
selected_handles.insert(handle);
let hover_handles = rustc_hash::FxHashSet::default();
let mut index = rustc_hash::FxHashMap::default();
index.insert(handle.value(), vec![0]);
pipeline.wire_handle_index = std::sync::Arc::new(index);
let wires = vec![ellipse_wire];
// Standard tint
pipeline.upload_selected_wires(
&device,
&queue,
&wires,
&selected_handles,
&hover_handles,
&[],
&depth,
Some([0.0, 1.0, 1.0, 1.0]),
);
assert_eq!(pipeline.gpu_selected_ellipses.len(), 1);
assert_eq!(pipeline.gpu_selected_ellipses[0].instance_count, 1);
assert!(pipeline.gpu_selected_wires.is_empty());
// Classic highlight mode (SELECTIONEFFECT 0 / selected_tint: None)
pipeline.upload_selected_wires(
&device,
&queue,
&wires,
&selected_handles,
&hover_handles,
&[],
&depth,
None,
);
assert_eq!(pipeline.gpu_selected_ellipses.len(), 1);
assert_eq!(pipeline.gpu_selected_ellipses[0].instance_count, 1);
// Hover highlight
let mut ellipse_hover = rustc_hash::FxHashSet::default();
ellipse_hover.insert(handle);
let empty_selected = rustc_hash::FxHashSet::default();
pipeline.upload_selected_wires(
&device,
&queue,
&wires,
&empty_selected,
&ellipse_hover,
&[],
&depth,
None,
);
assert_eq!(pipeline.gpu_selected_ellipses.len(), 1);
assert_eq!(pipeline.gpu_selected_ellipses[0].instance_count, 1);
pipeline.ensure_depth_texture(&device, iced::Size::new(512, 512));
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("test_target_ellipse"),
size: wgpu::Extent3d {
width: 512,
height: 512,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Bgra8UnormSrgb,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
view_formats: &[],
});
let target = texture.create_view(&wgpu::TextureViewDescriptor::default());
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("test_encoder_ellipse"),
});
pipeline.render(
&mut encoder,
&target,
iced::Size::new(512, 512),
iced::Rectangle { x: 0, y: 0, width: 512, height: 512 },
[0.0, 0.0, 0.0, 1.0],
false,
false,
false,
);
queue.submit(Some(encoder.finish()));
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
}
#[test]
#[ignore = "requires a GPU adapter"]
fn test_pline_arc_switch_preview_and_render() {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let adapter = block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default()))
.expect("GPU adapter");
let (device, queue) = block_on(adapter.request_device(&wgpu::DeviceDescriptor {
required_limits: adapter.limits(),
..Default::default()
}))
.expect("GPU device");
let mut pipeline = Pipeline::new(&device, &queue, wgpu::TextureFormat::Bgra8UnormSrgb);
let depth = rustc_hash::FxHashMap::default();
use crate::command::CadCommand;
let mut cmd = crate::modules::draw::draw::polyline::PlineCommand::new();
cmd.on_point(glam::DVec3::new(0.0, 0.0, 0.0));
let _res = cmd.on_point(glam::DVec3::new(10.0, 0.0, 0.0));
cmd.set_live_handle(acadrust::Handle::new(1));
cmd.on_text_input("A");
let test_points = [
glam::DVec3::new(10.0, 0.0, 0.0),
glam::DVec3::new(10.0000001, 0.0, 0.0),
glam::DVec3::new(10.0, 0.0000001, 0.0),
glam::DVec3::new(10.0, -0.0000001, 0.0),
glam::DVec3::new(9.9999999, 0.0, 0.0),
glam::DVec3::new(15.0, 5.0, 0.0),
glam::DVec3::new(10.0, 10.0, 0.0),
glam::DVec3::new(5.0, 5.0, 0.0),
glam::DVec3::new(0.0, 0.0, 0.0),
];
pipeline.ensure_depth_texture(&device, iced::Size::new(512, 512));
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("test_target"),
size: wgpu::Extent3d {
width: 512,
height: 512,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Bgra8UnormSrgb,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
view_formats: &[],
});
let target = texture.create_view(&wgpu::TextureViewDescriptor::default());
for pt in test_points {
let wire = cmd.on_mouse_move(pt);
if let Some(w) = wire {
pipeline.upload_preview_wires(&device, &queue, &[w], &depth);
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("test_encoder"),
});
pipeline.render(
&mut encoder,
&target,
iced::Size::new(512, 512),
iced::Rectangle { x: 0, y: 0, width: 512, height: 512 },
[0.0, 0.0, 0.0, 1.0],
false,
false,
false,
);
queue.submit(Some(encoder.finish()));
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
}
}
}
+96 -16
View File
@@ -251,6 +251,11 @@ pub struct Pipeline {
pub(crate) wire_arena_fallback_handles: rustc_hash::FxHashSet<acadrust::Handle>,
/// The Model content id both arenas currently mirror (`u64::MAX` = none).
pub(crate) wire_arena_id: u64,
/// Handles that contributed to this slot's retained analytical uploads.
pub(crate) partition_contributors: rustc_hash::FxHashSet<acadrust::Handle>,
/// The draw-depth generation those uploads baked. A full depth rebuild
/// reassigns every label, so they stop being reusable when it moves.
pub(crate) partition_depth_generation: u64,
/// Last content/camera/viewport tuple used to derive visible instance
/// ranges from the resident arena.
pub(crate) wire_cull_key: (u64, u64, u32, u32),
@@ -273,6 +278,8 @@ pub struct Pipeline {
/// frame they are present (small), drawn on top of the base wire pass — so
/// a live drag never re-uploads the resident base buffer.
gpu_preview_wires: Vec<WireGpu>,
gpu_preview_circles: Vec<CircleGpu>,
gpu_preview_ellipses: Vec<EllipseGpu>,
/// Wipeout masks — solid fills rendered after wires in a separate pass via
/// the legacy per-primitive `WipeoutGpu` renderer.
gpu_wipeouts: Vec<WipeoutGpu>,
@@ -2336,6 +2343,8 @@ impl Pipeline {
wire_arena_fallback_kind: None,
wire_arena_fallback_handles: rustc_hash::FxHashSet::default(),
wire_arena_id: u64::MAX,
partition_contributors: rustc_hash::FxHashSet::default(),
partition_depth_generation: u64::MAX,
wire_cull_key: (u64::MAX, u64::MAX, 0, 0),
hatch_lod_key: (usize::MAX, u64::MAX, 0, 0, false),
wipeout_lod_key: (usize::MAX, u64::MAX, 0, 0, false),
@@ -2346,6 +2355,8 @@ impl Pipeline {
gpu_selected_ellipses: vec![],
gpu_selected_block_wires: vec![],
gpu_preview_wires: vec![],
gpu_preview_circles: vec![],
gpu_preview_ellipses: vec![],
gpu_wipeouts: vec![],
wipeout_skip_flags: vec![],
gpu_images: vec![],
@@ -2630,6 +2641,9 @@ impl Pipeline {
slots.sort_unstable();
for &i in &slots {
if let Some(w) = wires.get(i as usize) {
if !w.display_visible {
continue;
}
selected_wires.push(w);
}
}
@@ -2641,12 +2655,18 @@ impl Pipeline {
slots.sort_unstable();
for &i in &slots {
if let Some(w) = wires.get(i as usize) {
if !w.display_visible {
continue;
}
hover_wires.push(w);
}
}
}
}
for wire in annotation_context_wires {
if !wire.display_visible {
continue;
}
if wire.selected {
selected_wires.push(wire);
} else {
@@ -2654,13 +2674,12 @@ impl Pipeline {
}
}
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 {
if let Some(tint) = selected_tint {
inst.color = tint;
}
inst.color = circle_sel_tint;
selected_circles.push(inst);
}
}
@@ -2686,13 +2705,12 @@ impl Pipeline {
};
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 {
if let Some(tint) = selected_tint {
inst.color = tint;
}
inst.color = ellipse_sel_tint;
selected_ellipses.push(inst);
}
}
@@ -2940,11 +2958,47 @@ impl Pipeline {
wires: &[WireModel],
depth_map: &rustc_hash::FxHashMap<u64, [f32; 2]>,
) {
self.gpu_preview_wires = if wires.is_empty() {
vec![]
if wires.is_empty() {
self.gpu_preview_wires.clear();
self.gpu_preview_circles.clear();
self.gpu_preview_ellipses.clear();
} else {
WireGpu::from_run(device, queue, wires, depth_map, false, self.wire_const_bgl.as_ref())
};
let partitioned = wire_arena::partition_wires(wires, depth_map);
let mut non_analytical = partitioned.regular;
non_analytical.extend(partitioned.mesh);
self.gpu_preview_wires = if non_analytical.is_empty() {
vec![]
} else {
WireGpu::from_run_refs(
device,
queue,
&non_analytical,
depth_map,
false,
self.wire_const_bgl.as_ref(),
)
};
self.gpu_preview_circles = if partitioned.circle_instances.is_empty() {
vec![]
} else {
vec![CircleGpu::from_instances(
device,
queue,
"preview.circles",
&partitioned.circle_instances,
)]
};
self.gpu_preview_ellipses = if partitioned.ellipse_instances.is_empty() {
vec![]
} else {
vec![EllipseGpu::from_instances(
device,
queue,
"preview.ellipses",
&partitioned.ellipse_instances,
)]
};
}
}
/// Upload the live grip-drag / command-preview SDF glyph quads. Re-uploaded
@@ -4611,6 +4665,24 @@ impl Pipeline {
}
}
}
if self.gpu_preview_circles.iter().any(|cg| cg.instance_count > 0) {
pass.set_pipeline(&self.circle_xray_pipeline);
for cg in &self.gpu_preview_circles {
if cg.instance_count > 0 {
pass.set_vertex_buffer(0, cg.instance_buffer.slice(..));
pass.draw(0..6, 0..cg.instance_count);
}
}
}
if self.gpu_preview_ellipses.iter().any(|eg| eg.instance_count > 0) {
pass.set_pipeline(&self.ellipse_xray_pipeline);
for eg in &self.gpu_preview_ellipses {
if eg.instance_count > 0 {
pass.set_vertex_buffer(0, eg.instance_buffer.slice(..));
pass.draw(0..6, 0..eg.instance_count);
}
}
}
}
// ── Pass 5c: SDF text quads (drawn over wires) ────────────────────
@@ -4721,6 +4793,8 @@ impl Pipeline {
|| !self.block_text_highlight_gpu.is_empty();
if !self.gpu_selected_wires.is_empty()
|| !self.gpu_selected_block_wires.is_empty()
|| !self.gpu_selected_circles.is_empty()
|| !self.gpu_selected_ellipses.is_empty()
|| have_text_highlight
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
@@ -4787,6 +4861,7 @@ impl Pipeline {
} else {
&self.circle_xray_pipeline
});
pass.set_bind_group(0, &self.uniform_bind_group, &[]);
for cg in &self.gpu_selected_circles {
if cg.instance_count > 0 {
pass.set_vertex_buffer(0, cg.instance_buffer.slice(..));
@@ -4800,6 +4875,7 @@ impl Pipeline {
} else {
&self.ellipse_xray_pipeline
});
pass.set_bind_group(0, &self.uniform_bind_group, &[]);
for eg in &self.gpu_selected_ellipses {
if eg.instance_count > 0 {
pass.set_vertex_buffer(0, eg.instance_buffer.slice(..));
@@ -4892,12 +4968,7 @@ impl Pipeline {
}
}
/// Forget every cache key that describes this slot's uploaded content.
///
/// Shared by slot reuse and by [`Self::release_heavy_resources`] so the two
/// cannot disagree about what "this slot holds nothing you can trust"
/// means. Keys only — the buffers themselves are dropped by the caller that
/// wants the memory back.
/// Reset every cache key that describes this slot's uploaded content.
pub(crate) fn forget_cached_keys(&mut self) {
self.cached_epoch = (u64::MAX, u64::MAX, u64::MAX);
self.cached_wire_id = u64::MAX;
@@ -4920,6 +4991,9 @@ impl Pipeline {
self.silhouette_key = (usize::MAX, u64::MAX, [u32::MAX; 3], false);
self.silhouette_source_key = (usize::MAX, usize::MAX, u64::MAX);
self.render_sig = u64::MAX;
// The retained-upload record is part of the slot's cached state.
self.partition_contributors.clear();
self.partition_depth_generation = u64::MAX;
}
/// Catch errors delivered after the previous upload/submit completed.
@@ -4933,6 +5007,8 @@ impl Pipeline {
self.wire_arena = None;
self.wire_arena_mesh = None;
self.wire_arena_id = u64::MAX;
self.partition_contributors.clear();
self.partition_depth_generation = u64::MAX;
self.alloc_size = Size::new(0, 0);
self.shadow_full = None;
self.background_source_id = usize::MAX;
@@ -4964,12 +5040,16 @@ impl Pipeline {
self.wire_arena_fallback_kind = None;
self.wire_arena_fallback_handles.clear();
self.wire_arena_id = u64::MAX;
self.partition_contributors.clear();
self.partition_depth_generation = u64::MAX;
self.gpu_selected_wires.clear();
self.gpu_selected_block_wires.clear();
self.gpu_selected_circles.clear();
self.gpu_selected_ellipses.clear();
self.gpu_preview_wires.clear();
self.gpu_preview_circles.clear();
self.gpu_preview_ellipses.clear();
self.hatch_gpu.clear();
self.gpu_wipeouts.clear();
self.wipeout_skip_flags.clear();
+66 -1
View File
@@ -174,6 +174,16 @@ pub struct PartitionedWires<'a> {
pub instanced: Vec<&'a WireModel>,
pub circle_instances: Vec<super::circle_gpu::CircleInstance>,
pub ellipse_instances: Vec<super::ellipse_gpu::EllipseInstance>,
/// Handles that contributed to the retained analytical uploads.
pub contributors: rustc_hash::FxHashSet<Handle>,
}
/// Whether this wire contributes to an upload retained across arena patches.
pub fn feeds_analytical_uploads(wire: &WireModel) -> bool {
matches!(
classify_wire(wire),
WireKind::Block | WireKind::Circle | WireKind::Ellipse
)
}
/// Single-pass classification and extraction of all viewport wire categories.
@@ -190,26 +200,36 @@ pub fn partition_wires<'a>(
let mut instanced = Vec::new();
let mut circle_instances = Vec::new();
let mut ellipse_instances = Vec::new();
let mut contributors: rustc_hash::FxHashSet<Handle> = rustc_hash::FxHashSet::default();
let note = |wire: &WireModel, set: &mut rustc_hash::FxHashSet<Handle>| {
if let Some(handle) = handle_of(wire) {
set.insert(handle);
}
};
for wire in wires {
if !wire.display_visible {
continue;
}
if wire.render_instance.is_some() {
note(wire, &mut contributors);
instanced.push(wire);
continue;
}
let depth = super::wire_gpu::wire_draw_depth(wire, depth_map);
// Resolve draw depth only for wires eligible for analytical extraction.
if !wire.tangent_geoms.is_empty()
&& wire.fill_tris.is_empty()
&& wire.pick_tris.is_empty()
&& wire.text_verts.is_empty()
{
let depth = super::wire_gpu::wire_draw_depth(wire, depth_map);
if let Some(insts) = super::circle_gpu::extract_circle_instances(wire, depth) {
note(wire, &mut contributors);
circle_instances.extend(insts);
continue;
}
if let Some(insts) = super::ellipse_gpu::extract_ellipse_instances(wire, depth) {
note(wire, &mut contributors);
ellipse_instances.extend(insts);
continue;
}
@@ -229,6 +249,7 @@ pub fn partition_wires<'a>(
instanced,
circle_instances,
ellipse_instances,
contributors,
}
}
@@ -1644,4 +1665,48 @@ mod tests {
assert!(blank_const().pattern_length < 0.0);
assert!(blank_packed_instance().pattern_length < 0.0);
}
#[test]
fn partition_wires_partitions_preview_analytical_curves() {
use crate::scene::model::wire_model::TangentGeom;
let mut circle_preview = WireModel::default();
circle_preview.tangent_geoms.push(TangentGeom::PlanarCircle {
center: [1.0, 2.0, 3.0],
axis_x: [1.0, 0.0, 0.0],
axis_y: [0.0, 1.0, 0.0],
radius: 10.0,
});
let mut arc_preview = WireModel::default();
arc_preview.tangent_geoms.push(TangentGeom::Arc {
center: [4.0, 5.0, 6.0],
axis_x: [1.0, 0.0, 0.0],
axis_y: [0.0, 1.0, 0.0],
radius: 8.0,
start_angle: 0.1,
end_angle: 2.0,
});
let mut ellipse_preview = WireModel::default();
ellipse_preview.tangent_geoms.push(TangentGeom::PlanarEllipse {
center: [7.0, 8.0, 9.0],
major_axis: [5.0, 0.0, 0.0],
normal: [0.0, 0.0, 1.0],
minor_axis_ratio: 0.6,
start_param: 0.0,
end_param: std::f64::consts::TAU,
});
let mut regular_line = WireModel::default();
regular_line.points.push([0.0, 0.0, 0.0]);
regular_line.points.push([1.0, 1.0, 1.0]);
let wires = vec![circle_preview, arc_preview, ellipse_preview, regular_line];
let depth_map = rustc_hash::FxHashMap::default();
let partitioned = partition_wires(&wires, &depth_map);
assert_eq!(partitioned.circle_instances.len(), 2);
assert_eq!(partitioned.ellipse_instances.len(), 1);
assert_eq!(partitioned.regular.len(), 1);
}
}
+114 -11
View File
@@ -360,19 +360,62 @@ impl Scene {
self.selected.len()
}
/// Sorted entity types in the current layout, cached until geometry or layout changes.
/// Entity type names in the current layout for the selection-filter menu.
/// Pure additions are folded into the cached set; other changes rebuild it.
pub fn entity_type_names_in_layout(&self) -> std::sync::Arc<Vec<String>> {
use crate::entities::traits::entity_type_name;
let block = self.current_layout_block_handle();
let mut cached_epoch = None;
{
let cache = self.layout_type_names_cache.borrow();
if let Some((epoch, cached_block, names)) = cache.as_ref() {
if *epoch == self.geometry_epoch && *cached_block == block {
return std::sync::Arc::clone(names);
if let Some((epoch, cached_block, _, names)) = cache.as_ref() {
if *cached_block == block {
if *epoch == self.geometry_epoch {
return std::sync::Arc::clone(names);
}
cached_epoch = Some(*epoch);
}
}
}
let mut names: std::collections::BTreeSet<&str> =
// Incremental: fold the changes since the cached epoch into the set.
if let Some(since) = cached_epoch {
if let Some(deltas) = self.replay_since(since) {
if deltas
.iter()
.all(|(_, kind)| *kind == ChangeKind::Added)
{
let mut cache = self.layout_type_names_cache.borrow_mut();
if let Some((epoch, _, present, names)) = cache.as_mut() {
let mut added = false;
for (handle, _) in &deltas {
if let Some(entity) = self.document.get_entity(*handle) {
if entity.common().owner_handle == block {
// `contains` first: the common case is a
// type already present, and that path must
// not allocate.
let name = entity_type_name(entity);
if !present.contains(name) {
present.insert(name.to_string());
added = true;
}
}
}
}
// Only rebuild the list when the set actually moved;
// otherwise the existing `Arc` is still the answer.
if added {
*names =
std::sync::Arc::new(present.iter().cloned().collect());
}
*epoch = self.geometry_epoch;
return std::sync::Arc::clone(names);
}
}
}
}
let mut present: std::collections::BTreeSet<String> =
std::collections::BTreeSet::new();
if let Some(record) = self
.document
@@ -382,15 +425,21 @@ impl Scene {
{
for &handle in &record.entity_handles {
if let Some(entity) = self.document.get_entity(handle) {
names.insert(entity_type_name(entity));
let name = entity_type_name(entity);
if !present.contains(name) {
present.insert(name.to_string());
}
}
}
}
let names: std::sync::Arc<Vec<String>> = std::sync::Arc::new(
names.into_iter().map(str::to_string).collect(),
);
*self.layout_type_names_cache.borrow_mut() =
Some((self.geometry_epoch, block, std::sync::Arc::clone(&names)));
let names: std::sync::Arc<Vec<String>> =
std::sync::Arc::new(present.iter().cloned().collect());
*self.layout_type_names_cache.borrow_mut() = Some((
self.geometry_epoch,
block,
present,
std::sync::Arc::clone(&names),
));
names
}
@@ -954,6 +1003,60 @@ impl Scene {
mod tests {
use super::*;
#[test]
fn adding_a_type_already_present_reuses_the_list() {
use acadrust::entities::{Circle, EntityType, Line};
use acadrust::types::Vector3;
use std::sync::Arc;
let line = || {
EntityType::Line(Line::from_points(
Vector3::new(0.0, 0.0, 0.0),
Vector3::new(1.0, 0.0, 0.0),
))
};
let mut scene = Scene::new();
scene.add_entity(line());
let first = scene.entity_type_names_in_layout();
scene.add_entity(line());
let second = scene.entity_type_names_in_layout();
assert!(
Arc::ptr_eq(&first, &second),
"a second line changes no type name, so the list must be reused",
);
scene.add_entity(EntityType::Circle(Circle::new()));
let third = scene.entity_type_names_in_layout();
assert_eq!(third.as_slice(), ["Circle", "Line"], "a new type must appear");
// The incremental answer has to be the answer a full walk gives.
scene.layout_type_names_cache.borrow_mut().take();
assert_eq!(
scene.entity_type_names_in_layout().as_slice(),
third.as_slice(),
"folding edits in must match rebuilding from scratch",
);
}
#[test]
fn changing_an_entity_type_rebuilds_the_type_names() {
use acadrust::entities::{Circle, EntityType, Line};
use acadrust::types::Vector3;
let mut scene = Scene::new();
let handle = scene.add_entity(EntityType::Line(Line::from_points(
Vector3::new(0.0, 0.0, 0.0),
Vector3::new(1.0, 0.0, 0.0),
)));
assert_eq!(scene.entity_type_names_in_layout().as_slice(), ["Line"]);
let mut circle = Circle::new();
circle.common.handle = handle;
assert!(scene.update_entity(EntityType::Circle(circle)));
assert_eq!(scene.entity_type_names_in_layout().as_slice(), ["Circle"]);
}
#[test]
fn layout_type_cache_reuses_and_invalidates_on_edits_undo_and_layout() {
use acadrust::entities::{Circle, EntityType, Line};
+83 -26
View File
@@ -149,6 +149,9 @@ pub struct ViewportData {
/// by the wire / face3d pipelines as a clip-z bias. WireModels carry no
/// depth field (84 construction sites); the bias is looked up by handle
/// at GPU-upload time from this map instead.
/// See `Scene::draw_depth_generation`. Cached uploads that bake a depth are
/// only reusable while this is unchanged.
pub(in crate::scene) draw_depth_generation: u64,
pub(in crate::scene) draw_depths:
std::sync::Weak<rustc_hash::FxHashMap<u64, [f32; 2]>>,
pub(in crate::scene) hatches: Arc<Vec<HatchModel>>,
@@ -839,31 +842,83 @@ impl shader::Primitive for Primitive {
gpus.extend(arena.wire_gpus());
}
inner.gpu_wires = std::sync::Arc::new(gpus);
let partitioned =
wire_arena::partition_wires(&vp_wires, &draw_depths);
inner.gpu_block_wires = std::sync::Arc::new(
inner.upload_block_wires(
device,
queue,
&partitioned.instanced,
&draw_depths,
&mut pipeline.block_geometry,
),
);
inner.gpu_circles = std::sync::Arc::new(
inner.upload_circles_from_instances(
device,
queue,
&partitioned.circle_instances,
),
);
inner.gpu_ellipses = std::sync::Arc::new(
inner.upload_ellipses_from_instances(
device,
queue,
&partitioned.ellipse_instances,
),
);
// Retain analytical uploads when a patch changes neither
// their contributors nor their baked draw depths.
let analytical_untouched = _patched
&& inner.partition_depth_generation == vp.draw_depth_generation
&& patch.is_some_and(|patch| {
patch.changes.iter().all(|(handle, _)| {
!inner.partition_contributors.contains(handle)
&& !patch.runs.get(handle).is_some_and(|run| {
run.iter()
.any(wire_arena::feeds_analytical_uploads)
})
})
});
let t_part = _perf.then(iced::time::Instant::now);
let partitioned = (!analytical_untouched)
.then(|| wire_arena::partition_wires(&vp_wires, &draw_depths));
let part_ms = t_part
.map_or(0.0, |t| t.elapsed().as_secs_f64() * 1000.0);
let t_blk = _perf.then(iced::time::Instant::now);
let mut blk_ms = 0.0f64;
let mut curve_ms = 0.0f64;
// `None` means the three uploads this slot holds are
// still the answer, so they are left alone.
if let Some(partitioned) = &partitioned {
inner.gpu_block_wires = std::sync::Arc::new(
inner.upload_block_wires(
device,
queue,
&partitioned.instanced,
&draw_depths,
&mut pipeline.block_geometry,
),
);
blk_ms = t_blk
.map_or(0.0, |t| t.elapsed().as_secs_f64() * 1000.0);
let t_curve = _perf.then(iced::time::Instant::now);
inner.gpu_circles = std::sync::Arc::new(
inner.upload_circles_from_instances(
device,
queue,
&partitioned.circle_instances,
),
);
inner.gpu_ellipses = std::sync::Arc::new(
inner.upload_ellipses_from_instances(
device,
queue,
&partitioned.ellipse_instances,
),
);
inner
.partition_contributors
.clone_from(&partitioned.contributors);
inner.partition_depth_generation = vp.draw_depth_generation;
curve_ms = t_curve
.map_or(0.0, |t| t.elapsed().as_secs_f64() * 1000.0);
}
if _perf {
// Report counts only when partitioning ran.
match &partitioned {
Some(partitioned) => crate::perf_record!(
"[perf] arena-post partition={part_ms:.1}ms \
blocks={blk_ms:.1}ms curves={curve_ms:.1}ms skipped=false wires={} instanced={} \
circles={} ellipses={}",
vp_wires.len(),
partitioned.instanced.len(),
partitioned.circle_instances.len(),
partitioned.ellipse_instances.len(),
),
None => crate::perf_record!(
"[perf] arena-post skipped=true wires={} \
retained_contributors={}",
vp_wires.len(),
inner.partition_contributors.len(),
),
}
}
if _patched {
wire_arena::patch_handle_index(
&mut inner.wire_handle_index,
@@ -1216,8 +1271,9 @@ impl shader::Primitive for Primitive {
let prepare_ms = nav_prepare_started.elapsed().as_secs_f64() * 1000.0;
if let Some(sample) = self.nav_perf {
crate::perf::record(format_args!(
"[perf] nav-prepare op={} space={} mode={} input={:.2}ms build={:.2}ms prepare={:.2}ms elapsed={:.2}ms viewports={}",
"[perf] nav-prepare op={} cause={} space={} mode={} input={:.2}ms build={:.2}ms prepare={:.2}ms elapsed={:.2}ms viewports={}",
sample.op.label(),
sample.cause,
sample.space,
sample.mode,
sample.input_ms,
@@ -4238,6 +4294,7 @@ impl Scene {
face3d_wires,
text_verts,
preview_text_verts,
draw_depth_generation: self.draw_depth_generation(),
draw_depths: Arc::downgrade(&draw_depths),
hatches,
wipeout_hatches,