forked from 1b-agent/opencadstudio-upstream
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:
commit
7913f8e2da
68 files changed
+5496
-548
No files matched your search
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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"]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user