forked from 1b-agent/opencadstudio-upstream
Merge remote-tracking branch 'origin/main' into plugin/host-model-api
# Conflicts: # crates/ocs_plugin_api/src/process/v4.rs # src/app/update/mod.rs
This commit is contained in:
commit
8648225ac5
309 files changed
+81166
-4157
No files matched your search
+37
-10
@@ -203,17 +203,44 @@ pub(crate) fn ring_source_handles(
|
||||
}
|
||||
|
||||
fn curve_forward(curve: &Curve, start: [f64; 2], next: [f64; 2]) -> bool {
|
||||
let a = curve.parameter_at(start);
|
||||
let b = curve.parameter_at(next);
|
||||
let mut delta = b - a;
|
||||
if curve.is_closed() {
|
||||
if delta > 0.5 {
|
||||
delta -= 1.0;
|
||||
} else if delta < -0.5 {
|
||||
delta += 1.0;
|
||||
// For circular geometry, determine direction directly from the
|
||||
// geometric traversal of the detected boundary. This avoids parameter
|
||||
// wrap-around choosing the major arc instead of the minor arc.
|
||||
match curve {
|
||||
Curve::Circle(circle) => {
|
||||
let ax = start[0] - circle.centre[0];
|
||||
let ay = start[1] - circle.centre[1];
|
||||
let bx = next[0] - circle.centre[0];
|
||||
let by = next[1] - circle.centre[1];
|
||||
|
||||
ax * by - ay * bx >= 0.0
|
||||
}
|
||||
|
||||
Curve::Arc(arc) => {
|
||||
let ax = start[0] - arc.centre[0];
|
||||
let ay = start[1] - arc.centre[1];
|
||||
let bx = next[0] - arc.centre[0];
|
||||
let by = next[1] - arc.centre[1];
|
||||
|
||||
ax * by - ay * bx >= 0.0
|
||||
}
|
||||
|
||||
_ => {
|
||||
let a = curve.parameter_at(start);
|
||||
let b = curve.parameter_at(next);
|
||||
let mut delta = b - a;
|
||||
|
||||
if curve.is_closed() {
|
||||
if delta > 0.5 {
|
||||
delta -= 1.0;
|
||||
} else if delta < -0.5 {
|
||||
delta += 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
delta >= 0.0
|
||||
}
|
||||
}
|
||||
delta >= 0.0
|
||||
}
|
||||
|
||||
fn stored_arc_angles(start: f64, end: f64, counter_clockwise: bool, whole: bool) -> (f64, f64) {
|
||||
@@ -350,7 +377,7 @@ pub(crate) fn exact_hatch_paths(
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(ring_index, ring)| {
|
||||
let (points, curves) = refined_boundary_ring(ring, sources, tolerance);
|
||||
let (points, curves) = refined_boundary_ring(ring, sources, tolerance);
|
||||
let count = points.len();
|
||||
if count < 3 {
|
||||
return None;
|
||||
|
||||
Vendored
+212
-9
@@ -29,6 +29,11 @@ use crate::scene::model::wire_model::{
|
||||
};
|
||||
|
||||
const MAX_NESTING_DEPTH: usize = 32;
|
||||
/// Small line-only nested definitions cost more as separate instance records
|
||||
/// than as part of their parent's existing style batches. Folding these tiny
|
||||
/// subtrees at expansion time keeps exact geometry while avoiding one
|
||||
/// heavyweight `WireModel` per leaf occurrence.
|
||||
const INLINE_NESTED_POINT_BUDGET: usize = 128;
|
||||
/// Skip wires whose world-AABB projects to fewer than this many pixels in
|
||||
/// the active view. Picks up tiny detail at zoom-out so the tessellator
|
||||
/// doesn't waste time on geometry that contributes a few sub-pixel marks
|
||||
@@ -150,6 +155,9 @@ pub struct BlockMetrics {
|
||||
pub struct BlockDefn {
|
||||
pub subs: Vec<LocalSub>,
|
||||
pub metrics: BlockMetrics,
|
||||
/// Point cost when this whole subtree is safe and cheap to fold into its
|
||||
/// parent's batches. `None` keeps the regular instanced expansion path.
|
||||
inline_point_cost: Option<usize>,
|
||||
/// Raw entity count of the source block record (`entity_handles.len()`).
|
||||
/// Divisor for nested depth composition: a nested insert's children get a
|
||||
/// sub-range of `parent_scale / (child_count + 1)`, shared with the scene
|
||||
@@ -317,22 +325,60 @@ impl BlockCache {
|
||||
use crate::par::prelude::*;
|
||||
// Definitions are complete, so each recursive read can run independently.
|
||||
let this: &Self = self;
|
||||
let resolved: Vec<(&String, BlockMetrics)> = names
|
||||
let resolved: Vec<(&String, BlockMetrics, Option<usize>)> = names
|
||||
.par_iter()
|
||||
.map(|name| {
|
||||
let mut visited: Vec<String> = Vec::new();
|
||||
(name, this.defn_metrics_recursive(name, &mut visited))
|
||||
let metrics = this.defn_metrics_recursive(name, &mut visited);
|
||||
let inline_point_cost =
|
||||
this.defn_inline_point_cost_recursive(name, &mut Vec::new());
|
||||
(name, metrics, inline_point_cost)
|
||||
})
|
||||
.collect();
|
||||
for (name, metrics) in resolved {
|
||||
for (name, metrics, inline_point_cost) in resolved {
|
||||
if let Some(defn_arc) = self.defns.get_mut(name) {
|
||||
let mut defn = (**defn_arc).clone();
|
||||
defn.metrics = metrics;
|
||||
defn.inline_point_cost = inline_point_cost;
|
||||
*defn_arc = Arc::new(defn);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn defn_inline_point_cost_recursive(
|
||||
&self,
|
||||
block_name: &str,
|
||||
visited: &mut Vec<String>,
|
||||
) -> Option<usize> {
|
||||
if visited.iter().any(|name| name == block_name) {
|
||||
return None;
|
||||
}
|
||||
let defn = self.defns.get(block_name)?;
|
||||
visited.push(block_name.to_string());
|
||||
let result = (|| {
|
||||
let mut cost = 0usize;
|
||||
for sub in &defn.subs {
|
||||
let sub_cost = match sub {
|
||||
LocalSub::Wire(wire) => inline_wire_point_cost(wire)?,
|
||||
LocalSub::Nested(nested)
|
||||
if nested.clip_poly.is_none() && nested.attachments.is_empty() =>
|
||||
{
|
||||
self.defn_inline_point_cost_recursive(&nested.block_name, visited)?
|
||||
.checked_mul(nested.instance_offsets.len())?
|
||||
}
|
||||
LocalSub::Nested(_) => return None,
|
||||
};
|
||||
cost = cost.checked_add(sub_cost)?;
|
||||
if cost > INLINE_NESTED_POINT_BUDGET {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some(cost)
|
||||
})();
|
||||
visited.pop();
|
||||
result
|
||||
}
|
||||
|
||||
fn defn_metrics_recursive(
|
||||
&self,
|
||||
block_name: &str,
|
||||
@@ -585,10 +631,27 @@ fn build_defn(
|
||||
BlockDefn {
|
||||
subs,
|
||||
metrics: BlockMetrics::default(),
|
||||
inline_point_cost: None,
|
||||
child_count: br.entity_handles.len(),
|
||||
}
|
||||
}
|
||||
|
||||
fn inline_wire_point_cost(wire: &LocalWire) -> Option<usize> {
|
||||
let line_tangents_only = wire
|
||||
.tangent_geoms
|
||||
.iter()
|
||||
.all(|tangent| matches!(tangent, TangentGeom::Line { .. }));
|
||||
(line_tangents_only
|
||||
&& !wire.is_point
|
||||
&& wire.point_marker.is_none()
|
||||
&& wire.text_verts.is_empty()
|
||||
&& wire.fill_tris.is_empty()
|
||||
&& wire.pick_tris.is_empty()
|
||||
&& wire.pattern_stations.is_empty()
|
||||
&& wire.world_width == 0.0)
|
||||
.then_some(wire.points.len())
|
||||
}
|
||||
|
||||
fn build_nested_ref(
|
||||
nested_ins: &acadrust::entities::Insert,
|
||||
scale_policy: crate::scene::BlockScalePolicy,
|
||||
@@ -1640,6 +1703,14 @@ impl Batches {
|
||||
);
|
||||
}
|
||||
}
|
||||
// Glyph quads stay at neutral depth on purpose: the per-wire
|
||||
// depth_override (b.local_depth) is composed with the insert's
|
||||
// scene-graph level at upload time (wire_draw_depth:
|
||||
// depths[insert] + override * half), the same place the
|
||||
// wipeout/hatch fills get theirs. Baking the raw child rank
|
||||
// into the vertices here would double-count it in per-block
|
||||
// label units — for a negative rank that sinks the text below
|
||||
// its own block's wipes instead of ahead of them.
|
||||
if !b.pattern_stations.is_empty() {
|
||||
b.pattern_stations = encode_pattern_stations(
|
||||
std::mem::take(&mut b.pattern_stations),
|
||||
@@ -1898,7 +1969,20 @@ fn expand_defn(
|
||||
let composed_for = |offset: &[f64; 3]| {
|
||||
nested_instance_transform(nref, *offset).then(accum_xform)
|
||||
};
|
||||
if let Some(cp) = &nref.clip_poly {
|
||||
if nested_defn.inline_point_cost.is_some() && nref.clip_poly.is_none() {
|
||||
for offset in &nref.instance_offsets {
|
||||
expand_defn(
|
||||
nested_defn,
|
||||
&composed_for(offset),
|
||||
&inner_ctx,
|
||||
out,
|
||||
visited,
|
||||
depth + 1,
|
||||
nref.suppress_root_points,
|
||||
nested_range,
|
||||
);
|
||||
}
|
||||
} else if let Some(cp) = &nref.clip_poly {
|
||||
let base_composed = nref.xform.then(accum_xform);
|
||||
let base_translation = transform_translation(&base_composed);
|
||||
let base_poly: Vec<[f64; 2]> = cp
|
||||
@@ -2495,11 +2579,17 @@ fn emit_wire(
|
||||
.map(|marker| transformed_point_marker(marker, accum_xform));
|
||||
let point_marker = marker_transform.map(|(marker, _)| marker);
|
||||
|
||||
// Only band wires take a per-child composed depth: their solid area is
|
||||
// what covers siblings, and their width already splits them into their own
|
||||
// batches — thin wires keep the shared whole-insert depth so same-style
|
||||
// batches stay merged.
|
||||
let local_depth = (lw.world_width > 0.0)
|
||||
// Band wires take a per-child composed depth: their solid area is what
|
||||
// covers siblings, and their width already splits them into their own
|
||||
// batches. Text-bearing wires need it too — but as `depth_override`, not
|
||||
// baked into vertices: the upload resolves it against the insert's
|
||||
// scene-graph entry (depths[insert] + override * half), which is what
|
||||
// keeps text interleaved with its sibling wipeout/hatch fills. Without a
|
||||
// composed rank the text sits at the bare insert level, an exact tie with
|
||||
// every sibling fill, and the later wipeout pass erases it (unselected
|
||||
// block text vanished under its wipeout; selecting won only because the
|
||||
// xray pass ignores depth).
|
||||
let local_depth = (lw.world_width > 0.0 || !lw.text_verts.is_empty())
|
||||
.then(|| d_range.0 + lw.local_rank * d_range.1);
|
||||
let plot_visible = ctx.plot_visible
|
||||
&& lw.plot_visible
|
||||
@@ -3036,3 +3126,116 @@ mod bg_resolution_tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod compact_nested_tests {
|
||||
use super::*;
|
||||
use crate::scene::view::render::InheritStyle;
|
||||
use acadrust::entities::{Insert, Line};
|
||||
use acadrust::tables::BlockRecord;
|
||||
|
||||
fn add_block(document: &mut CadDocument, name: &str) -> Handle {
|
||||
let mut block = BlockRecord::new(name);
|
||||
block.handle = document.allocate_handle();
|
||||
let handle = block.handle;
|
||||
document.block_records.add(block).unwrap();
|
||||
handle
|
||||
}
|
||||
|
||||
fn add_owned(document: &mut CadDocument, owner: Handle, mut entity: EntityType) {
|
||||
entity.common_mut().owner_handle = owner;
|
||||
document.add_entity(entity).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_small_nested_lines_fold_into_parent_batches() {
|
||||
let mut document = CadDocument::new();
|
||||
let leaf = add_block(&mut document, "LEAF");
|
||||
for y in 0..6 {
|
||||
add_owned(
|
||||
&mut document,
|
||||
leaf,
|
||||
EntityType::Line(Line::from_points(
|
||||
Vector3::new(0.0, y as f64, 0.0),
|
||||
Vector3::new(1.0, y as f64, 0.0),
|
||||
)),
|
||||
);
|
||||
}
|
||||
|
||||
let twig = add_block(&mut document, "TWIG");
|
||||
for x in 0..5 {
|
||||
add_owned(
|
||||
&mut document,
|
||||
twig,
|
||||
EntityType::Insert(Insert::new(
|
||||
"LEAF",
|
||||
Vector3::new(x as f64 * 2.0, 0.0, 0.0),
|
||||
)),
|
||||
);
|
||||
}
|
||||
|
||||
let root = add_block(&mut document, "ROOT");
|
||||
for x in 0..100 {
|
||||
add_owned(
|
||||
&mut document,
|
||||
root,
|
||||
EntityType::Insert(Insert::new(
|
||||
"TWIG",
|
||||
Vector3::new(x as f64 * 20.0, 0.0, 0.0),
|
||||
)),
|
||||
);
|
||||
}
|
||||
|
||||
let root_insert = Insert::new("ROOT", Vector3::ZERO);
|
||||
let root_handle = document
|
||||
.add_entity(EntityType::Insert(root_insert.clone()))
|
||||
.unwrap();
|
||||
let cache = BlockCache::build(
|
||||
&document,
|
||||
1.0,
|
||||
None,
|
||||
true,
|
||||
[0.0, 0.0, 0.0, 1.0],
|
||||
None,
|
||||
&HashMap::default(),
|
||||
);
|
||||
let wires = expand_insert(
|
||||
&document,
|
||||
&cache,
|
||||
&root_insert,
|
||||
root_handle,
|
||||
[1.0; 4],
|
||||
0,
|
||||
0.0,
|
||||
[0.0; 8],
|
||||
1.0,
|
||||
InheritStyle {
|
||||
color: [1.0; 4],
|
||||
pat_len: 0.0,
|
||||
pat: [0.0; 8],
|
||||
lw_px: 1.0,
|
||||
},
|
||||
0,
|
||||
true,
|
||||
false,
|
||||
1.0,
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
[0.0, 0.0, 0.0, 1.0],
|
||||
1.0,
|
||||
crate::scene::BlockScalePolicy::FromInsert,
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let finite_points = wires
|
||||
.iter()
|
||||
.flat_map(|wire| &wire.points)
|
||||
.filter(|point| point[0].is_finite())
|
||||
.count();
|
||||
assert_eq!(finite_points, 6_000);
|
||||
assert_eq!(wires.len(), 1);
|
||||
assert!(wires[0].render_instance.is_none());
|
||||
}
|
||||
}
|
||||
Vendored
+5
-2
@@ -30,7 +30,10 @@ pub fn general_section(entity: &EntityType) -> PropSection {
|
||||
},
|
||||
);
|
||||
|
||||
let hyperlink = crate::scene::pe_url_of(entity).unwrap_or_default().to_owned();
|
||||
let hyperlink = crate::scene::pe_url_description_of(entity)
|
||||
.or_else(|| crate::scene::pe_url_of(entity))
|
||||
.unwrap_or_default()
|
||||
.to_owned();
|
||||
|
||||
let section = PropSection {
|
||||
title: t!("General").into_owned(),
|
||||
@@ -83,7 +86,7 @@ pub fn general_section(entity: &EntityType) -> PropSection {
|
||||
Property {
|
||||
label: t!("Hyperlink").into_owned(),
|
||||
field: "hyperlink",
|
||||
value: PropValue::PlainText(hyperlink),
|
||||
value: PropValue::Hyperlink(hyperlink),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -567,6 +567,7 @@ impl Scene {
|
||||
viewport.view_target.x = center.x;
|
||||
viewport.view_target.y = center.y;
|
||||
viewport.view_target.z = center.z;
|
||||
self.notify_viewport_changed(handle);
|
||||
} else {
|
||||
self.camera.borrow_mut().target = center;
|
||||
}
|
||||
|
||||
@@ -3110,6 +3110,75 @@ mod tests {
|
||||
assert_eq!(line_wire.world_width, 10.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_mixed_polyline_keeps_exact_line_intersections() {
|
||||
let tangents = vec![
|
||||
TangentGeom::Line {
|
||||
p1: [-10.0, 0.0, 0.0],
|
||||
p2: [0.0, 0.0, 0.0],
|
||||
},
|
||||
TangentGeom::Arc {
|
||||
center: [0.0, 4.0, 0.0],
|
||||
axis_x: [1.0, 0.0, 0.0],
|
||||
axis_y: [0.0, 1.0, 0.0],
|
||||
radius: 4.0,
|
||||
start_angle: -std::f64::consts::FRAC_PI_2,
|
||||
end_angle: std::f64::consts::FRAC_PI_2,
|
||||
},
|
||||
TangentGeom::Line {
|
||||
p1: [0.0, 8.0, 0.0],
|
||||
p2: [10.0, 8.0, 0.0],
|
||||
},
|
||||
];
|
||||
let vertices = vec![
|
||||
[-10.0, 0.0, 0.0],
|
||||
[0.0, 0.0, 0.0],
|
||||
[0.0, 8.0, 0.0],
|
||||
[10.0, 8.0, 0.0],
|
||||
];
|
||||
let wires = split_mixed_polyline(
|
||||
&tangents,
|
||||
&vertices,
|
||||
"mixed",
|
||||
[1.0; 4],
|
||||
false,
|
||||
0.0,
|
||||
[0.0; 8],
|
||||
1.0,
|
||||
Vec::new(),
|
||||
None,
|
||||
true,
|
||||
&[(0.0, 0.0); 3],
|
||||
0.0,
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
);
|
||||
let line_wire = wires
|
||||
.iter()
|
||||
.find(|wire| {
|
||||
wire.tangent_geoms
|
||||
.iter()
|
||||
.all(|geom| matches!(geom, TangentGeom::Line { .. }))
|
||||
})
|
||||
.expect("straight-segment wire");
|
||||
let circle = WireModel {
|
||||
tangent_geoms: vec![TangentGeom::PlanarCircle {
|
||||
center: [5.0, 4.0, 0.0],
|
||||
axis_x: [1.0, 0.0, 0.0],
|
||||
axis_y: [0.0, 1.0, 0.0],
|
||||
radius: 5.0,
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mut points = crate::snap::exact_curve_intersections(line_wire, &circle)
|
||||
.expect("analytical intersections");
|
||||
points.sort_by(|left, right| left.x.total_cmp(&right.x));
|
||||
assert_eq!(points.len(), 2, "unexpected intersections: {points:?}");
|
||||
assert!((points[0] - glam::DVec3::new(2.0, 8.0, 0.0)).length() < 1.0e-9);
|
||||
assert!((points[1] - glam::DVec3::new(8.0, 8.0, 0.0)).length() < 1.0e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_split_mixed_polyline_zero_width_segments_after_tapered_segment() {
|
||||
let tg_line = TangentGeom::Line {
|
||||
|
||||
+1224
-25
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,587 @@
|
||||
//! Resolve a dimension feature through `[viewport?, insert..., entity]`.
|
||||
//! `xrefs` stores the primary chain; `intersection_objects` stores the second
|
||||
//! intersection source. Features are evaluated in entity-local coordinates and
|
||||
//! transformed through their block instances. The caller projects viewport
|
||||
//! references onto the sheet. All arithmetic uses `f64`.
|
||||
|
||||
use acadrust::objects::AssocDimensionReference;
|
||||
use acadrust::types::{Handle, Matrix4, Transform, Vector3};
|
||||
use acadrust::{CadDocument, EntityType};
|
||||
use cadkernel::geom2d::{closest_point, Curve as KernelCurve};
|
||||
use cadkernel::space::Plane;
|
||||
|
||||
use crate::entities::curve::entity_curve;
|
||||
|
||||
/// `AcDb::OsnapMode`, the value stored in
|
||||
/// [`AssocDimensionReference::osnap_type`].
|
||||
///
|
||||
/// These are ObjectARX codes, independent of [`crate::snap::SnapType`].
|
||||
#[allow(dead_code)] // Include codes preserved from unsupported imported modes.
|
||||
pub(crate) mod osnap {
|
||||
pub const NONE: u8 = 0;
|
||||
pub const END: u8 = 1;
|
||||
pub const MID: u8 = 2;
|
||||
pub const CEN: u8 = 3;
|
||||
pub const NODE: u8 = 4;
|
||||
pub const QUAD: u8 = 5;
|
||||
pub const INTERSEC: u8 = 6;
|
||||
pub const INS: u8 = 7;
|
||||
pub const PERP: u8 = 8;
|
||||
pub const TAN: u8 = 9;
|
||||
pub const NEAR: u8 = 10;
|
||||
pub const APPARENT_INT: u8 = 11;
|
||||
pub const PARA: u8 = 12;
|
||||
pub const START_POINT: u8 = 13;
|
||||
}
|
||||
|
||||
/// Translate a live snap mode into the `AcDb::OsnapMode` we persist.
|
||||
///
|
||||
/// Modes with no ObjectARX equivalent (grid, object pick, 3D solid snaps) map
|
||||
/// to [`osnap::NONE`]; a reference carrying `NONE` resolves by
|
||||
/// marker/parameter alone.
|
||||
pub(crate) fn osnap_type_for(snap: crate::snap::SnapType) -> u8 {
|
||||
use crate::snap::SnapType as S;
|
||||
match snap {
|
||||
S::Endpoint => osnap::END,
|
||||
S::Midpoint => osnap::MID,
|
||||
S::Center => osnap::CEN,
|
||||
S::Node => osnap::NODE,
|
||||
S::Quadrant => osnap::QUAD,
|
||||
S::Intersection => osnap::INTERSEC,
|
||||
S::Insertion => osnap::INS,
|
||||
S::Perpendicular => osnap::PERP,
|
||||
S::Tangent => osnap::TAN,
|
||||
S::Nearest | S::Extension => osnap::NEAR,
|
||||
S::ApparentIntersection => osnap::APPARENT_INT,
|
||||
S::Parallel => osnap::PARA,
|
||||
S::Grid | S::ObjectPick | S::Vertex | S::EdgeMidpoint | S::FaceCenter | S::Knot | S::FacePerpendicular | S::NearestFace => {
|
||||
osnap::NONE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A resolved `xrefs` chain.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct ReferenceChain {
|
||||
/// The layout viewport the reference is seen through, when the chain
|
||||
/// starts with one. `None` for a plain model-space reference.
|
||||
pub viewport: Option<Handle>,
|
||||
/// INSERT handles from the outermost instance down to the instance that
|
||||
/// directly contains [`Self::entity`]. Empty for top-level geometry.
|
||||
pub block_path: Vec<Handle>,
|
||||
/// The innermost entity — the one that owns the feature.
|
||||
pub entity: Handle,
|
||||
/// Block-path transform: entity-local coordinates -> model coordinates.
|
||||
/// Identity when `block_path` is empty.
|
||||
pub transform: Transform,
|
||||
}
|
||||
|
||||
/// Why a chain could not be walked. The data is never discarded on a failure:
|
||||
/// the caller keeps the reference exactly as read and leaves the dimension
|
||||
/// showing its last valid appearance.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum ChainError {
|
||||
/// `xrefs` was empty — nothing to resolve.
|
||||
Empty,
|
||||
Invalid,
|
||||
/// A handle in the chain names an object that is not in the document
|
||||
/// (erased source; restored by an undo).
|
||||
Missing(Handle),
|
||||
}
|
||||
|
||||
/// Walk `xrefs` into a [`ReferenceChain`].
|
||||
///
|
||||
/// The chain is read positionally rather than by a stored kind, because that
|
||||
/// is all the file gives us: a leading VIEWPORT is the trans-space hop, any
|
||||
/// INSERTs after it are the block-instance path, and the first handle that is
|
||||
/// neither is the geometry. A chain that is nothing but INSERTs (dimensioning
|
||||
/// a block reference's insertion point) ends on the innermost INSERT.
|
||||
pub(crate) fn walk_chain(
|
||||
document: &CadDocument,
|
||||
xrefs: &[Handle],
|
||||
) -> Result<ReferenceChain, ChainError> {
|
||||
if xrefs.is_empty() {
|
||||
return Err(ChainError::Empty);
|
||||
}
|
||||
let mut viewport = None;
|
||||
let mut block_path = Vec::new();
|
||||
let mut transform = Transform::identity();
|
||||
let mut owner = None;
|
||||
for (index, &handle) in xrefs.iter().enumerate() {
|
||||
if handle.is_null() {
|
||||
return Err(ChainError::Invalid);
|
||||
}
|
||||
let node = document
|
||||
.get_entity(handle)
|
||||
.ok_or(ChainError::Missing(handle))?;
|
||||
if owner.is_some_and(|owner| node.common().owner_handle != owner) {
|
||||
return Err(ChainError::Invalid);
|
||||
}
|
||||
if index == 0 && matches!(node, EntityType::Viewport(_)) {
|
||||
viewport = Some(handle);
|
||||
owner = document.block_records.get("*Model_Space").map(|b| b.handle);
|
||||
continue;
|
||||
}
|
||||
if index == xrefs.len() - 1 {
|
||||
return Ok(ReferenceChain {
|
||||
viewport,
|
||||
block_path,
|
||||
entity: handle,
|
||||
transform,
|
||||
});
|
||||
}
|
||||
let EntityType::Insert(insert) = node else {
|
||||
return Err(ChainError::Invalid);
|
||||
};
|
||||
// Arrays need an instance index that this reference format does not carry.
|
||||
if insert.row_count > 1
|
||||
|| insert.column_count > 1
|
||||
|| block_path.len() >= 8
|
||||
|| block_path.contains(&handle)
|
||||
{
|
||||
return Err(ChainError::Invalid);
|
||||
}
|
||||
let block = document
|
||||
.block_records
|
||||
.get(&insert.block_name)
|
||||
.ok_or(ChainError::Invalid)?;
|
||||
owner = Some(block.handle);
|
||||
block_path.push(handle);
|
||||
transform = transform.compose(&crate::scene::render_graph::insert_transform(
|
||||
document, insert,
|
||||
));
|
||||
}
|
||||
Err(ChainError::Empty)
|
||||
}
|
||||
|
||||
/// Invert an affine block transform, rejecting singular or nonfinite matrices.
|
||||
pub(crate) fn invert(transform: &Transform) -> Option<Transform> {
|
||||
let m = &transform.matrix.m;
|
||||
let matrix = glam::DMat4::from_cols_array_2d(&std::array::from_fn(|column| {
|
||||
std::array::from_fn(|row| m[row][column])
|
||||
}));
|
||||
if !matrix.is_finite() || matrix.determinant().abs() < 1e-18 {
|
||||
return None;
|
||||
}
|
||||
let inverse = matrix.inverse();
|
||||
if !inverse.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let columns = inverse.to_cols_array_2d();
|
||||
Some(Transform::from_matrix(Matrix4 {
|
||||
m: std::array::from_fn(|row| std::array::from_fn(|column| columns[column][row])),
|
||||
}))
|
||||
}
|
||||
|
||||
// ── Feature evaluation ────────────────────────────────────────────────────
|
||||
|
||||
fn vector3(point: [f64; 3]) -> Vector3 {
|
||||
Vector3::new(point[0], point[1], point[2])
|
||||
}
|
||||
|
||||
fn dpoint(point: Vector3) -> [f64; 3] {
|
||||
[point.x, point.y, point.z]
|
||||
}
|
||||
|
||||
fn distance_squared(a: Vector3, b: Vector3) -> f64 {
|
||||
let (dx, dy, dz) = (a.x - b.x, a.y - b.y, a.z - b.z);
|
||||
dx * dx + dy * dy + dz * dz
|
||||
}
|
||||
|
||||
fn lift(plane: &Plane, uv: [f64; 2]) -> Vector3 {
|
||||
vector3(plane.point_at(uv))
|
||||
}
|
||||
|
||||
/// Everything a feature evaluation may need beyond the reference itself.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub(crate) struct FeatureContext {
|
||||
/// The stored osnap point, already expressed in *entity-local*
|
||||
/// coordinates. Used as the disambiguating hint for every feature that has
|
||||
/// more than one candidate (quadrant, intersection, tangent, nearest).
|
||||
pub hint: Option<Vector3>,
|
||||
/// The point the feature is measured *from*, in entity-local coordinates.
|
||||
/// PERPENDICULAR and TANGENT are two-point features: the foot / touch
|
||||
/// point only exists relative to an external point, which for a dimension
|
||||
/// is the other definition point. Tangency requires this point.
|
||||
pub from: Option<Vector3>,
|
||||
}
|
||||
|
||||
/// Evaluate the osnap feature `reference` names on `entity`, in the entity's
|
||||
/// own coordinates.
|
||||
///
|
||||
/// Returns `None` — never a guess — when the osnap type is one we do not
|
||||
/// evaluate, or when the geometry no longer supports it. The caller keeps the
|
||||
/// reference data intact and leaves the dimension where it was.
|
||||
pub(crate) fn feature_point(
|
||||
document: &CadDocument,
|
||||
entity: &EntityType,
|
||||
reference: &AssocDimensionReference,
|
||||
context: FeatureContext,
|
||||
) -> Option<Vector3> {
|
||||
match reference.osnap_type {
|
||||
osnap::END | osnap::START_POINT if reference.main_subent_type == 2 => {
|
||||
imported_endpoint(entity, reference, context.hint)
|
||||
}
|
||||
osnap::CEN => center_point(entity, reference),
|
||||
osnap::MID => mid_point(entity, reference),
|
||||
osnap::QUAD => quadrant_point(entity, reference, context.hint?),
|
||||
osnap::NODE | osnap::INS => node_point(entity),
|
||||
osnap::INTERSEC | osnap::APPARENT_INT => intersection_point(
|
||||
document,
|
||||
entity,
|
||||
reference,
|
||||
context.hint?,
|
||||
reference.osnap_type == osnap::APPARENT_INT,
|
||||
),
|
||||
osnap::PERP => perpendicular_point(entity, context.from.or(context.hint)?),
|
||||
osnap::TAN => tangent_point(entity, reference, context),
|
||||
osnap::NEAR => nearest_point(entity, reference, context.hint),
|
||||
// END / START_POINT / PARA / NONE all address a named vertex or a
|
||||
// stored curve parameter, which is what the legacy marker convention
|
||||
// already encodes. The caller keeps that path.
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether [`feature_point`] knows how to evaluate this osnap type. A type we
|
||||
/// do not handle keeps its stored data and stays unresolved.
|
||||
pub(crate) fn evaluates(osnap_type: u8) -> bool {
|
||||
matches!(
|
||||
osnap_type,
|
||||
osnap::END
|
||||
| osnap::START_POINT
|
||||
| osnap::CEN
|
||||
| osnap::MID
|
||||
| osnap::QUAD
|
||||
| osnap::NODE
|
||||
| osnap::INS
|
||||
| osnap::INTERSEC
|
||||
| osnap::APPARENT_INT
|
||||
| osnap::PERP
|
||||
| osnap::TAN
|
||||
| osnap::NEAR
|
||||
)
|
||||
}
|
||||
|
||||
/// Whether this osnap type needs the *other* definition point to evaluate.
|
||||
pub(crate) fn needs_from_point(osnap_type: u8) -> bool {
|
||||
matches!(osnap_type, osnap::PERP | osnap::TAN)
|
||||
}
|
||||
|
||||
fn center_point(entity: &EntityType, reference: &AssocDimensionReference) -> Option<Vector3> {
|
||||
match entity {
|
||||
EntityType::Circle(circle) => Some(circle.center_wcs()),
|
||||
EntityType::Arc(arc) => Some(arc.center_wcs()),
|
||||
EntityType::Ellipse(ellipse) => Some(ellipse.center),
|
||||
_ => {
|
||||
// A polyline's bulge segment: `main_gs_marker` names the segment.
|
||||
let planar = entity_curve(entity)?;
|
||||
let KernelCurve::Polyline(polyline) = &planar.curve else {
|
||||
return None;
|
||||
};
|
||||
let segment = if reference.main_subent_type == 1
|
||||
&& reference.main_gs_marker == super::dimension_assoc::POLYLINE_ARC_CENTER_MARKER
|
||||
{
|
||||
reference.osnap_distance.round().max(0.0) as usize
|
||||
} else {
|
||||
segment_index(reference)?
|
||||
};
|
||||
let arc = polyline.segment_arc(segment)?;
|
||||
Some(lift(&planar.plane, arc.center))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn mid_point(entity: &EntityType, reference: &AssocDimensionReference) -> Option<Vector3> {
|
||||
let planar = entity_curve(entity)?;
|
||||
match &planar.curve {
|
||||
// For a polyline the marker names which segment's midpoint it is;
|
||||
// without one, the midpoint of the whole run.
|
||||
KernelCurve::Polyline(_) if reference.main_gs_marker >= 0 => {
|
||||
let segments = planar.curve.segments();
|
||||
let segment = segments.get(segment_index(reference)?)?;
|
||||
Some(lift(&planar.plane, segment.point_at(0.5)))
|
||||
}
|
||||
_ => Some(lift(&planar.plane, planar.curve.point_at(0.5))),
|
||||
}
|
||||
}
|
||||
|
||||
fn quadrant_point(
|
||||
entity: &EntityType,
|
||||
reference: &AssocDimensionReference,
|
||||
hint: Vector3,
|
||||
) -> Option<Vector3> {
|
||||
let planar = entity_curve(entity)?;
|
||||
let (centre, radius) = match &planar.curve {
|
||||
KernelCurve::Circle(circle) => (circle.centre, circle.radius),
|
||||
KernelCurve::Arc(arc) => (arc.centre, arc.radius),
|
||||
_ => return None,
|
||||
};
|
||||
let valid_angle = |angle: f64| match &planar.curve {
|
||||
KernelCurve::Arc(arc) => {
|
||||
cadkernel::geom2d::angle_within_arc(angle, arc.start_angle, arc.end_angle)
|
||||
}
|
||||
_ => true,
|
||||
};
|
||||
let at_angle = |angle: f64| {
|
||||
lift(
|
||||
&planar.plane,
|
||||
KernelCurve::Circle(cadkernel::geom2d::Circle { centre, radius })
|
||||
.point_at(angle.rem_euclid(std::f64::consts::TAU) / std::f64::consts::TAU),
|
||||
)
|
||||
};
|
||||
// New references retain the chosen source-local angle, so translation
|
||||
// cannot silently select another quadrant using the old world point.
|
||||
if reference.main_subent_type == 1 && reference.main_gs_marker == -2 {
|
||||
let angle = reference.osnap_distance;
|
||||
return (angle.is_finite() && valid_angle(angle)).then(|| at_angle(angle));
|
||||
}
|
||||
// An imported point can identify an unchanged quadrant, but cannot prove
|
||||
// which quadrant was intended after it moves away from the stored hint.
|
||||
(0..4)
|
||||
.map(|index| std::f64::consts::FRAC_PI_2 * index as f64)
|
||||
.filter(|angle| valid_angle(*angle))
|
||||
.map(at_angle)
|
||||
.find(|candidate| distance_squared(*candidate, hint) < 1e-12)
|
||||
}
|
||||
|
||||
fn node_point(entity: &EntityType) -> Option<Vector3> {
|
||||
match entity {
|
||||
EntityType::Point(point) => Some(point.location),
|
||||
EntityType::Insert(insert) => Some(insert.insert_point),
|
||||
EntityType::Text(text) => Some(text.insertion_point),
|
||||
EntityType::MText(text) => Some(text.insertion_point),
|
||||
EntityType::Shape(shape) => Some(shape.insertion_point),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn perpendicular_point(entity: &EntityType, from: Vector3) -> Option<Vector3> {
|
||||
let planar = entity_curve(entity)?;
|
||||
let uv = planar.plane.project(dpoint(from))?;
|
||||
cadkernel::geom2d::snap::perpendicular_from(&planar.curve, uv)
|
||||
.into_iter()
|
||||
.map(|candidate| lift(&planar.plane, candidate.point))
|
||||
.min_by(|a, b| distance_squared(*a, from).total_cmp(&distance_squared(*b, from)))
|
||||
}
|
||||
|
||||
/// Perpendicularity must be evaluated after a block's nonuniform scale.
|
||||
pub(crate) fn perpendicular_in_model(
|
||||
entity: &EntityType,
|
||||
transform: &Transform,
|
||||
from: Vector3,
|
||||
) -> Option<Vector3> {
|
||||
use glam::DVec3;
|
||||
let source = entity_curve(entity)?;
|
||||
let origin = transform.apply(vector3(source.plane.origin));
|
||||
let x = DVec3::from_array(dpoint(
|
||||
transform.apply_rotation(vector3(source.plane.x_axis)),
|
||||
));
|
||||
let y = DVec3::from_array(dpoint(
|
||||
transform.apply_rotation(vector3(source.plane.y_axis)),
|
||||
));
|
||||
let axis_x = x.try_normalize()?;
|
||||
let normal = x.cross(y).try_normalize()?;
|
||||
let axis_y = normal.cross(axis_x);
|
||||
let plane = Plane::from_axes(dpoint(origin), axis_x.to_array(), axis_y.to_array());
|
||||
let curve = source.curve.transformed(&cadkernel::geom2d::Transform {
|
||||
origin: [0.0, 0.0].into(),
|
||||
x_axis: [x.dot(axis_x), x.dot(axis_y)].into(),
|
||||
y_axis: [y.dot(axis_x), y.dot(axis_y)].into(),
|
||||
})?;
|
||||
let uv = plane.project(dpoint(from))?;
|
||||
cadkernel::geom2d::snap::perpendicular_from(&curve, uv)
|
||||
.into_iter()
|
||||
.map(|candidate| lift(&plane, candidate.point))
|
||||
.min_by(|a, b| distance_squared(*a, from).total_cmp(&distance_squared(*b, from)))
|
||||
}
|
||||
|
||||
fn nearest_point(
|
||||
entity: &EntityType,
|
||||
reference: &AssocDimensionReference,
|
||||
hint: Option<Vector3>,
|
||||
) -> Option<Vector3> {
|
||||
let planar = entity_curve(entity)?;
|
||||
// Circles and arcs store an angle; other curves use a normalized parameter.
|
||||
match &planar.curve {
|
||||
KernelCurve::Circle(_) | KernelCurve::Arc(_) => match entity {
|
||||
EntityType::Circle(circle) => Some(circle.point_at_angle_wcs(reference.osnap_distance)),
|
||||
EntityType::Arc(arc) => Some(arc.point_at_angle_wcs(reference.osnap_distance)),
|
||||
_ => None,
|
||||
},
|
||||
_ => {
|
||||
let parameter = reference.osnap_distance;
|
||||
if parameter.is_finite() && (0.0..=1.0).contains(¶meter) {
|
||||
return Some(lift(&planar.plane, planar.curve.point_at(parameter)));
|
||||
}
|
||||
// No usable parameter: fall back to the point on the curve nearest
|
||||
// the stored osnap point, which is what NEAR means anyway.
|
||||
let uv = planar.plane.project(dpoint(hint?))?;
|
||||
Some(lift(&planar.plane, closest_point(&planar.curve, uv).point))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tangent ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// Tangent point for the line through `context.from`.
|
||||
fn tangent_point(
|
||||
entity: &EntityType,
|
||||
reference: &AssocDimensionReference,
|
||||
context: FeatureContext,
|
||||
) -> Option<Vector3> {
|
||||
if let EntityType::Spline(spline) = entity {
|
||||
return spline_tangent_point(spline, reference, context);
|
||||
}
|
||||
let planar = entity_curve(entity)?;
|
||||
let from = context.from?;
|
||||
let uv = planar.plane.project(dpoint(from))?;
|
||||
cadkernel::geom2d::snap::tangent_from(&planar.curve, uv)
|
||||
.into_iter()
|
||||
.map(|candidate| lift(&planar.plane, candidate.point))
|
||||
.min_by(|a, b| {
|
||||
distance_squared(*a, context.hint.unwrap_or(from))
|
||||
.total_cmp(&distance_squared(*b, context.hint.unwrap_or(from)))
|
||||
})
|
||||
}
|
||||
|
||||
fn spline_tangent_point(
|
||||
spline: &acadrust::entities::Spline,
|
||||
reference: &AssocDimensionReference,
|
||||
context: FeatureContext,
|
||||
) -> Option<Vector3> {
|
||||
let curve = crate::entities::spline::nurbs3(spline)?;
|
||||
let (start, end) = curve.domain();
|
||||
let stored = reference.osnap_distance;
|
||||
let u = if stored.is_finite() && stored > start - 1e-9 && stored < end + 1e-9 {
|
||||
stored.clamp(start, end)
|
||||
} else {
|
||||
let hint = context.hint.or(context.from)?;
|
||||
curve.parameter_at(dpoint(hint)).clamp(start, end)
|
||||
};
|
||||
curve
|
||||
.tangent_from_near(dpoint(context.from?), u)
|
||||
.map(vector3)
|
||||
}
|
||||
|
||||
// ── Intersection ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Intersection of `entity` with the reference's second object chain.
|
||||
///
|
||||
/// Transform both curves into the first entity's local plane and use the
|
||||
/// kernel's exact/converged intersection solver. Apparent intersections may
|
||||
/// project noncoplanar curves; only straight lines support an extended crossing.
|
||||
fn intersection_point(
|
||||
document: &CadDocument,
|
||||
entity: &EntityType,
|
||||
reference: &AssocDimensionReference,
|
||||
hint: Vector3,
|
||||
apparent: bool,
|
||||
) -> Option<Vector3> {
|
||||
let main = walk_chain(document, &reference.xrefs).ok()?;
|
||||
let other = walk_chain(document, &reference.intersection_objects).ok()?;
|
||||
if main.viewport != other.viewport {
|
||||
return None;
|
||||
}
|
||||
let inverse = invert(&main.transform)?;
|
||||
let first = entity_curve(entity)?;
|
||||
let second = entity_curve(document.get_entity(other.entity)?)?;
|
||||
let map = |uv| {
|
||||
let local = inverse.apply(other.transform.apply(lift(&second.plane, uv)));
|
||||
let projected = first.plane.project(dpoint(local))?;
|
||||
if !apparent && distance_squared(local, lift(&first.plane, projected)) > 1e-12 {
|
||||
return None;
|
||||
}
|
||||
Some(projected)
|
||||
};
|
||||
let origin = glam::DVec2::from_array(map([0.0, 0.0])?);
|
||||
let x = glam::DVec2::from_array(map([1.0, 0.0])?) - origin;
|
||||
let y = glam::DVec2::from_array(map([0.0, 1.0])?) - origin;
|
||||
let second = second.curve.transformed(&cadkernel::geom2d::Transform {
|
||||
origin: origin.to_array().into(),
|
||||
x_axis: x.to_array().into(),
|
||||
y_axis: y.to_array().into(),
|
||||
})?;
|
||||
let hint_uv = first.plane.project(dpoint(hint))?;
|
||||
let mut points: Vec<_> = cadkernel::geom2d::intersect(
|
||||
&first.curve,
|
||||
&second,
|
||||
cadkernel::geom2d::Tolerance::new(1e-9),
|
||||
)
|
||||
.into_iter()
|
||||
.map(|hit| hit.point)
|
||||
.collect();
|
||||
if apparent && points.is_empty() {
|
||||
// Extended apparent intersections are meaningful for straight lines.
|
||||
// Curved geometry must actually cross after projection.
|
||||
if let (KernelCurve::Line(a), KernelCurve::Line(b)) = (&first.curve, &second) {
|
||||
let extend = |line: &cadkernel::geom2d::Line| {
|
||||
KernelCurve::XLine(cadkernel::geom2d::XLine {
|
||||
base: line.start,
|
||||
direction: [line.end[0] - line.start[0], line.end[1] - line.start[1]],
|
||||
})
|
||||
};
|
||||
points.extend(
|
||||
cadkernel::geom2d::intersect(
|
||||
&extend(a),
|
||||
&extend(b),
|
||||
cadkernel::geom2d::Tolerance::new(1e-9),
|
||||
)
|
||||
.into_iter()
|
||||
.map(|hit| hit.point),
|
||||
);
|
||||
}
|
||||
}
|
||||
points
|
||||
.into_iter()
|
||||
.min_by(|a, b| {
|
||||
let error = |p: &[f64; 2]| (p[0] - hint_uv[0]).powi(2) + (p[1] - hint_uv[1]).powi(2);
|
||||
error(a).total_cmp(&error(b))
|
||||
})
|
||||
.map(|point| lift(&first.plane, point))
|
||||
}
|
||||
|
||||
/// Internal markers are zero-based; imported edge markers are one-based.
|
||||
fn segment_index(reference: &AssocDimensionReference) -> Option<usize> {
|
||||
let marker = if reference.main_subent_type == 2 {
|
||||
reference.main_gs_marker.checked_sub(1)?
|
||||
} else {
|
||||
reference.main_gs_marker
|
||||
};
|
||||
usize::try_from(marker).ok()
|
||||
}
|
||||
|
||||
fn imported_endpoint(
|
||||
entity: &EntityType,
|
||||
reference: &AssocDimensionReference,
|
||||
hint: Option<Vector3>,
|
||||
) -> Option<Vector3> {
|
||||
let planar = entity_curve(entity)?;
|
||||
let t = match reference.osnap_distance {
|
||||
v if v.abs() < 1e-9 => 0.0,
|
||||
v if (v - 1.0).abs() < 1e-9 => 1.0,
|
||||
_ => return None,
|
||||
};
|
||||
let point = match &planar.curve {
|
||||
KernelCurve::Polyline(_) => {
|
||||
let segment = segment_index(reference)?;
|
||||
let point = lift(
|
||||
&planar.plane,
|
||||
planar.curve.segments().get(segment)?.point_at(t),
|
||||
);
|
||||
// Exporters can leave stale GS markers after editing a polyline.
|
||||
// Beyond the unambiguous first-edge convention, require a real
|
||||
// stored snap point to validate this imported endpoint. Zero and
|
||||
// 2e50 sentinel points do not establish feature identity.
|
||||
if segment > 0 && !hint.is_some_and(|hint| distance_squared(point, hint) < 1e-10) {
|
||||
return None;
|
||||
}
|
||||
point
|
||||
}
|
||||
KernelCurve::Line(_) | KernelCurve::Arc(_) | KernelCurve::Nurbs(_) => {
|
||||
lift(&planar.plane, planar.curve.point_at(t))
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
Some(point)
|
||||
}
|
||||
@@ -43,6 +43,7 @@ struct EntityNodes {
|
||||
geometry_node_id: i32,
|
||||
points: FxHashMap<i32, i32>,
|
||||
segments: FxHashMap<usize, i32>,
|
||||
axes: FxHashMap<i32, i32>,
|
||||
}
|
||||
|
||||
/// Builds one scope's `Assoc2dConstraintGroup` graph incrementally,
|
||||
@@ -55,8 +56,6 @@ struct GroupBuilder<'a> {
|
||||
/// Real entity handles that ended up with a geometry node — what
|
||||
/// `AssocGeomDependency` objects get created for, in first-touch order.
|
||||
referenced_entities: Vec<Handle>,
|
||||
horizontal_datum: Option<i32>,
|
||||
vertical_datum: Option<i32>,
|
||||
}
|
||||
|
||||
impl<'a> GroupBuilder<'a> {
|
||||
@@ -67,8 +66,6 @@ impl<'a> GroupBuilder<'a> {
|
||||
next_node_id: FIRST_NODE_ID,
|
||||
entities: FxHashMap::default(),
|
||||
referenced_entities: Vec::new(),
|
||||
horizontal_datum: None,
|
||||
vertical_datum: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -396,6 +393,52 @@ impl<'a> GroupBuilder<'a> {
|
||||
Some(node_id)
|
||||
}
|
||||
|
||||
/// Returns a derived line node for a text baseline or ellipse axis. These
|
||||
/// are real native curve nodes tied to the source entity dependency, so
|
||||
/// the selected direction survives save/reopen instead of collapsing to
|
||||
/// the entity insertion/center point.
|
||||
fn directional_axis_node(&mut self, reference: ParametricRef) -> Option<i32> {
|
||||
let marker = reference.marker?;
|
||||
reference.directional_axis()?;
|
||||
if let Some(node_id) = self
|
||||
.entities
|
||||
.get(&reference.entity)
|
||||
.and_then(|entity| entity.axes.get(&marker))
|
||||
{
|
||||
return Some(*node_id);
|
||||
}
|
||||
let entity = self.document.get_entity(reference.entity)?;
|
||||
let [start, end] =
|
||||
super::parametric_constraints::directional_axis_endpoints(entity, reference)?;
|
||||
let delta = end - start;
|
||||
if delta.length_squared() <= 1.0e-24 {
|
||||
return None;
|
||||
}
|
||||
let node_id = self.alloc_node_id();
|
||||
self.push_node(
|
||||
node_id,
|
||||
"AcConstrainedBoundedLine",
|
||||
AssocConstraintNodeData::BoundedLine {
|
||||
geometry_dependency: Handle::NULL,
|
||||
geometry_node_id: node_id,
|
||||
point: start,
|
||||
direction: delta.normalize(),
|
||||
is_ray: false,
|
||||
start_point: start,
|
||||
end_point: end,
|
||||
},
|
||||
);
|
||||
self.entities
|
||||
.entry(reference.entity)
|
||||
.or_default()
|
||||
.axes
|
||||
.insert(marker, node_id);
|
||||
if !self.referenced_entities.contains(&reference.entity) {
|
||||
self.referenced_entities.push(reference.entity);
|
||||
}
|
||||
Some(node_id)
|
||||
}
|
||||
|
||||
/// Returns the point-node id for a line endpoint or curve center,
|
||||
/// creating it the first time the marker is referenced.
|
||||
fn point_node(&mut self, handle: Handle, marker: i32) -> Option<i32> {
|
||||
@@ -407,11 +450,17 @@ impl<'a> GroupBuilder<'a> {
|
||||
return Some(*existing);
|
||||
}
|
||||
let segment_midpoint = ParametricRef::point(handle, marker).segment_midpoint_index();
|
||||
let segment_center = ParametricRef::point(handle, marker).segment_center_index();
|
||||
let polyline = matches!(
|
||||
self.document.get_entity(handle),
|
||||
Some(EntityType::LwPolyline(_) | EntityType::Polyline2D(_))
|
||||
);
|
||||
let (curve_id, point_type) = if let Some(segment) = segment_midpoint {
|
||||
let (curve_id, point_type) = if let Some(segment) = segment_center {
|
||||
(
|
||||
self.segment_node(handle, segment)?,
|
||||
implicit_point_type::CENTER,
|
||||
)
|
||||
} else if let Some(segment) = segment_midpoint {
|
||||
(
|
||||
self.segment_node(handle, segment)?,
|
||||
implicit_point_type::MID,
|
||||
@@ -498,9 +547,15 @@ impl<'a> GroupBuilder<'a> {
|
||||
/// The node id `ParametricRef` resolves to: a point node for a marked
|
||||
/// reference, the whole geometry node otherwise.
|
||||
fn ref_node(&mut self, r: ParametricRef) -> Option<i32> {
|
||||
if r.directional_axis().is_some() {
|
||||
return self.directional_axis_node(r);
|
||||
}
|
||||
if r.segment_midpoint_index().is_some() {
|
||||
return self.point_node(r.entity, r.marker?);
|
||||
}
|
||||
if r.segment_center_index().is_some() {
|
||||
return self.point_node(r.entity, r.marker?);
|
||||
}
|
||||
if let Some(segment) = r.segment_index() {
|
||||
return self.segment_node(r.entity, segment);
|
||||
}
|
||||
@@ -603,15 +658,7 @@ impl<'a> GroupBuilder<'a> {
|
||||
Some(node_id)
|
||||
}
|
||||
|
||||
fn datum_line(&mut self, horizontal: bool) -> i32 {
|
||||
let cached = if horizontal {
|
||||
self.horizontal_datum
|
||||
} else {
|
||||
self.vertical_datum
|
||||
};
|
||||
if let Some(node_id) = cached {
|
||||
return node_id;
|
||||
}
|
||||
fn datum_line(&mut self, direction: Vector3) -> i32 {
|
||||
let node_id = self.alloc_node_id();
|
||||
self.push_node(
|
||||
node_id,
|
||||
@@ -620,18 +667,13 @@ impl<'a> GroupBuilder<'a> {
|
||||
geometry_dependency: Handle::NULL,
|
||||
geometry_node_id: node_id,
|
||||
point: Vector3::ZERO,
|
||||
direction: if horizontal {
|
||||
Vector3::UNIT_X
|
||||
direction: if direction.length_squared() > 1.0e-24 {
|
||||
direction.normalize()
|
||||
} else {
|
||||
Vector3::UNIT_Y
|
||||
Vector3::UNIT_X
|
||||
},
|
||||
},
|
||||
);
|
||||
if horizontal {
|
||||
self.horizontal_datum = Some(node_id);
|
||||
} else {
|
||||
self.vertical_datum = Some(node_id);
|
||||
}
|
||||
node_id
|
||||
}
|
||||
|
||||
@@ -721,7 +763,13 @@ fn constraint_node(
|
||||
) -> Option<i32> {
|
||||
let refs: &[ParametricRef] = &constraint.refs;
|
||||
if constraint.kind == ConstraintKind::Smooth {
|
||||
let [first, second] = refs else { return None };
|
||||
let (first, second, second_curve_ref) = match refs {
|
||||
[first, second] => (*first, *second, ParametricRef::whole(second.entity)),
|
||||
[first, second, second_curve] if second.entity == second_curve.entity => {
|
||||
(*first, *second, *second_curve)
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
let endpoint_parameter = |reference: ParametricRef| {
|
||||
let entity = document.get_entity(reference.entity)?;
|
||||
let EntityType::Spline(spline) = entity else {
|
||||
@@ -741,15 +789,15 @@ fn constraint_node(
|
||||
}?;
|
||||
Some(Some(value))
|
||||
};
|
||||
let first_parameter = endpoint_parameter(*first)?;
|
||||
let second_parameter = endpoint_parameter(*second)?;
|
||||
let first_parameter = endpoint_parameter(first)?;
|
||||
let second_parameter = endpoint_parameter(second)?;
|
||||
if first_parameter.is_none() && second_parameter.is_none() {
|
||||
return None;
|
||||
}
|
||||
let first_curve = builder.geometry_node(first.entity)?;
|
||||
let second_curve = builder.geometry_node(second.entity)?;
|
||||
let first_point = builder.ref_node(*first)?;
|
||||
let second_point = builder.ref_node(*second)?;
|
||||
let second_curve = builder.ref_node(second_curve_ref)?;
|
||||
let first_point = builder.ref_node(first)?;
|
||||
let second_point = builder.ref_node(second)?;
|
||||
|
||||
let coincidence = builder.alloc_node_id();
|
||||
builder.push_node(
|
||||
@@ -867,7 +915,12 @@ fn constraint_node(
|
||||
.map(|reference| builder.ref_node(*reference))
|
||||
.collect::<Option<_>>()?;
|
||||
let horizontal = constraint.kind == ConstraintKind::Horizontal;
|
||||
let datum = builder.datum_line(horizontal);
|
||||
let fallback = if horizontal {
|
||||
Vector3::UNIT_X
|
||||
} else {
|
||||
Vector3::UNIT_Y
|
||||
};
|
||||
let datum = builder.datum_line(constraint.axis_direction.unwrap_or(fallback));
|
||||
let node_id = builder.alloc_node_id();
|
||||
builder.push_node(
|
||||
node_id,
|
||||
@@ -1553,6 +1606,10 @@ fn dependency_entity(
|
||||
),
|
||||
AssocConstraintNodeData::Point { .. },
|
||||
) => true,
|
||||
(
|
||||
Some(EntityType::Text(_) | EntityType::MText(_) | EntityType::Ellipse(_)),
|
||||
AssocConstraintNodeData::BoundedLine { .. },
|
||||
) => true,
|
||||
(Some(EntityType::Line(_)), AssocConstraintNodeData::BoundedLine { is_ray, .. }) => !is_ray,
|
||||
(
|
||||
Some(EntityType::LwPolyline(_) | EntityType::Polyline2D(_)),
|
||||
@@ -1593,6 +1650,21 @@ fn group_requires_preservation(document: &CadDocument, group: &Assoc2dConstraint
|
||||
})
|
||||
}
|
||||
|
||||
fn work_plane_vector(work_plane: &[Vector3; 3], local: Vector3) -> Option<Vector3> {
|
||||
let [origin, axis_x, axis_y] = *work_plane;
|
||||
let plane = cadkernel::space::Plane::from_axes(
|
||||
[origin.x, origin.y, origin.z],
|
||||
[axis_x.x, axis_x.y, axis_x.z],
|
||||
[axis_y.x, axis_y.y, axis_y.z],
|
||||
);
|
||||
let mut world = cadkernel::space::Vec3::from(plane.vector_at([local.x, local.y]));
|
||||
if local.z != 0.0 {
|
||||
world = world + cadkernel::space::Vec3::from(plane.normal()?) * local.z;
|
||||
}
|
||||
let world = world.normalize()?;
|
||||
Some(Vector3::new(world.x, world.y, world.z))
|
||||
}
|
||||
|
||||
fn polyline_segment_reference(
|
||||
document: &CadDocument,
|
||||
entity: Handle,
|
||||
@@ -1643,6 +1715,52 @@ fn polyline_segment_reference(
|
||||
.map(|index| ParametricRef::segment(entity, index))
|
||||
}
|
||||
|
||||
fn directional_axis_reference(
|
||||
document: &CadDocument,
|
||||
entity: Handle,
|
||||
data: &AssocConstraintNodeData,
|
||||
work_plane: &[Vector3; 3],
|
||||
) -> Option<ParametricRef> {
|
||||
let AssocConstraintNodeData::BoundedLine {
|
||||
start_point,
|
||||
end_point,
|
||||
..
|
||||
} = data
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
match document.get_entity(entity)? {
|
||||
EntityType::Text(_) | EntityType::MText(_) => {
|
||||
Some(ParametricRef::text_baseline(entity))
|
||||
}
|
||||
EntityType::Ellipse(ellipse) => {
|
||||
let [origin, axis_x, axis_y] = *work_plane;
|
||||
let normal = Vector3::new(
|
||||
axis_x.y * axis_y.z - axis_x.z * axis_y.y,
|
||||
axis_x.z * axis_y.x - axis_x.x * axis_y.z,
|
||||
axis_x.x * axis_y.y - axis_x.y * axis_y.x,
|
||||
);
|
||||
let to_world = |point: Vector3| {
|
||||
origin + axis_x * point.x + axis_y * point.y + normal * point.z
|
||||
};
|
||||
let direction = (to_world(*end_point) - to_world(*start_point)).normalize();
|
||||
let major = ellipse.major_axis.normalize();
|
||||
let ellipse_normal = ellipse.normal.normalize();
|
||||
let minor = Vector3::new(
|
||||
ellipse_normal.y * major.z - ellipse_normal.z * major.y,
|
||||
ellipse_normal.z * major.x - ellipse_normal.x * major.z,
|
||||
ellipse_normal.x * major.y - ellipse_normal.y * major.x,
|
||||
);
|
||||
if direction.dot(&minor).abs() > direction.dot(&major).abs() {
|
||||
Some(ParametricRef::ellipse_minor_axis(entity))
|
||||
} else {
|
||||
Some(ParametricRef::ellipse_major_axis(entity))
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn numeric_value(value: &AssocEvalVariant) -> Option<f64> {
|
||||
match value.value {
|
||||
AssocEvalValue::Real(value) => Some(value),
|
||||
@@ -1927,6 +2045,14 @@ pub(super) fn native_constraint_set(
|
||||
if let Some(entity) = dependency_entity(document, dependency, &node.data) {
|
||||
let reference =
|
||||
polyline_segment_reference(document, entity, &node.data, &group.work_plane)
|
||||
.or_else(|| {
|
||||
directional_axis_reference(
|
||||
document,
|
||||
entity,
|
||||
&node.data,
|
||||
&group.work_plane,
|
||||
)
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
if matches!(node.data, AssocConstraintNodeData::Point { .. }) {
|
||||
ParametricRef::point(entity, 0)
|
||||
@@ -1965,6 +2091,9 @@ pub(super) fn native_constraint_set(
|
||||
implicit_point_type::MID => {
|
||||
ParametricRef::segment_midpoint(curve.entity, segment)
|
||||
}
|
||||
implicit_point_type::CENTER => {
|
||||
ParametricRef::segment_center(curve.entity, segment)
|
||||
}
|
||||
_ => continue,
|
||||
}
|
||||
} else {
|
||||
@@ -2022,7 +2151,7 @@ pub(super) fn native_constraint_set(
|
||||
owned_constraint_ids,
|
||||
..
|
||||
} if kind == ConstraintKind::Smooth => {
|
||||
let endpoints = owned_constraint_ids
|
||||
let mut endpoints: Vec<ParametricRef> = owned_constraint_ids
|
||||
.iter()
|
||||
.filter_map(|id| group.nodes.iter().find(|child| child.node_id == *id))
|
||||
.find(|child| {
|
||||
@@ -2038,6 +2167,23 @@ pub(super) fn native_constraint_set(
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
if let Some(target) = endpoints.get(1).copied() {
|
||||
let segment = owned_constraint_ids
|
||||
.iter()
|
||||
.filter_map(|id| group.nodes.iter().find(|child| child.node_id == *id))
|
||||
.filter(|child| {
|
||||
child.class_name.eq_ignore_ascii_case("AcTangentConstraint")
|
||||
})
|
||||
.flat_map(|child| child.connections.iter())
|
||||
.filter_map(|id| refs.get(id).copied())
|
||||
.find(|reference| {
|
||||
reference.entity == target.entity
|
||||
&& reference.segment_index().is_some()
|
||||
});
|
||||
if let Some(segment) = segment {
|
||||
endpoints.push(segment);
|
||||
}
|
||||
}
|
||||
(endpoints, Vec::new())
|
||||
}
|
||||
_ => (
|
||||
@@ -2084,6 +2230,7 @@ pub(super) fn native_constraint_set(
|
||||
ConstraintKind::DistanceDirected => matches!(targets.len(), 2 | 3),
|
||||
ConstraintKind::EqualDistance => targets.len() == 4,
|
||||
ConstraintKind::RigidSet => !targets.is_empty() && rigid_points.len() >= 2,
|
||||
ConstraintKind::Smooth => matches!(targets.len(), 2 | 3),
|
||||
_ => targets.len() == 2,
|
||||
};
|
||||
if !valid_target_count {
|
||||
@@ -2107,6 +2254,31 @@ pub(super) fn native_constraint_set(
|
||||
if let Some(constraint) = set.constraints.last_mut() {
|
||||
constraint.enabled = enabled;
|
||||
constraint.rigid_points = rigid_points;
|
||||
if matches!(kind, ConstraintKind::Horizontal | ConstraintKind::Vertical) {
|
||||
let datum_id = match &node.data {
|
||||
AssocConstraintNodeData::Parallel {
|
||||
datum_line_index, ..
|
||||
} => *datum_line_index,
|
||||
_ => None,
|
||||
};
|
||||
let local_direction = datum_id.and_then(|datum_id| {
|
||||
group.nodes.iter().find_map(|datum| {
|
||||
(datum.node_id == datum_id).then_some(&datum.data).and_then(|data| {
|
||||
match data {
|
||||
AssocConstraintNodeData::Line { direction, .. } => {
|
||||
Some(*direction)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
})
|
||||
});
|
||||
if let Some(world) =
|
||||
local_direction.and_then(|local| work_plane_vector(&group.work_plane, local))
|
||||
{
|
||||
constraint.axis_direction = Some(world);
|
||||
}
|
||||
}
|
||||
if let AssocConstraintNodeData::Distance {
|
||||
direction_type,
|
||||
distance,
|
||||
@@ -2337,7 +2509,7 @@ fn materialize_scope(
|
||||
continue;
|
||||
};
|
||||
if entity_nodes.geometry_node_id == 0 {
|
||||
if entity_nodes.segments.is_empty() {
|
||||
if entity_nodes.segments.is_empty() && entity_nodes.axes.is_empty() {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -2346,6 +2518,7 @@ fn materialize_scope(
|
||||
for node_id in std::iter::once(entity_nodes.geometry_node_id)
|
||||
.filter(|node_id| *node_id != 0)
|
||||
.chain(entity_nodes.segments.values().copied())
|
||||
.chain(entity_nodes.axes.values().copied())
|
||||
{
|
||||
if let Some(node) = nodes.iter_mut().find(|node| node.node_id == node_id) {
|
||||
set_geometry_dependency(&mut node.data, dep_handle);
|
||||
@@ -2736,14 +2909,15 @@ mod tests {
|
||||
fn horizontal_and_vertical_constraints_round_trip_through_dwg_and_dxf() {
|
||||
for ext in ["dwg", "dxf"] {
|
||||
let mut scene = Scene::new();
|
||||
let a = line_entity(&mut scene, (0.0, 0.0), (10.0, 0.0));
|
||||
let a = line_entity(&mut scene, (0.0, 0.0), (6.0, 8.0));
|
||||
let b = line_entity(&mut scene, (10.0, 0.0), (10.0, 5.0));
|
||||
let horizontal_direction = Vector3::new(0.6, 0.8, 0.0);
|
||||
scene
|
||||
.parametric_constraint_set_mut(ParametricScope::ModelSpace)
|
||||
.add(
|
||||
.add_axis_constraint(
|
||||
ConstraintKind::Horizontal,
|
||||
vec![ParametricRef::whole(a)],
|
||||
None,
|
||||
horizontal_direction,
|
||||
);
|
||||
scene
|
||||
.parametric_constraint_set_mut(ParametricScope::ModelSpace)
|
||||
@@ -2809,6 +2983,20 @@ mod tests {
|
||||
assert!(datums.iter().all(|datum| group.nodes.iter().any(|node| {
|
||||
node.node_id == *datum && node.class_name == "AcConstrainedDatumLine"
|
||||
})));
|
||||
|
||||
let mut restored = Scene::new();
|
||||
restored.document = reloaded;
|
||||
restored.load_parametric_constraints_from_document();
|
||||
let horizontal = restored
|
||||
.parametric_constraint_set(ParametricScope::ModelSpace)
|
||||
.unwrap()
|
||||
.constraints
|
||||
.iter()
|
||||
.find(|constraint| constraint.kind == ConstraintKind::Horizontal)
|
||||
.unwrap();
|
||||
assert!(
|
||||
(horizontal.axis_direction.unwrap() - horizontal_direction).length() < 1.0e-12
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+31
-10
@@ -134,7 +134,7 @@ impl Scene {
|
||||
let _ = self.document.layers.add(layer);
|
||||
}
|
||||
|
||||
fn ensure_app_id(&mut self, name: &str) {
|
||||
pub(super) fn ensure_app_id(&mut self, name: &str) {
|
||||
if name.trim().is_empty() || self.document.app_ids.contains(name) {
|
||||
return;
|
||||
}
|
||||
@@ -2156,19 +2156,19 @@ impl Scene {
|
||||
|
||||
fn populate_images_from_document_unbumped(&mut self) {
|
||||
self.images.clear();
|
||||
let entries: Vec<(Handle, acadrust::entities::RasterImage)> = self
|
||||
let entries: Vec<(Handle, acadrust::entities::EntityType)> = self
|
||||
.document
|
||||
.entities()
|
||||
.filter_map(|e| {
|
||||
if let EntityType::RasterImage(img) = e {
|
||||
Some((img.common.handle, img.clone()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
.filter(|e| {
|
||||
matches!(
|
||||
e,
|
||||
EntityType::RasterImage(_) | EntityType::Ole2Frame(_) | EntityType::Underlay(_)
|
||||
)
|
||||
})
|
||||
.map(|e| (e.common().handle, e.clone()))
|
||||
.collect();
|
||||
for (handle, img) in entries {
|
||||
if let Some(model) = ImageModel::from_raster_image(&img) {
|
||||
for (handle, entity) in entries {
|
||||
if let Some(model) = self.image_seed_for(&entity) {
|
||||
self.images.insert(handle, model);
|
||||
}
|
||||
}
|
||||
@@ -2712,4 +2712,25 @@ impl Scene {
|
||||
self.named_parameters = crate::scene::named_parameters::ParameterTable::new();
|
||||
self.bump_geometry();
|
||||
}
|
||||
|
||||
/// Reset to the drawing File → New produces. Every path that starts a
|
||||
/// fresh drawing (the New tab, CLEAR, the automation `new` op) goes
|
||||
/// through here so they all agree on what a blank drawing contains.
|
||||
pub fn reset_to_new_drawing(&mut self) {
|
||||
self.clear();
|
||||
self.populate_new_drawing_defaults();
|
||||
}
|
||||
|
||||
/// What a blank document gets on top of the codec's defaults: the
|
||||
/// standard linetypes and a compatible page setup on every paper layout.
|
||||
pub fn populate_new_drawing_defaults(&mut self) {
|
||||
crate::io::linetypes::populate_document(&mut self.document);
|
||||
for obj in self.document.objects.values_mut() {
|
||||
if let acadrust::objects::ObjectType::Layout(l) = obj {
|
||||
if l.name != "Model" {
|
||||
crate::scene::apply_default_page_setup(l, "");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+595
-25
@@ -31,6 +31,9 @@ mod camera_ops;
|
||||
pub(crate) mod centerline;
|
||||
pub(crate) mod centermark;
|
||||
pub(crate) mod dimension_assoc;
|
||||
pub(crate) mod dimension_assoc_chain;
|
||||
pub use dimension_assoc::{ReferenceStatus, ResolvedReference};
|
||||
pub use page_setup::{apply_default_page_setup, document_page_setups, rotated_margins};
|
||||
mod dwg_native_constraints;
|
||||
mod entity;
|
||||
#[cfg(test)]
|
||||
@@ -40,6 +43,8 @@ mod layout;
|
||||
mod limits;
|
||||
mod modify;
|
||||
mod mspace;
|
||||
pub mod viewport_ref;
|
||||
pub mod viewport_dimension_pick;
|
||||
pub mod named_parameters;
|
||||
mod page_setup;
|
||||
mod paper;
|
||||
@@ -47,7 +52,7 @@ mod preview;
|
||||
mod project;
|
||||
mod scene_markers;
|
||||
mod selection;
|
||||
pub(crate) use selection::pe_url_of;
|
||||
pub(crate) use selection::{pe_url_description_of, pe_url_of};
|
||||
pub mod parametric_constraints;
|
||||
mod parametric_solve;
|
||||
|
||||
@@ -159,6 +164,333 @@ pub(crate) fn tessellate_entity(
|
||||
wires
|
||||
}
|
||||
|
||||
impl Scene {
|
||||
/// True for the modeler solid families whose wire path is an empty shell
|
||||
/// (their geometry lives in the mesh pipeline): B-rep corners for those
|
||||
/// come from the mesh edge list instead of wire points.
|
||||
fn is_solid_shell_entity(entity: &EntityType) -> bool {
|
||||
matches!(
|
||||
entity,
|
||||
EntityType::Solid3D(_)
|
||||
| EntityType::Region(_)
|
||||
| EntityType::Body(_)
|
||||
| EntityType::Surface(_)
|
||||
)
|
||||
}
|
||||
|
||||
/// B-rep snap points for a solid: edge endpoints as `Vertex` hints and
|
||||
/// edge centres as `EdgeMidpoint` hints (both from the tessellated edge
|
||||
/// list), plus face centres as `FaceCenter` hints (from the kernel B-rep
|
||||
/// faces). Empty for non-solids and for solids with no usable data.
|
||||
pub(crate) fn solid_snap_points(
|
||||
&self,
|
||||
handle: Handle,
|
||||
) -> Vec<(glam::DVec3, crate::scene::model::wire_model::SnapHint)> {
|
||||
use crate::scene::model::wire_model::SnapHint;
|
||||
let Some(entity) = self.document.get_entity(handle) else {
|
||||
return Vec::new();
|
||||
};
|
||||
if !Self::is_solid_shell_entity(entity) {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
if let Some(set) = self
|
||||
.meshes
|
||||
.get(&handle)
|
||||
.or_else(|| self.block_meshes.get(&handle))
|
||||
{
|
||||
let (edges, lows) = set.geometry_edges();
|
||||
let mut seen = HashSet::default();
|
||||
for (a, b) in
|
||||
crate::scene::model::mesh_model::solid_edge_segments(edges, lows)
|
||||
{
|
||||
for (point, hint) in [(a, SnapHint::Vertex), (b, SnapHint::Vertex)] {
|
||||
let key = [point.x.to_bits(), point.y.to_bits(), point.z.to_bits()];
|
||||
if seen.insert(key) {
|
||||
out.push((point, hint));
|
||||
}
|
||||
}
|
||||
out.push(((a + b) * 0.5, SnapHint::EdgeMidpoint));
|
||||
}
|
||||
}
|
||||
out.extend(
|
||||
Self::solid_face_centers(entity, self.solid_models.get(&handle))
|
||||
.into_iter()
|
||||
.map(|p| (p, SnapHint::FaceCenter)),
|
||||
);
|
||||
out
|
||||
}
|
||||
|
||||
/// Face centres for a solid entity: kernel B-rep faces averaged over
|
||||
/// their boundary loops. Prefers the cached kernel body (no re-parse);
|
||||
/// falls back to parsing the entity's ACIS data, which shares the cost
|
||||
/// profile of the mesh build at the same epoch. No body ⇒ no centres.
|
||||
fn solid_face_centers(
|
||||
entity: &EntityType,
|
||||
cached: Option<&cadkernel::brep::Body>,
|
||||
) -> Vec<glam::DVec3> {
|
||||
use crate::scene::convert::solid3d_tess::{kernel_body, kernel_region_body};
|
||||
use crate::scene::model::solid_model::face_centers;
|
||||
if let Some(body) = cached {
|
||||
return face_centers(body)
|
||||
.into_iter()
|
||||
.map(glam::DVec3::from_array)
|
||||
.collect();
|
||||
}
|
||||
match entity {
|
||||
EntityType::Solid3D(s) => kernel_body(s),
|
||||
EntityType::Region(r) => kernel_region_body(r),
|
||||
_ => None,
|
||||
}
|
||||
.map(|body| face_centers(&body))
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(glam::DVec3::from_array)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Attach solid B-rep snaps to the entity's shell wire (the empty wire
|
||||
/// carrying only the insertion snap). History/construction wires keep
|
||||
/// their own points and are left alone; non-solids are untouched.
|
||||
/// Idempotent: a wire already carrying 3D snaps is skipped, so shared
|
||||
/// memo entries can pass through every assembly path safely.
|
||||
pub(crate) fn attach_solid_snaps(&self, handle: Handle, wires: &mut Vec<WireModel>) {
|
||||
use crate::scene::model::wire_model::SnapHint;
|
||||
let points = self.solid_snap_points(handle);
|
||||
if points.is_empty() {
|
||||
return;
|
||||
}
|
||||
for wire in wires.iter_mut() {
|
||||
if !wire.points.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if wire.snap_pts.iter().any(|(_, hint)| {
|
||||
matches!(
|
||||
hint,
|
||||
SnapHint::Vertex
|
||||
| SnapHint::EdgeMidpoint
|
||||
| SnapHint::FaceCenter
|
||||
| SnapHint::Knot
|
||||
)
|
||||
}) {
|
||||
continue;
|
||||
}
|
||||
wire.snap_pts.extend(points.iter().copied());
|
||||
}
|
||||
}
|
||||
|
||||
/// Live solid-face snaps for a command point: Nearest-to-face (screen
|
||||
/// nearest point on a mesh triangle) and Perpendicular-to-face (foot from
|
||||
/// `base` onto a triangle). Mesh triangles carry no B-rep face grouping,
|
||||
/// so faces are individual triangles here — the pre-baked `FaceCenter`
|
||||
/// hints (true B-rep faces) are the separate discrete counterpart.
|
||||
///
|
||||
/// Top-level model meshes only: block-local sets live in a different
|
||||
/// space, and xclipped views are not honored (the wire channel the
|
||||
/// pre-baked 3D hints ride is clipped; this direct mesh read is not).
|
||||
/// Worst case in those views is a snap with no geometry behind it —
|
||||
/// never a corrupt point.
|
||||
pub(crate) fn solid_face_snaps(
|
||||
&self,
|
||||
cursor_screen: iced::Point,
|
||||
view_rot: glam::Mat4,
|
||||
eye: glam::DVec3,
|
||||
bounds: iced::Rectangle,
|
||||
aperture_px: f32,
|
||||
base: Option<glam::DVec3>,
|
||||
want_perp: bool,
|
||||
want_nearest: bool,
|
||||
) -> Option<crate::snap::SnapResult> {
|
||||
use crate::snap::{
|
||||
closest_point_on_tri_2d, foot_on_triangle, snap_better, snap_priority,
|
||||
snap_tier, SnapType,
|
||||
};
|
||||
if !want_perp && !want_nearest {
|
||||
return None;
|
||||
}
|
||||
if !(aperture_px.is_finite() && aperture_px > 0.0) {
|
||||
return None;
|
||||
}
|
||||
let perp_base = want_perp.then(|| base).flatten();
|
||||
let project = |world: glam::DVec3| {
|
||||
let ndc = view_rot.project_point3((world - eye).as_vec3());
|
||||
[
|
||||
(ndc.x + 1.0) * 0.5 * bounds.width,
|
||||
(1.0 - ndc.y) * 0.5 * bounds.height,
|
||||
]
|
||||
};
|
||||
let finite_screen = |s: [f32; 2]| s[0].is_finite() && s[1].is_finite();
|
||||
let in_bounds = |s: [f32; 2]| {
|
||||
s[0] >= 0.0 && s[0] <= bounds.width && s[1] >= 0.0 && s[1] <= bounds.height
|
||||
};
|
||||
let radius2 = aperture_px * aperture_px;
|
||||
let cursor = [cursor_screen.x, cursor_screen.y];
|
||||
// (tier, d2, sub, eye-depth², handle, hit): engine ordering, then
|
||||
// nearer-to-eye (coincident faces share pixels — the top face must
|
||||
// beat the bottom one), then the handle as a final deterministic
|
||||
// tie-break (mesh iteration order is not stable).
|
||||
let mut best: Option<(u8, f32, u8, f64, u64, crate::snap::SnapResult)> = None;
|
||||
let mut consider = |snap_type: SnapType,
|
||||
world: glam::DVec3,
|
||||
screen: [f32; 2],
|
||||
d2: f32,
|
||||
handle: Handle| {
|
||||
if !(d2 < radius2) || !in_bounds(screen) {
|
||||
return;
|
||||
}
|
||||
let (tier, sub) = (snap_tier(snap_type), snap_priority(snap_type));
|
||||
let depth2 = (world - eye).length_squared();
|
||||
let better = match &best {
|
||||
None => true,
|
||||
Some((bt, bd2, bs, bdepth2, bh, _)) => {
|
||||
snap_better(tier, d2, sub, (*bt, *bd2, *bs))
|
||||
|| (tier == *bt
|
||||
&& (d2 - *bd2).abs() <= 1e-4
|
||||
&& sub == *bs
|
||||
&& (depth2 < *bdepth2
|
||||
|| (depth2 == *bdepth2 && handle.value() < *bh)))
|
||||
}
|
||||
};
|
||||
if better {
|
||||
best = Some((
|
||||
tier,
|
||||
d2,
|
||||
sub,
|
||||
depth2,
|
||||
handle.value(),
|
||||
crate::snap::SnapResult {
|
||||
world,
|
||||
screen: iced::Point::new(screen[0], screen[1]),
|
||||
snap_type,
|
||||
tangent_obj: None,
|
||||
extension_base: None,
|
||||
extension_base2: None,
|
||||
extension_origin: None,
|
||||
extension_dir: None,
|
||||
viewport: None,
|
||||
source: Some(
|
||||
crate::command::DimensionAssociationSource::inferred(handle),
|
||||
),
|
||||
secondary_source: None,
|
||||
model_point: None,
|
||||
},
|
||||
));
|
||||
}
|
||||
};
|
||||
for (handle, set) in self.meshes.iter() {
|
||||
let Some(lod) = set.geometry_lods().first() else {
|
||||
continue;
|
||||
};
|
||||
if lod.indices.len() < 3 {
|
||||
continue;
|
||||
}
|
||||
// Screen-space cull from the mesh bounds before touching triangles.
|
||||
let (mut x0, mut y0) = (f32::INFINITY, f32::INFINITY);
|
||||
let (mut x1, mut y1) = (f32::NEG_INFINITY, f32::NEG_INFINITY);
|
||||
for corner in [
|
||||
[set.world_aabb[0], set.world_aabb[1], set.z_aabb[0]],
|
||||
[set.world_aabb[2], set.world_aabb[1], set.z_aabb[0]],
|
||||
[set.world_aabb[0], set.world_aabb[3], set.z_aabb[0]],
|
||||
[set.world_aabb[2], set.world_aabb[3], set.z_aabb[0]],
|
||||
[set.world_aabb[0], set.world_aabb[1], set.z_aabb[1]],
|
||||
[set.world_aabb[2], set.world_aabb[1], set.z_aabb[1]],
|
||||
[set.world_aabb[0], set.world_aabb[3], set.z_aabb[1]],
|
||||
[set.world_aabb[2], set.world_aabb[3], set.z_aabb[1]],
|
||||
] {
|
||||
let s = project(glam::DVec3::new(
|
||||
corner[0] as f64,
|
||||
corner[1] as f64,
|
||||
corner[2] as f64,
|
||||
));
|
||||
if !finite_screen(s) {
|
||||
continue;
|
||||
}
|
||||
x0 = x0.min(s[0]);
|
||||
y0 = y0.min(s[1]);
|
||||
x1 = x1.max(s[0]);
|
||||
y1 = y1.max(s[1]);
|
||||
}
|
||||
if cursor[0] < x0 - aperture_px
|
||||
|| cursor[0] > x1 + aperture_px
|
||||
|| cursor[1] < y0 - aperture_px
|
||||
|| cursor[1] > y1 + aperture_px
|
||||
{
|
||||
continue;
|
||||
}
|
||||
for tri in lod.indices.chunks_exact(3) {
|
||||
let mut v = [glam::DVec3::ZERO; 3];
|
||||
let mut ok = true;
|
||||
for (slot, index) in v.iter_mut().zip(tri.iter()) {
|
||||
let (Some(high), low) = (
|
||||
lod.verts.get(*index as usize),
|
||||
lod.verts_low.get(*index as usize).copied().unwrap_or([0.0; 3]),
|
||||
) else {
|
||||
ok = false;
|
||||
break;
|
||||
};
|
||||
*slot = glam::DVec3::new(
|
||||
high[0] as f64 + low[0] as f64,
|
||||
high[1] as f64 + low[1] as f64,
|
||||
high[2] as f64 + low[2] as f64,
|
||||
);
|
||||
if !slot.is_finite() {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
continue;
|
||||
}
|
||||
let [a, b, c] = v;
|
||||
let (sa, sb, sc) = (project(a), project(b), project(c));
|
||||
if !(finite_screen(sa) && finite_screen(sb) && finite_screen(sc)) {
|
||||
continue;
|
||||
}
|
||||
// Per-triangle screen reject before any 3D work.
|
||||
let (tx0, ty0) = (
|
||||
sa[0].min(sb[0]).min(sc[0]),
|
||||
sa[1].min(sb[1]).min(sc[1]),
|
||||
);
|
||||
let (tx1, ty1) = (
|
||||
sa[0].max(sb[0]).max(sc[0]),
|
||||
sa[1].max(sb[1]).max(sc[1]),
|
||||
);
|
||||
if cursor[0] < tx0 - aperture_px
|
||||
|| cursor[0] > tx1 + aperture_px
|
||||
|| cursor[1] < ty0 - aperture_px
|
||||
|| cursor[1] > ty1 + aperture_px
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if want_nearest {
|
||||
let (q, d2, w) = closest_point_on_tri_2d(cursor, sa, sb, sc);
|
||||
if d2 < radius2 {
|
||||
let world = a * w[0] as f64 + b * w[1] as f64 + c * w[2] as f64;
|
||||
consider(SnapType::NearestFace, world, q, d2, *handle);
|
||||
}
|
||||
}
|
||||
if let Some(base3d) = perp_base {
|
||||
if let Some(foot) = foot_on_triangle(base3d, a, b, c) {
|
||||
let sf = project(foot);
|
||||
if finite_screen(sf) {
|
||||
let dx = sf[0] - cursor[0];
|
||||
let dy = sf[1] - cursor[1];
|
||||
consider(
|
||||
SnapType::FacePerpendicular,
|
||||
foot,
|
||||
sf,
|
||||
dx * dx + dy * dy,
|
||||
*handle,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
best.map(|(_, _, _, _, _, hit)| hit)
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of `Scene::entity_index()`. The wire path queries `tree` for
|
||||
/// view-rect candidates and also always emits `unbounded_handles`
|
||||
/// (entities with no usable bbox — legacy `UNBOUNDED_AABB` sentinel).
|
||||
@@ -1683,6 +2015,10 @@ pub struct Scene {
|
||||
pub hover_highlight: Option<Handle>,
|
||||
/// All entities related to the constraint indicator currently under the cursor.
|
||||
constraint_hover_highlights: HashSet<Handle>,
|
||||
/// Sub-entity references related to that indicator. Polyline segments are
|
||||
/// drawn separately so hovering one constraint does not light the whole chain.
|
||||
constraint_hover_refs: Vec<crate::scene::parametric_constraints::ParametricRef>,
|
||||
constraint_hover_wires: Vec<WireModel>,
|
||||
/// Color used to draw the selection highlight overlay wires (SELECTIONEFFECTCOLOR).
|
||||
pub selection_color: [f32; 4],
|
||||
/// Whether selection visual effect (glow/highlight) is enabled (SELECTIONEFFECT).
|
||||
@@ -1868,7 +2204,8 @@ pub struct Scene {
|
||||
RefCell<HashMap<(String, String), Option<Arc<crate::io::plot_style::PlotStyleTable>>>>,
|
||||
styled_wire_cache: RefCell<HashMap<(u64, String), (u64, Arc<Vec<WireModel>>)>>,
|
||||
styled_hatch_cache: RefCell<HashMap<(u64, usize, usize, String, u32), Arc<Vec<HatchModel>>>>,
|
||||
styled_wire_fill_cache: RefCell<HashMap<(u64, String, u32), Arc<Vec<HatchModel>>>>,
|
||||
styled_wire_fill_cache:
|
||||
RefCell<HashMap<(u64, String, u32, u64), Arc<Vec<HatchModel>>>>,
|
||||
/// Per-viewport projected wire cache for paper-space content viewports.
|
||||
/// Stores projected + clipped wires in paper-space coordinates.
|
||||
/// Maps vp_handle → (geometry_epoch, Vec<WireModel>).
|
||||
@@ -2213,6 +2550,8 @@ impl Scene {
|
||||
refedit_keep: None,
|
||||
hover_highlight: None,
|
||||
constraint_hover_highlights: HashSet::default(),
|
||||
constraint_hover_refs: Vec::new(),
|
||||
constraint_hover_wires: Vec::new(),
|
||||
selection_color: crate::scene::model::wire_model::WireModel::SELECTED,
|
||||
selection_effect: true,
|
||||
transparency_display: true,
|
||||
@@ -2873,6 +3212,66 @@ impl Scene {
|
||||
driven_refs: &[parametric_constraints::ParametricRef],
|
||||
retain_size: bool,
|
||||
retained_originals: &[(Handle, EntityType)],
|
||||
) {
|
||||
self.bump_entities_with_solve_policy(
|
||||
changes,
|
||||
driven_refs,
|
||||
retain_size,
|
||||
retained_originals,
|
||||
&[],
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
/// Refresh display and associations after a solved grip or exact history restore.
|
||||
pub(crate) fn bump_entities_after_parametric_solve(&mut self, changes: &[(Handle, ChangeKind)]) {
|
||||
self.bump_entities_with_solve_policy(changes, &[], false, &[], &[], false);
|
||||
}
|
||||
|
||||
/// Apply a newly-created ordered relation while temporarily anchoring
|
||||
/// its reference side. The anchors guide only this first solve and are
|
||||
/// never added to the persistent constraint graph.
|
||||
pub fn bump_entities_with_initial_parametric_policy(
|
||||
&mut self,
|
||||
changes: &[(Handle, ChangeKind)],
|
||||
fixed_refs: &[parametric_constraints::ParametricRef],
|
||||
retain_size: bool,
|
||||
) {
|
||||
self.bump_entities_with_solve_policy(
|
||||
changes,
|
||||
&[],
|
||||
retain_size,
|
||||
&[],
|
||||
fixed_refs,
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
pub fn bump_entities_with_parametric_transform_policy(
|
||||
&mut self,
|
||||
changes: &[(Handle, ChangeKind)],
|
||||
driven_refs: &[parametric_constraints::ParametricRef],
|
||||
transformed_refs: &[parametric_constraints::ParametricRef],
|
||||
retained_originals: &[(Handle, EntityType)],
|
||||
) {
|
||||
self.bump_entities_with_solve_policy(
|
||||
changes,
|
||||
driven_refs,
|
||||
true,
|
||||
retained_originals,
|
||||
transformed_refs,
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
fn bump_entities_with_solve_policy(
|
||||
&mut self,
|
||||
changes: &[(Handle, ChangeKind)],
|
||||
driven_refs: &[parametric_constraints::ParametricRef],
|
||||
retain_size: bool,
|
||||
retained_originals: &[(Handle, EntityType)],
|
||||
fixed_refs: &[parametric_constraints::ParametricRef],
|
||||
solve_parametric: bool,
|
||||
) {
|
||||
if changes.iter().any(|(handle, kind)| {
|
||||
matches!(kind, ChangeKind::Removed)
|
||||
@@ -2906,12 +3305,13 @@ impl Scene {
|
||||
changes.push(change);
|
||||
}
|
||||
}
|
||||
if !self.parametric_constraints.is_empty() {
|
||||
for change in self.refresh_parametric_constraints_with_originals(
|
||||
if solve_parametric && !self.parametric_constraints.is_empty() {
|
||||
for change in self.refresh_parametric_constraints_with_initial_policy(
|
||||
&changes,
|
||||
driven_refs,
|
||||
retain_size,
|
||||
retained_originals,
|
||||
fixed_refs,
|
||||
) {
|
||||
if !changes.iter().any(|(handle, _)| *handle == change.0) {
|
||||
changes.push(change);
|
||||
@@ -3980,13 +4380,8 @@ impl Scene {
|
||||
None
|
||||
})?;
|
||||
// `paper_limits()` already swaps the sheet for a 90°/270° rotation, so the
|
||||
// margins must rotate to the same edges: a margin on a physical side moves
|
||||
// to the displayed side that side rotates onto.
|
||||
let (ml, mb, mr, mt) = match rot {
|
||||
1 | 3 => (bottom, left, top, right),
|
||||
2 => (right, top, left, bottom),
|
||||
_ => (left, bottom, right, top),
|
||||
};
|
||||
// margins must rotate to the same edges.
|
||||
let (ml, mb, mr, mt) = page_setup::rotated_margins((left, bottom, right, top), rot);
|
||||
// Plot margins are millimetres like the paper size; scale them into the
|
||||
// layout's paper-space units so the inset matches the (already scaled)
|
||||
// sheet rect. Without this an inch paper space insets an ~8-inch sheet
|
||||
@@ -4572,6 +4967,78 @@ impl Scene {
|
||||
.then_some(owner)
|
||||
}
|
||||
|
||||
/// Millimetres in one unit of the paper side of this drawing's scale
|
||||
/// family: 25.4 for an imperial drawing (whose `1/8" = 1'-0"` style scales
|
||||
/// measure paper in inches), 1 for a metric one. Plot-scale factors built
|
||||
/// from [`Scene::scale_list`] are expressed per this unit; page setups
|
||||
/// store theirs per the layout's own paper unit, so the two are converted
|
||||
/// through this value.
|
||||
pub(crate) fn scale_family_unit_mm(&self) -> f64 {
|
||||
if self.prefers_imperial_scales() == Some(true) {
|
||||
25.4
|
||||
} else {
|
||||
1.0
|
||||
}
|
||||
}
|
||||
|
||||
/// The `paper : drawing` ratio behind a scale-list name, from the
|
||||
/// drawing's own `Scale` object when it has one, otherwise from the
|
||||
/// built-in family. A ratio keeps the plot scale readable in a page setup
|
||||
/// (`1 : 250` rather than `0.004 : 1`).
|
||||
pub(crate) fn scale_ratio(&self, name: &str) -> Option<(f64, f64)> {
|
||||
use acadrust::objects::ObjectType;
|
||||
let stored = self.document.objects.values().find_map(|o| match o {
|
||||
ObjectType::Scale(s) if !s.is_temporary && s.name.eq_ignore_ascii_case(name) => {
|
||||
Some((s.paper_units, s.drawing_units))
|
||||
}
|
||||
_ => None,
|
||||
});
|
||||
stored
|
||||
.or_else(|| Self::parse_scale_name_ratio(name))
|
||||
.filter(|(paper, drawing)| *paper > 0.0 && *drawing > 0.0)
|
||||
}
|
||||
|
||||
/// The `paper : drawing` ratio spelled by a scale name: `1:250` gives
|
||||
/// `(1, 250)`, an architectural `1/8" = 1'-0"` gives `(0.125, 12)` in
|
||||
/// inches. Anything else is not a scale name.
|
||||
pub(crate) fn parse_scale_name_ratio(name: &str) -> Option<(f64, f64)> {
|
||||
if let Some((paper, drawing)) = name.split_once(':') {
|
||||
let paper: f64 = paper.trim().parse().ok()?;
|
||||
let drawing: f64 = drawing.trim().parse().ok()?;
|
||||
return (paper > 0.0 && drawing > 0.0).then_some((paper, drawing));
|
||||
}
|
||||
let (paper, drawing) = name.split_once('=')?;
|
||||
let paper = Self::parse_feet_inches(paper)?;
|
||||
let drawing = Self::parse_feet_inches(drawing)?;
|
||||
(paper > 0.0 && drawing > 0.0).then_some((paper, drawing))
|
||||
}
|
||||
|
||||
/// Inches in a `F'-I"` / `I"` / `F'` length, where the inch part may be a
|
||||
/// fraction (`3/32"`).
|
||||
fn parse_feet_inches(text: &str) -> Option<f64> {
|
||||
let text = text.trim();
|
||||
let (feet, inches) = match text.split_once('\'') {
|
||||
Some((feet, rest)) => (
|
||||
feet.trim().parse::<f64>().ok()?,
|
||||
rest.trim_start_matches('-').trim(),
|
||||
),
|
||||
None => (0.0, text),
|
||||
};
|
||||
let inches = inches.trim_end_matches('"').trim();
|
||||
let inches = if inches.is_empty() {
|
||||
0.0
|
||||
} else if let Some((num, den)) = inches.split_once('/') {
|
||||
let den: f64 = den.trim().parse().ok()?;
|
||||
if den <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
num.trim().parse::<f64>().ok()? / den
|
||||
} else {
|
||||
inches.parse::<f64>().ok()?
|
||||
};
|
||||
Some(feet * 12.0 + inches)
|
||||
}
|
||||
|
||||
/// Handle of the real (non-temporary) `Scale` object with this display name.
|
||||
/// Scales are matched by their `name` field, not the dictionary key — some
|
||||
/// files key the entries by an internal identifier (`*A1`, `A0`, …) that
|
||||
@@ -5124,12 +5591,48 @@ impl Scene {
|
||||
handles
|
||||
}
|
||||
|
||||
pub fn set_constraint_hover_highlights(&mut self, handles: &[Handle]) {
|
||||
let desired: HashSet<_> = handles.iter().copied().collect();
|
||||
if desired != self.constraint_hover_highlights {
|
||||
self.constraint_hover_highlights = desired;
|
||||
self.bump_selection();
|
||||
pub fn set_constraint_hover_highlights(
|
||||
&mut self,
|
||||
references: &[crate::scene::parametric_constraints::ParametricRef],
|
||||
) {
|
||||
let mut references = references.to_vec();
|
||||
references.dedup();
|
||||
if references == self.constraint_hover_refs {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut handles = HashSet::default();
|
||||
let mut wires = Vec::new();
|
||||
for reference in &references {
|
||||
let Some(segment) = reference.segment_index() else {
|
||||
handles.insert(reference.entity);
|
||||
continue;
|
||||
};
|
||||
let Some(entity) = self.document.get_entity(reference.entity) else {
|
||||
continue;
|
||||
};
|
||||
let Some(mut curve) = crate::entities::curve::entity_curve(entity) else {
|
||||
handles.insert(reference.entity);
|
||||
continue;
|
||||
};
|
||||
let Some(segment_curve) = curve.curve.segments().into_iter().nth(segment) else {
|
||||
continue;
|
||||
};
|
||||
curve.curve = segment_curve;
|
||||
let mut wire = WireModel::solid_f64(
|
||||
format!("constraint-segment:{}:{segment}", reference.entity.value()),
|
||||
curve.tessellate(8.0),
|
||||
WireModel::HOVER,
|
||||
false,
|
||||
);
|
||||
wire.set_fixed_screen_width(2.0);
|
||||
wires.push(wire);
|
||||
}
|
||||
|
||||
self.constraint_hover_refs = references;
|
||||
self.constraint_hover_highlights = handles;
|
||||
self.constraint_hover_wires = wires;
|
||||
self.bump_selection();
|
||||
}
|
||||
|
||||
/// Keep the current selection visible and temporarily filter every other
|
||||
@@ -5380,6 +5883,10 @@ impl Scene {
|
||||
self.document
|
||||
.set_viewport_annotation_scale(viewport, scale_handle);
|
||||
|
||||
// Viewport scale changed: the dimensions drawn on the sheet
|
||||
// through it measure the same model geometry at a new paper
|
||||
// size and have to be re-placed.
|
||||
self.notify_viewport_changed(viewport);
|
||||
self.resident_wire_sets.borrow_mut().clear();
|
||||
self.bump_geometry();
|
||||
|
||||
@@ -5426,6 +5933,10 @@ impl Scene {
|
||||
vp.view_height = vp.height / factor;
|
||||
self.document
|
||||
.set_viewport_annotation_scale(viewport, scale_handle);
|
||||
// Viewport scale changed: the dimensions drawn on the sheet
|
||||
// through it measure the same model geometry at a new paper
|
||||
// size and have to be re-placed.
|
||||
self.notify_viewport_changed(viewport);
|
||||
self.resident_wire_sets.borrow_mut().clear();
|
||||
self.bump_geometry();
|
||||
true
|
||||
@@ -5884,7 +6395,7 @@ impl Scene {
|
||||
continue;
|
||||
}
|
||||
visible_changed.insert(*h);
|
||||
let raw = tessellate_entity(
|
||||
let mut raw = tessellate_entity(
|
||||
&self.document,
|
||||
&empty_sel,
|
||||
style_viewport.or(self.active_viewport),
|
||||
@@ -5897,6 +6408,10 @@ impl Scene {
|
||||
None,
|
||||
anno_scale_override.is_some(),
|
||||
);
|
||||
// Solid B-rep corners snap via the mesh edge list (their wire
|
||||
// path is an empty shell). Enrich before the memo/assembly split
|
||||
// so both share the same snap-bearing wires.
|
||||
self.attach_solid_snaps(*h, &mut raw);
|
||||
memo_updates.push((*h, Arc::new(raw.clone())));
|
||||
let mut faded = raw;
|
||||
self.apply_refedit_fade(&mut faded, bg);
|
||||
@@ -9835,9 +10350,15 @@ impl Scene {
|
||||
let mut out = hit_wires;
|
||||
{
|
||||
let mut memo = memo_cell.borrow_mut();
|
||||
for (h, a) in &miss_pairs {
|
||||
for (h, a) in miss_pairs {
|
||||
// Uniquely owned (just built above): enrich solid shells
|
||||
// with B-rep vertex snaps before the memo/assembly split.
|
||||
let mut wires =
|
||||
Arc::try_unwrap(a).unwrap_or_else(|a| a.as_ref().clone());
|
||||
self.attach_solid_snaps(h, &mut wires);
|
||||
let a = Arc::new(wires);
|
||||
out.extend(a.iter().cloned());
|
||||
memo.insert(*h, Arc::clone(a));
|
||||
memo.insert(h, Arc::clone(&a));
|
||||
}
|
||||
}
|
||||
materialize_ms = crate::perf::elapsed_ms(t_materialize);
|
||||
@@ -10245,7 +10766,7 @@ vis_index={:.1} visible_probe={:.1}",
|
||||
}
|
||||
|
||||
fn rebuild_dependency_index(&self) -> SceneDependencyIndex {
|
||||
let layout_blocks: HashSet<Handle> = self
|
||||
let mut layout_blocks: HashSet<Handle> = self
|
||||
.document
|
||||
.objects
|
||||
.values()
|
||||
@@ -10256,6 +10777,14 @@ vis_index={:.1} visible_probe={:.1}",
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
// DWG imports may have model/paper block records without matching
|
||||
// Layout objects. They still own directly rendered entities, so their
|
||||
// layer dependencies must resolve to those entity handles.
|
||||
layout_blocks.extend(self.document.block_records.iter().filter_map(|record| {
|
||||
let name = normalize_name(&record.name);
|
||||
(name.starts_with("*MODEL_SPACE") || name.starts_with("*PAPER_SPACE"))
|
||||
.then_some(record.handle)
|
||||
}));
|
||||
let block_names: HashMap<Handle, String> = self
|
||||
.document
|
||||
.block_records
|
||||
@@ -10796,7 +11325,7 @@ vis_index={:.1} visible_probe={:.1}",
|
||||
let (bg, anno, annotation_scale_handle, blk_cache, paper) = self.tessellation_context();
|
||||
// tessellate_one is used for one-off lookups (hit test, properties).
|
||||
// Skip culling here so the caller always gets the full geometry.
|
||||
tessellate_entity(
|
||||
let mut wires = tessellate_entity(
|
||||
&self.document,
|
||||
&self.selected,
|
||||
self.active_viewport,
|
||||
@@ -10808,7 +11337,11 @@ vis_index={:.1} visible_probe={:.1}",
|
||||
None,
|
||||
None,
|
||||
paper,
|
||||
)
|
||||
);
|
||||
// Same solid-corner snaps as the resident paths, so one-off consumers
|
||||
// (overlay changed-wires, edit previews) agree with hit-test/snap.
|
||||
self.attach_solid_snaps(e.common().handle, &mut wires);
|
||||
wires
|
||||
}
|
||||
|
||||
fn model_space_block_handle(&self) -> Handle {
|
||||
@@ -10855,11 +11388,21 @@ vis_index={:.1} visible_probe={:.1}",
|
||||
/// when something is selected. `None` keeps the current camera target as
|
||||
/// the orbit centre. (#229)
|
||||
pub fn orbit_pivot(&self) -> Option<glam::DVec3> {
|
||||
self.navigation_selection_bounds()
|
||||
.map(|(min, max)| (min + max) * 0.5)
|
||||
}
|
||||
|
||||
pub(crate) fn navigation_selection_bounds(&self) -> Option<(glam::DVec3, glam::DVec3)> {
|
||||
if self.selected.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let block = self.current_layout_block_handle();
|
||||
let scale = if self.current_layout == "Model" {
|
||||
let model_view = self.current_layout == "Model" || self.active_viewport.is_some();
|
||||
let block = if self.active_viewport.is_some() {
|
||||
self.model_space_block_handle()
|
||||
} else {
|
||||
self.current_layout_block_handle()
|
||||
};
|
||||
let scale = if model_view {
|
||||
crate::scene::annotative::scale_handle_by_name(
|
||||
&self.document,
|
||||
&self.document.header.current_annotation_scale,
|
||||
@@ -10916,7 +11459,7 @@ vis_index={:.1} visible_probe={:.1}",
|
||||
}
|
||||
}
|
||||
if any {
|
||||
Some((min + max) * 0.5)
|
||||
Some((min, max))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@@ -11631,6 +12174,33 @@ mod journal_tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imported_model_space_hatch_without_layout_object_is_a_layer_target() {
|
||||
use acadrust::entities::Hatch;
|
||||
use acadrust::objects::ObjectType;
|
||||
use acadrust::tables::layer::Layer;
|
||||
|
||||
let mut scene = Scene::new();
|
||||
scene.document.layers.add_or_replace(Layer::new("Shadows"));
|
||||
let mut hatch = Hatch::new();
|
||||
hatch.common.layer = "Shadows".to_string();
|
||||
let handle = scene.add_entity(EntityType::Hatch(hatch));
|
||||
let model = scene.model_space_block_handle();
|
||||
assert_eq!(scene.document.get_entity(handle).unwrap().common().owner_handle, model);
|
||||
scene.document.objects.retain(|_, object| !matches!(object, ObjectType::Layout(_)));
|
||||
scene.invalidate_dependency_index();
|
||||
|
||||
let targets = scene.dependency_targets(DependencyKind::Layer, &["Shadows".to_string()]);
|
||||
assert!(targets.render_handles.contains(&handle));
|
||||
assert!(targets.source_handles.contains(&handle));
|
||||
assert!(!targets.touches_block_definition);
|
||||
|
||||
scene.document.layers.get_mut("Shadows").unwrap().flags.off = true;
|
||||
let epoch = scene.geometry_epoch;
|
||||
scene.invalidate_layer_dependencies(&["Shadows".to_string()]);
|
||||
assert_ne!(scene.geometry_epoch, epoch, "the cached hatch needs an entity delta");
|
||||
}
|
||||
|
||||
/// The loader's handed-over draw depths must equal a local rebuild.
|
||||
#[test]
|
||||
fn the_handed_over_draw_depth_map_matches_a_recompute() {
|
||||
|
||||
@@ -318,3 +318,128 @@ impl MeshLodSet {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Max B-rep edge pairs read from one solid for snapping. Curved-edge
|
||||
/// tessellations fan out into thousands of joints; the cap keeps the
|
||||
/// interaction index and the per-move snap scan bounded.
|
||||
pub const MAX_SOLID_SNAP_EDGES: usize = 2048;
|
||||
|
||||
/// Distinct B-rep edge segments from a high/low double-single line list
|
||||
/// (mesh `edge_verts` pairs + `edge_verts_low`), feeding solid vertex
|
||||
/// (endpoints) and edge-midpoint (centres) snaps.
|
||||
///
|
||||
/// Degenerate (zero-length) and non-finite pairs are dropped, and identical
|
||||
/// pairs collapse regardless of endpoint order. Past the cap the output is
|
||||
/// stride-sampled so coverage degrades gracefully instead of dropping every
|
||||
/// edge past some cutoff.
|
||||
pub fn solid_edge_segments(
|
||||
edge_verts: &[[f32; 3]],
|
||||
edge_verts_low: &[[f32; 3]],
|
||||
) -> Vec<(glam::DVec3, glam::DVec3)> {
|
||||
fn point(high: [f32; 3], low: [f32; 3]) -> Option<glam::DVec3> {
|
||||
let p = glam::DVec3::new(
|
||||
high[0] as f64 + low[0] as f64,
|
||||
high[1] as f64 + low[1] as f64,
|
||||
high[2] as f64 + low[2] as f64,
|
||||
);
|
||||
p.is_finite().then_some(p)
|
||||
}
|
||||
fn key(p: &glam::DVec3) -> [u64; 3] {
|
||||
[p.x.to_bits(), p.y.to_bits(), p.z.to_bits()]
|
||||
}
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut out = Vec::new();
|
||||
for index in 0..edge_verts.len() / 2 {
|
||||
let high = [edge_verts[2 * index], edge_verts[2 * index + 1]];
|
||||
let low = [
|
||||
edge_verts_low.get(2 * index).copied().unwrap_or([0.0; 3]),
|
||||
edge_verts_low
|
||||
.get(2 * index + 1)
|
||||
.copied()
|
||||
.unwrap_or([0.0; 3]),
|
||||
];
|
||||
let (Some(a), Some(b)) = (point(high[0], low[0]), point(high[1], low[1])) else {
|
||||
continue;
|
||||
};
|
||||
if a.distance_squared(b) <= 1e-12 {
|
||||
continue;
|
||||
}
|
||||
let (ka, kb) = (key(&a), key(&b));
|
||||
let ordered = if ka <= kb { (ka, kb) } else { (kb, ka) };
|
||||
if !seen.insert(ordered) {
|
||||
continue;
|
||||
}
|
||||
out.push((a, b));
|
||||
}
|
||||
if out.len() > MAX_SOLID_SNAP_EDGES {
|
||||
let step = out.len().div_ceil(MAX_SOLID_SNAP_EDGES);
|
||||
out = out.into_iter().step_by(step).collect();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod solid_snap_tests {
|
||||
use super::solid_edge_segments;
|
||||
|
||||
fn pt(x: f32, y: f32, z: f32) -> [f32; 3] {
|
||||
[x, y, z]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_corners_keep_both_segments() {
|
||||
// Two edges sharing the corner (1,0,0): both pairs survive, the
|
||||
// corner itself dedups later at attach time, not here.
|
||||
let edges = vec![
|
||||
pt(0.0, 0.0, 0.0),
|
||||
pt(1.0, 0.0, 0.0),
|
||||
pt(1.0, 0.0, 0.0),
|
||||
pt(1.0, 1.0, 0.0),
|
||||
];
|
||||
let low = vec![[0.0f32; 3]; 4];
|
||||
let segs = solid_edge_segments(&edges, &low);
|
||||
assert_eq!(segs.len(), 2, "shared corner collapsed segments: {segs:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn degenerate_and_non_finite_pairs_are_dropped() {
|
||||
let edges = vec![
|
||||
pt(0.0, 0.0, 0.0),
|
||||
pt(f32::NAN, 0.0, 0.0),
|
||||
pt(1.0, 0.0, 0.0),
|
||||
pt(1.0, 0.0, 0.0), // degenerate: zero length
|
||||
pt(2.0, 0.0, 0.0),
|
||||
pt(3.0, 0.0, f32::INFINITY),
|
||||
pt(4.0, 0.0, 0.0),
|
||||
pt(5.0, 0.0, 0.0),
|
||||
];
|
||||
let low = vec![[0.0f32; 3]; 8];
|
||||
let segs = solid_edge_segments(&edges, &low);
|
||||
assert_eq!(segs.len(), 1, "expected only the valid pair: {segs:?}");
|
||||
assert!(segs[0].0.is_finite() && segs[0].1.is_finite());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_is_capped_with_coverage() {
|
||||
let mut edges = Vec::new();
|
||||
for k in 0..2200 {
|
||||
let x = 100.0 + k as f32;
|
||||
edges.push(pt(x, 0.0, 0.0));
|
||||
edges.push(pt(x, 1.0, 0.0));
|
||||
}
|
||||
let low = vec![[0.0f32; 3]; 4400];
|
||||
let segs = solid_edge_segments(&edges, &low);
|
||||
assert!(
|
||||
segs.len() <= super::MAX_SOLID_SNAP_EDGES,
|
||||
"cap exceeded: {}",
|
||||
segs.len()
|
||||
);
|
||||
assert!(
|
||||
segs.len() >= super::MAX_SOLID_SNAP_EDGES / 2,
|
||||
"stride sampling dropped too much coverage: {}",
|
||||
segs.len()
|
||||
);
|
||||
// The first pair survives sampling (index 0 is kept).
|
||||
assert_eq!(segs[0].0, glam::DVec3::new(100.0, 0.0, 0.0));
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,9 @@ pub enum PropValue {
|
||||
EditText(String),
|
||||
/// Editable text that must not be expression-evaluated.
|
||||
PlainText(String),
|
||||
/// Hyperlink collection summary. The value is display-only in the row;
|
||||
/// the adjacent collection button opens the URL/description editor.
|
||||
Hyperlink(String),
|
||||
/// Layer name — rendered as a combo_box.
|
||||
LayerChoice(String),
|
||||
/// Generic string choice rendered as a combo_box.
|
||||
|
||||
@@ -18,7 +18,7 @@ pub struct PdfPage {
|
||||
|
||||
const RASTER_DPI: f32 = 150.0;
|
||||
|
||||
type PageKey = (String, String);
|
||||
type PageKey = (String, String, u32);
|
||||
|
||||
fn page_cache() -> &'static Mutex<HashMap<PageKey, Option<Arc<PdfPage>>>> {
|
||||
static CACHE: OnceLock<Mutex<HashMap<PageKey, Option<Arc<PdfPage>>>>> = OnceLock::new();
|
||||
@@ -40,12 +40,18 @@ pub fn register_source(path: &str, bytes: Arc<Vec<u8>>) {
|
||||
page_cache()
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner())
|
||||
.retain(|(cached_path, _), _| cached_path != path);
|
||||
.retain(|(cached_path, _, _), _| cached_path != path);
|
||||
}
|
||||
|
||||
/// Rasterise a 1-based PDF page, memoised by source path and page name.
|
||||
pub fn rasterize_page(path: &str, page: &str) -> Option<Arc<PdfPage>> {
|
||||
let key = (path.to_string(), page.to_string());
|
||||
rasterize_page_at_dpi(path, page, RASTER_DPI)
|
||||
}
|
||||
|
||||
/// Rasterise a 1-based PDF page at a caller-chosen DPI, memoised per source
|
||||
/// path, page and DPI (print quality differs from the on-screen 150 DPI).
|
||||
pub fn rasterize_page_at_dpi(path: &str, page: &str, dpi: f32) -> Option<Arc<PdfPage>> {
|
||||
let key = (path.to_string(), page.to_string(), dpi.to_bits());
|
||||
if let Some(hit) = page_cache()
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner())
|
||||
@@ -55,7 +61,7 @@ pub fn rasterize_page(path: &str, page: &str) -> Option<Arc<PdfPage>> {
|
||||
return hit;
|
||||
}
|
||||
|
||||
let built = rasterize_uncached(path, page);
|
||||
let built = rasterize_uncached(path, page, dpi);
|
||||
page_cache()
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner())
|
||||
@@ -63,12 +69,19 @@ pub fn rasterize_page(path: &str, page: &str) -> Option<Arc<PdfPage>> {
|
||||
built
|
||||
}
|
||||
|
||||
fn rasterize_uncached(path: &str, page: &str) -> Option<Arc<PdfPage>> {
|
||||
/// Rasterise without touching the memo: for one-off consumers such as a
|
||||
/// print job, whose 300 DPI page would otherwise stay cached for the whole
|
||||
/// session (tens of megabytes per sheet).
|
||||
pub fn rasterize_page_at_dpi_uncached(path: &str, page: &str, dpi: f32) -> Option<Arc<PdfPage>> {
|
||||
rasterize_uncached(path, page, dpi)
|
||||
}
|
||||
|
||||
fn rasterize_uncached(path: &str, page: &str, dpi: f32) -> Option<Arc<PdfPage>> {
|
||||
let bytes = source_bytes(path)?;
|
||||
let pdf = Pdf::new(bytes).ok()?;
|
||||
let page_no = page.trim().parse::<usize>().unwrap_or(1).max(1);
|
||||
let page = pdf.pages().get(page_no - 1)?;
|
||||
let scale = RASTER_DPI / 72.0;
|
||||
let scale = dpi / 72.0;
|
||||
let pixmap = hayro::render(
|
||||
page,
|
||||
&RenderCache::new(),
|
||||
@@ -86,7 +99,7 @@ fn rasterize_uncached(path: &str, page: &str) -> Option<Arc<PdfPage>> {
|
||||
pixels: Arc::new(pixmap.data_as_u8_slice().to_vec()),
|
||||
width,
|
||||
height,
|
||||
dpi: RASTER_DPI,
|
||||
dpi,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -371,6 +371,45 @@ pub fn planar_face_normal(body: &Body, face: FaceKey) -> Option<[f64; 3]> {
|
||||
Some(if face.forward { normal } else { -normal }.to_array())
|
||||
}
|
||||
|
||||
/// Centre of every B-rep face: the average of its boundary-loop vertices.
|
||||
/// Exact for planar faces; for curved faces the loop average floats slightly
|
||||
/// inside the surface — a documented approximation the snap path must not pay
|
||||
/// a surface evaluation for on every wire build. Capped like the edge snaps.
|
||||
pub fn face_centers(body: &Body) -> Vec<[f64; 3]> {
|
||||
const MAX_FACE_CENTERS: usize = 2048;
|
||||
let mut out = Vec::new();
|
||||
for face in body.face_keys() {
|
||||
let mut sum = [0.0; 3];
|
||||
let mut count = 0usize;
|
||||
for coedge in body.face_coedges(face) {
|
||||
let Some((va, vb)) = body.coedge_vertices(coedge) else {
|
||||
continue;
|
||||
};
|
||||
for vk in [va, vb] {
|
||||
let Some(v) = body.vertices.get(vk) else {
|
||||
continue;
|
||||
};
|
||||
if !v.point.iter().all(|c| c.is_finite()) {
|
||||
continue;
|
||||
}
|
||||
sum[0] += v.point[0];
|
||||
sum[1] += v.point[1];
|
||||
sum[2] += v.point[2];
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
if count >= 3 {
|
||||
let n = count as f64;
|
||||
out.push([sum[0] / n, sum[1] / n, sum[2] / n]);
|
||||
}
|
||||
}
|
||||
if out.len() > MAX_FACE_CENTERS {
|
||||
let step = out.len().div_ceil(MAX_FACE_CENTERS);
|
||||
out = out.into_iter().step_by(step).collect();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// ── Boolean operations ──────────────────────────────────────────────────────
|
||||
|
||||
/// Which CSG to apply. Mirrors `model::boolean_cmd::BoolOp` but kept local so
|
||||
@@ -601,6 +640,30 @@ mod tests {
|
||||
assert_eq!(display([2, 2], false), boundaries);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn box_face_centers_are_six() {
|
||||
let body = box_solid([0.0, 0.0, 0.0], 10.0, 10.0, 10.0).unwrap();
|
||||
let centers = face_centers(&body);
|
||||
assert_eq!(centers.len(), 6, "a box has six faces: {centers:?}");
|
||||
for expected in [
|
||||
[5.0, 0.0, 0.0],
|
||||
[-5.0, 0.0, 0.0],
|
||||
[0.0, 5.0, 0.0],
|
||||
[0.0, -5.0, 0.0],
|
||||
[0.0, 0.0, 5.0],
|
||||
[0.0, 0.0, -5.0],
|
||||
] {
|
||||
assert!(
|
||||
centers.iter().any(|c| {
|
||||
(c[0] - expected[0]).abs() < 1e-9
|
||||
&& (c[1] - expected[1]).abs() < 1e-9
|
||||
&& (c[2] - expected[2]).abs() < 1e-9
|
||||
}),
|
||||
"missing face center {expected:?} in {centers:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_primitive_is_the_size_it_was_asked_for() {
|
||||
// Triangle counts say a mesh exists; the volume says it is the right
|
||||
|
||||
@@ -22,6 +22,18 @@ pub enum SnapHint {
|
||||
/// arc's two ends would also produce a midpoint snap out on the chord,
|
||||
/// which is not on the geometry.
|
||||
Endpoint,
|
||||
/// B-rep corner of a 3D solid. Maps to the separate 3D snap system
|
||||
/// (`SnapType::Vertex`), never to the 2D modes.
|
||||
Vertex,
|
||||
/// Centre of a 3D solid B-rep edge. Maps to the separate 3D snap system
|
||||
/// (`SnapType::EdgeMidpoint`), never to the 2D modes.
|
||||
EdgeMidpoint,
|
||||
/// Centre of a 3D solid B-rep face. Maps to the separate 3D snap system
|
||||
/// (`SnapType::FaceCenter`), never to the 2D modes.
|
||||
FaceCenter,
|
||||
/// NURBS knot location on a spline entity. Maps to the separate 3D snap
|
||||
/// system (`SnapType::Knot`), never to the 2D modes.
|
||||
Knot,
|
||||
}
|
||||
|
||||
/// Geometric primitive used by the tangent-snap engine.
|
||||
|
||||
+82
-1
@@ -161,6 +161,80 @@ impl Scene {
|
||||
.filter(|&h| !self.is_layer_locked(h))
|
||||
.collect();
|
||||
let handles = &handles[..];
|
||||
let retained_originals: Vec<(Handle, EntityType)> = handles
|
||||
.iter()
|
||||
.filter_map(|handle| {
|
||||
self.document
|
||||
.get_entity(*handle)
|
||||
.cloned()
|
||||
.map(|entity| (*handle, entity))
|
||||
})
|
||||
.collect();
|
||||
let transformed_refs: Vec<_> = handles
|
||||
.iter()
|
||||
.copied()
|
||||
.map(parametric_constraints::ParametricRef::whole)
|
||||
.collect();
|
||||
let mut driven_refs = Vec::new();
|
||||
if !matches!(t, EntityTransform::Translate(_)) {
|
||||
for set in &self.parametric_constraints {
|
||||
for constraint in &set.constraints {
|
||||
if !constraint.enabled
|
||||
|| !matches!(
|
||||
constraint.kind,
|
||||
parametric_constraints::ConstraintKind::Parallel
|
||||
| parametric_constraints::ConstraintKind::Perpendicular
|
||||
| parametric_constraints::ConstraintKind::Colinear
|
||||
)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let selected_refs = constraint
|
||||
.refs
|
||||
.iter()
|
||||
.filter(|reference| handles.contains(&reference.entity))
|
||||
.count();
|
||||
if selected_refs == 0 || selected_refs == constraint.refs.len() {
|
||||
continue;
|
||||
}
|
||||
for reference in constraint
|
||||
.refs
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|reference| !handles.contains(&reference.entity))
|
||||
{
|
||||
let anchor = if let Some(index) = reference.segment_index() {
|
||||
parametric_constraints::ParametricRef::point(
|
||||
reference.entity,
|
||||
index as i32,
|
||||
)
|
||||
} else if reference.directional_axis().is_some()
|
||||
&& matches!(
|
||||
self.document.get_entity(reference.entity),
|
||||
Some(EntityType::Ellipse(_))
|
||||
)
|
||||
{
|
||||
parametric_constraints::ParametricRef::center(reference.entity)
|
||||
} else {
|
||||
parametric_constraints::ParametricRef::point(reference.entity, 0)
|
||||
};
|
||||
let follows_selected_point = set.constraints.iter().any(|link| {
|
||||
link.enabled
|
||||
&& link.kind
|
||||
== parametric_constraints::ConstraintKind::Coincident
|
||||
&& link.refs.contains(&anchor)
|
||||
&& link
|
||||
.refs
|
||||
.iter()
|
||||
.any(|item| handles.contains(&item.entity))
|
||||
});
|
||||
if !follows_selected_point && !driven_refs.contains(&anchor) {
|
||||
driven_refs.push(anchor);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Preserve readable text orientation when MIRRTEXT is disabled.
|
||||
let preserve_text_orientation =
|
||||
matches!(t, EntityTransform::Mirror { .. }) && !self.document.header.mirror_text;
|
||||
@@ -290,7 +364,12 @@ impl Scene {
|
||||
// per-handle, keeping the block cache + all other memoized wires.
|
||||
let changes: Vec<(Handle, ChangeKind)> =
|
||||
handles.iter().map(|&h| (h, ChangeKind::Modified)).collect();
|
||||
self.bump_entities(&changes);
|
||||
self.bump_entities_with_parametric_transform_policy(
|
||||
&changes,
|
||||
&driven_refs,
|
||||
&transformed_refs,
|
||||
&retained_originals,
|
||||
);
|
||||
self.refresh_meshes_for_handles(&refresh_solid_handles);
|
||||
}
|
||||
|
||||
@@ -730,6 +809,7 @@ impl Scene {
|
||||
// Complete group copies record their new Group objects and dictionary
|
||||
// entry as targeted object deltas inside copy_complete_groups.
|
||||
self.copy_complete_groups(&handle_map);
|
||||
self.copy_dimension_associations(&handle_map);
|
||||
// The copies are new handles (natural memo misses, tessellated fresh)
|
||||
// and reference only already-cached blocks — no block defn changes.
|
||||
// Report them as additions so derived caches patch in exactly the copies.
|
||||
@@ -1398,6 +1478,7 @@ impl Scene {
|
||||
if let Some(entity) = self.document.get_entity_mut(handle) {
|
||||
view::dispatch::apply_grip(entity, grip_id, apply);
|
||||
}
|
||||
self.sync_diameter_association_angle(handle);
|
||||
if let Some((annotation_handle, old_landing)) = leader_landing_before {
|
||||
let new_landing = self.document.get_entity(handle).and_then(|entity| {
|
||||
let EntityType::Leader(leader) = entity else {
|
||||
|
||||
+233
-3
@@ -80,12 +80,173 @@ impl Scene {
|
||||
&self,
|
||||
canvas_px: (f32, f32),
|
||||
) -> Option<(view::camera::Camera, iced::Rectangle)> {
|
||||
let vp_handle = self.active_viewport?;
|
||||
self.viewport_edit_frame_for(self.active_viewport?, canvas_px)
|
||||
}
|
||||
|
||||
/// Camera and canvas rectangle for snapping through an inactive viewport.
|
||||
pub fn viewport_edit_frame_for(
|
||||
&self,
|
||||
vp_handle: Handle,
|
||||
canvas_px: (f32, f32),
|
||||
) -> Option<(view::camera::Camera, iced::Rectangle)> {
|
||||
let cam = self.camera_for_viewport(vp_handle)?;
|
||||
let full = self.viewport_screen_rect(vp_handle, canvas_px)?;
|
||||
Some((cam, full))
|
||||
}
|
||||
|
||||
/// Planar paper/model mapping derived from the viewport's display camera.
|
||||
/// Oblique and perspective views have no supported planar mapping.
|
||||
pub fn viewport_frame(
|
||||
&self,
|
||||
vp_handle: Handle,
|
||||
) -> Option<crate::scene::viewport_ref::ViewportFrame> {
|
||||
use crate::scene::viewport_ref::ViewportFrame;
|
||||
|
||||
let (paper_center, vp_height, locked) = match self.document.get_entity(vp_handle) {
|
||||
Some(EntityType::Viewport(vp)) => (
|
||||
glam::DVec2::new(vp.center.x, vp.center.y),
|
||||
vp.height,
|
||||
vp.status.locked,
|
||||
),
|
||||
_ => return None,
|
||||
};
|
||||
if !paper_center.is_finite() || !vp_height.is_finite() || vp_height < 1e-9 {
|
||||
return None;
|
||||
}
|
||||
let cam = self.camera_for_viewport(vp_handle)?;
|
||||
if cam.projection != view::camera::Projection::Orthographic {
|
||||
return None;
|
||||
}
|
||||
// Screen right/up of the viewport camera, in model space. A planar
|
||||
// frame exists only when both stay in the model XY plane and form a
|
||||
// right-handed pair (a plan view, possibly twisted). Anything else is
|
||||
// an oblique 3-D view: unsupported.
|
||||
let right = (cam.rotation * glam::Vec3::X).as_dvec3();
|
||||
let up = (cam.rotation * glam::Vec3::Y).as_dvec3();
|
||||
if !right.is_finite() || !up.is_finite() || right.z.abs() > 1e-6 || up.z.abs() > 1e-6 {
|
||||
return None;
|
||||
}
|
||||
let normal = right.cross(up);
|
||||
if normal.z < 1.0 - 1e-6 {
|
||||
return None; // mirrored or degenerate
|
||||
}
|
||||
// Model units visible across the viewport's height -> paper per model.
|
||||
let model_height = cam.ortho_size() as f64 * 2.0;
|
||||
if !model_height.is_finite() || model_height < 1e-12 {
|
||||
return None;
|
||||
}
|
||||
let scale = vp_height / model_height;
|
||||
if !scale.is_finite() || scale <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
// `ViewportFrame` rotates model geometry by `twist` (CCW) before
|
||||
// scaling; the row that produces the paper X component is `right`, so
|
||||
// cos(twist) = right.x and sin(twist) = -right.y.
|
||||
let twist = (-right.y).atan2(right.x);
|
||||
Some(ViewportFrame {
|
||||
viewport: vp_handle,
|
||||
paper_center,
|
||||
model_target: glam::DVec2::new(cam.target.x as f64, cam.target.y as f64),
|
||||
scale,
|
||||
twist,
|
||||
locked,
|
||||
})
|
||||
}
|
||||
|
||||
/// Visible, supported content viewports at a sheet point, topmost first.
|
||||
/// Shared by snapping and explicit dimension object picking.
|
||||
pub fn viewport_frames_at_paper_point(
|
||||
&self,
|
||||
paper: glam::DVec3,
|
||||
) -> Vec<crate::scene::viewport_ref::ViewportFrame> {
|
||||
if self.current_layout == "Model" || self.active_viewport.is_some() {
|
||||
return Vec::new();
|
||||
}
|
||||
self.layout_content_viewports()
|
||||
.iter()
|
||||
.rev()
|
||||
.filter_map(|&handle| {
|
||||
if !self.viewport_displays_content(handle)
|
||||
|| !self.viewport_displays_paper_point(handle, paper.truncate())
|
||||
{
|
||||
return None;
|
||||
}
|
||||
self.viewport_frame(handle)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Test the viewport rectangle and optional nonrectangular clip boundary.
|
||||
pub fn viewport_displays_paper_point(&self, vp_handle: Handle, paper: glam::DVec2) -> bool {
|
||||
let Some(EntityType::Viewport(vp)) = self.document.get_entity(vp_handle) else {
|
||||
return false;
|
||||
};
|
||||
let hw = (vp.width * 0.5).abs();
|
||||
let hh = (vp.height * 0.5).abs();
|
||||
if hw < 1e-9 || hh < 1e-9 {
|
||||
return false;
|
||||
}
|
||||
if (paper.x - vp.center.x).abs() > hw || (paper.y - vp.center.y).abs() > hh {
|
||||
return false;
|
||||
}
|
||||
if vp.clip_boundary_handle.is_null() {
|
||||
return true;
|
||||
}
|
||||
let Some(boundary) = self.document.get_entity(vp.clip_boundary_handle)
|
||||
.and_then(crate::entities::curve::entity_curve)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let Some(normal) = boundary.plane.normal() else {
|
||||
return false;
|
||||
};
|
||||
if !boundary.curve.is_closed() || normal[0].abs() > 1e-9 || normal[1].abs() > 1e-9 {
|
||||
return false;
|
||||
}
|
||||
let Some(point) = boundary.plane.project([paper.x, paper.y, vp.center.z]) else {
|
||||
return false;
|
||||
};
|
||||
cadkernel::geom2d::contains(
|
||||
&[boundary.curve],
|
||||
point,
|
||||
cadkernel::geom2d::Tolerance::new(1e-9),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod clip_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn concave_clip_boundary_rejects_the_notch() {
|
||||
let mut scene = Scene::new();
|
||||
let mut poly = acadrust::entities::LwPolyline::from_points(
|
||||
[(0.0, 0.0), (10.0, 0.0), (10.0, 5.0), (5.0, 5.0), (5.0, 10.0), (0.0, 10.0)]
|
||||
.map(|(x, y)| acadrust::types::Vector2::new(x, y))
|
||||
.to_vec(),
|
||||
);
|
||||
poly.is_closed = true;
|
||||
let clip = scene.add_entity(EntityType::LwPolyline(poly));
|
||||
let mut viewport = acadrust::entities::Viewport::new();
|
||||
viewport.center = acadrust::types::Vector3::new(5.0, 5.0, 0.0);
|
||||
viewport.width = 20.0;
|
||||
viewport.height = 20.0;
|
||||
viewport.clip_boundary_handle = clip;
|
||||
let viewport = scene.add_entity(EntityType::Viewport(viewport));
|
||||
for (x, y, inside) in [
|
||||
(2.0, 2.0, true), (8.0, 2.0, true), (8.0, 8.0, false),
|
||||
(-1.0, 5.0, false), (5.0, 7.0, true),
|
||||
] {
|
||||
assert_eq!(
|
||||
scene.viewport_displays_paper_point(viewport, glam::DVec2::new(x, y)), inside,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Scene {
|
||||
|
||||
/// Fold the active viewport's saved view onto the effective camera (the
|
||||
/// auto-fit centre for stale UTM views) and persist it into `view_target` /
|
||||
/// `view_height`. Called on entering MSPACE so pan/zoom, paper↔model and the
|
||||
@@ -109,6 +270,7 @@ impl Scene {
|
||||
vp.view_height = eff_h;
|
||||
}
|
||||
}
|
||||
self.notify_viewport_changed(vp_handle);
|
||||
}
|
||||
|
||||
/// Fit model-space bounds into the active floating viewport without moving
|
||||
@@ -147,6 +309,7 @@ impl Scene {
|
||||
viewport.custom_scale = viewport.height / viewport.view_height;
|
||||
}
|
||||
}
|
||||
self.notify_viewport_changed(viewport_handle);
|
||||
self.camera_generation += 1;
|
||||
true
|
||||
}
|
||||
@@ -198,6 +361,7 @@ impl Scene {
|
||||
vp.view_target.y += model_delta.y as f64;
|
||||
vp.view_target.z += model_delta.z as f64;
|
||||
}
|
||||
self.notify_viewport_changed(vp_handle);
|
||||
}
|
||||
|
||||
/// Zoom the active viewport's model-space view by `steps` notches.
|
||||
@@ -247,6 +411,7 @@ impl Scene {
|
||||
}
|
||||
}
|
||||
}
|
||||
self.notify_viewport_changed(vp_handle);
|
||||
}
|
||||
|
||||
/// Orbit the active viewport's view direction by the given screen-pixel delta.
|
||||
@@ -324,6 +489,7 @@ impl Scene {
|
||||
vp.view_direction.z = dir.z as f64;
|
||||
vp.twist_angle = twist;
|
||||
}
|
||||
self.notify_viewport_changed(vp_handle);
|
||||
}
|
||||
|
||||
/// Mutate the active viewport's camera through a closure, then re-encode the
|
||||
@@ -352,6 +518,7 @@ impl Scene {
|
||||
.normalize_or(glam::Vec3::Y);
|
||||
let roll = up0.cross(desired_up).dot(dir).atan2(up0.dot(desired_up));
|
||||
let twist = -roll as f64;
|
||||
let mut changed = false;
|
||||
if let Some(acadrust::EntityType::Viewport(vp)) = self.document.get_entity_mut(vp_handle) {
|
||||
if vp.status.locked {
|
||||
return false;
|
||||
@@ -364,9 +531,12 @@ impl Scene {
|
||||
tmp.projection == view::camera::Projection::Perspective;
|
||||
vp.lens_length =
|
||||
(12.0 / (tmp.fov_y * 0.5).tan().max(1e-6)) as f64;
|
||||
return true;
|
||||
changed = true;
|
||||
}
|
||||
false
|
||||
if changed {
|
||||
self.notify_viewport_changed(vp_handle);
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// Render mode of the active paper-space viewport, or `None` when no
|
||||
@@ -446,6 +616,46 @@ impl Scene {
|
||||
self.camera.borrow().rotation
|
||||
}
|
||||
|
||||
/// Camera owning navigation, including an entered paper-space viewport.
|
||||
pub(crate) fn navigation_camera(&self) -> view::camera::Camera {
|
||||
self.active_viewport
|
||||
.and_then(|h| self.camera_for_viewport(h))
|
||||
.unwrap_or_else(|| self.camera.borrow().clone())
|
||||
}
|
||||
|
||||
pub(crate) fn navigation_locked(&self) -> bool {
|
||||
self.active_viewport.is_some_and(|h| {
|
||||
!matches!(self.document.get_entity(h), Some(acadrust::EntityType::Viewport(v)) if !v.status.locked)
|
||||
})
|
||||
}
|
||||
|
||||
/// Commit an externally navigated camera through the same floating-view
|
||||
/// encoding as the ViewCube. Camera coordinates already include view_center.
|
||||
pub(crate) fn apply_navigation_camera(&mut self, camera: view::camera::Camera) -> bool {
|
||||
if self.navigation_locked() {
|
||||
return false;
|
||||
}
|
||||
if let Some(handle) = self.active_viewport {
|
||||
if let Some(acadrust::EntityType::Viewport(vp)) = self.document.get_entity_mut(handle) {
|
||||
vp.view_target.x = camera.target.x;
|
||||
vp.view_target.y = camera.target.y;
|
||||
vp.view_target.z = camera.target.z;
|
||||
vp.view_center.x = 0.;
|
||||
vp.view_center.y = 0.;
|
||||
vp.view_height = camera.ortho_size() as f64 * 2.;
|
||||
vp.custom_scale = vp.height / vp.view_height;
|
||||
}
|
||||
// Notify associations only after all camera fields are persisted.
|
||||
if !self.mutate_active_viewport_camera(|c| *c = camera.clone()) {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
*self.camera.borrow_mut() = camera;
|
||||
}
|
||||
self.camera_generation += 1;
|
||||
true
|
||||
}
|
||||
|
||||
pub fn active_camera_projection(&self) -> view::camera::Projection {
|
||||
if let Some(handle) = self.active_viewport {
|
||||
if let Some(camera) = self.camera_for_viewport(handle) {
|
||||
@@ -524,6 +734,26 @@ impl Scene {
|
||||
.map(|(h, _)| h)
|
||||
}
|
||||
|
||||
/// Content (floating) viewports of the current paper layout, in draw order
|
||||
/// (the last entry draws on top). Empty in the Model layout.
|
||||
pub fn layout_content_viewports(&self) -> Arc<Vec<Handle>> {
|
||||
if self.current_layout == "Model" {
|
||||
return Arc::new(Vec::new());
|
||||
}
|
||||
let (_, _, handles) = self.paper_viewport_handles();
|
||||
handles
|
||||
}
|
||||
|
||||
/// `true` when this viewport's contents are displayed (VP ON). The border
|
||||
/// layer's visibility deliberately does NOT matter: a viewport whose frame
|
||||
/// layer is off still shows its model content, so it still snaps.
|
||||
pub fn viewport_displays_content(&self, vp_handle: Handle) -> bool {
|
||||
matches!(
|
||||
self.document.get_entity(vp_handle),
|
||||
Some(EntityType::Viewport(vp)) if vp.status.is_on
|
||||
)
|
||||
}
|
||||
|
||||
/// Return the handle of the first active user viewport in the current layout,
|
||||
/// or `None` if there are none. Used by the MS command.
|
||||
pub fn first_user_viewport(&self) -> Option<Handle> {
|
||||
|
||||
+111
-24
@@ -5,33 +5,113 @@
|
||||
//! dictionary + objects consistent so the result round-trips through DWG/DXF.
|
||||
|
||||
use super::Scene;
|
||||
use acadrust::objects::{Dictionary, ObjectType, PlotSettings};
|
||||
use acadrust::Handle;
|
||||
use acadrust::objects::{Dictionary, Layout, ObjectType, PlotSettings};
|
||||
use acadrust::{CadDocument, Handle};
|
||||
|
||||
/// Give a freshly created paper layout the page setup a new drawing starts
|
||||
/// with — ISO A4 landscape on no plotter, plotted 1:1 as a layout — stored the
|
||||
/// way the file format expects it: the portrait medium under its canonical
|
||||
/// name with the orientation in the rotation code. `plot_style` names the
|
||||
/// plot style table to attach, or is empty.
|
||||
pub fn apply_default_page_setup(layout: &mut Layout, plot_style: &str) {
|
||||
use acadrust::objects::{PlotPaperUnits, PlotRotation, PlotType, ScaledType};
|
||||
let a4 = crate::io::paper_catalog::default_paper();
|
||||
let (paper_width, paper_height) = a4.portrait_mm();
|
||||
layout.min_limits = (0.0, 0.0);
|
||||
layout.max_limits = (paper_height, paper_width);
|
||||
layout.min_extents = (0.0, 0.0, 0.0);
|
||||
layout.max_extents = (paper_height, paper_width, 0.0);
|
||||
layout.paper_width = paper_width;
|
||||
layout.paper_height = paper_height;
|
||||
layout.plot_rotation = PlotRotation::Degrees90.to_code();
|
||||
layout.plot_paper_units = PlotPaperUnits::Millimeters.to_code();
|
||||
layout.plot_type = PlotType::Layout.to_code();
|
||||
layout.plot_scale_type = ScaledType::OneToOne.to_code();
|
||||
layout.plot_scale_numerator = 1.0;
|
||||
layout.plot_scale_denominator = 1.0;
|
||||
layout.plot_scale_factor = 1.0;
|
||||
layout.plot_flags.use_standard_scale = true;
|
||||
layout.plot_flags.print_lineweights = true;
|
||||
layout.plot_flags.draw_viewports_first = true;
|
||||
layout.plot_flags.plot_plot_styles = !plot_style.is_empty();
|
||||
layout.plot_flags.show_plot_styles = !plot_style.is_empty();
|
||||
layout.plot_style_sheet = plot_style.to_string();
|
||||
layout.paper_size = a4.canonical.to_string();
|
||||
layout.plot_printer_name = crate::io::plot_device::PlotDevice::None.canonical_name();
|
||||
}
|
||||
|
||||
/// The unprintable margins of a sheet as they appear on the rotated layout,
|
||||
/// `(left, bottom, right, top)`. Stored margins belong to the medium's own
|
||||
/// edges; a plot rotated by a quarter turn (`rotation` is the file's 0–3
|
||||
/// code) shows the medium turned on the layout, so each margin moves to the
|
||||
/// edge its side lands on — verified against limits written by the
|
||||
/// commercial application: at 90° the medium's top margin is the layout's
|
||||
/// left one and its left margin the layout's bottom one.
|
||||
pub fn rotated_margins(
|
||||
(left, bottom, right, top): (f64, f64, f64, f64),
|
||||
rotation: i16,
|
||||
) -> (f64, f64, f64, f64) {
|
||||
match rotation {
|
||||
1 => (top, left, bottom, right),
|
||||
2 => (right, top, left, bottom),
|
||||
3 => (bottom, right, top, left),
|
||||
_ => (left, bottom, right, top),
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle of a document's `ACAD_PLOTSETTINGS` dictionary, located robustly.
|
||||
///
|
||||
/// The canonical path is the header's `acad_plotsettings_dict_handle`, but
|
||||
/// DWGs written by other programs don't always leave that pointer resolvable
|
||||
/// (the header handle points at no loaded dictionary — see
|
||||
/// [`crate::scene::annotative::root_named_dict_handle`]). In that case fall
|
||||
/// back to the dictionary that owns the drawing's `PlotSettings` objects,
|
||||
/// mirroring [`Scene::scalelist_dict_handle`]. Returns `None` when the
|
||||
/// drawing genuinely has no named page setups.
|
||||
fn plotsettings_dict_handle_in(document: &CadDocument) -> Option<Handle> {
|
||||
let dh = document.header.acad_plotsettings_dict_handle;
|
||||
if matches!(document.objects.get(&dh), Some(ObjectType::Dictionary(_))) {
|
||||
return Some(dh);
|
||||
}
|
||||
let owner = document.objects.values().find_map(|o| match o {
|
||||
ObjectType::PlotSettings(ps) => Some(ps.owner),
|
||||
_ => None,
|
||||
})?;
|
||||
matches!(document.objects.get(&owner), Some(ObjectType::Dictionary(_))).then_some(owner)
|
||||
}
|
||||
|
||||
/// The named page setups of any document — `(name, settings)` in dictionary
|
||||
/// order. Reads a drawing that is not open in the editor (the source of a
|
||||
/// `PSETUPIN` import) the same way the editor reads its own.
|
||||
pub fn document_page_setups(document: &CadDocument) -> Vec<(String, PlotSettings)> {
|
||||
let Some(dh) = plotsettings_dict_handle_in(document) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Some(ObjectType::Dictionary(dict)) = document.objects.get(&dh) else {
|
||||
return Vec::new();
|
||||
};
|
||||
dict.entries
|
||||
.iter()
|
||||
.filter_map(|(name, handle)| match document.objects.get(handle) {
|
||||
Some(ObjectType::PlotSettings(ps)) => Some((name.clone(), ps.clone())),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A page setup copied out of another drawing, made safe for this one: the
|
||||
/// handles it pointed at (a plot view, a visual style) meant something only
|
||||
/// in its source, so they are dropped while the names, which travel, stay.
|
||||
pub fn detach_page_setup(ps: &mut PlotSettings) {
|
||||
ps.plot_view_handle = Handle::NULL;
|
||||
ps.visual_style_handle = Handle::NULL;
|
||||
ps.reactors.clear();
|
||||
ps.xdictionary_handle = None;
|
||||
}
|
||||
|
||||
impl Scene {
|
||||
/// Handle of the `ACAD_PLOTSETTINGS` dictionary, located robustly.
|
||||
///
|
||||
/// The canonical path is the header's `acad_plotsettings_dict_handle`, but
|
||||
/// DWGs written by other programs don't always leave that pointer resolvable
|
||||
/// (the header handle points at no loaded dictionary — see
|
||||
/// [`crate::scene::annotative::root_named_dict_handle`]). In that case fall
|
||||
/// back to the dictionary that owns the drawing's `PlotSettings` objects,
|
||||
/// mirroring [`Scene::scalelist_dict_handle`]. Returns `None` when the
|
||||
/// drawing genuinely has no named page setups.
|
||||
fn plotsettings_dict_handle(&self) -> Option<Handle> {
|
||||
let dh = self.document.header.acad_plotsettings_dict_handle;
|
||||
if matches!(self.document.objects.get(&dh), Some(ObjectType::Dictionary(_))) {
|
||||
return Some(dh);
|
||||
}
|
||||
let owner = self.document.objects.values().find_map(|o| match o {
|
||||
ObjectType::PlotSettings(ps) => Some(ps.owner),
|
||||
_ => None,
|
||||
})?;
|
||||
matches!(
|
||||
self.document.objects.get(&owner),
|
||||
Some(ObjectType::Dictionary(_))
|
||||
)
|
||||
.then_some(owner)
|
||||
plotsettings_dict_handle_in(&self.document)
|
||||
}
|
||||
|
||||
/// Names of the document's named page setups, in dictionary order.
|
||||
@@ -42,6 +122,13 @@ impl Scene {
|
||||
}
|
||||
}
|
||||
|
||||
/// Bring a page setup from another drawing in under `name`, replacing
|
||||
/// one of that name the way `PSETUPIN` redefines it.
|
||||
pub fn import_page_setup(&mut self, name: &str, mut ps: PlotSettings) {
|
||||
detach_page_setup(&mut ps);
|
||||
self.page_setup_save(name, ps);
|
||||
}
|
||||
|
||||
/// Clone the named page setup's `PlotSettings`, or `None` if absent.
|
||||
pub fn page_setup_get(&self, name: &str) -> Option<PlotSettings> {
|
||||
let dh = self.plotsettings_dict_handle()?;
|
||||
|
||||
+874
-135
File diff suppressed because it is too large.
Load diff
+1918
-381
File diff suppressed because it is too large.
Load diff
@@ -30,9 +30,13 @@ pub struct SelectionState {
|
||||
pub poly_points: Vec<Point>,
|
||||
pub poly_crossing: bool,
|
||||
pub poly_last_crossing: bool,
|
||||
/// Canvas position the right-click context menu is anchored at while it
|
||||
/// is open. `None` = closed. Its rows are rebuilt from app state every
|
||||
/// frame (`ui::popup::context_menu::build_context_menu`).
|
||||
pub context_menu: Option<Point>,
|
||||
/// True while the context menu's Draw Order sub-items are expanded.
|
||||
pub draworder_submenu: bool,
|
||||
/// Transient UI state of the open context menu (expanded submenu,
|
||||
/// keyboard highlight). Reset every time the menu opens.
|
||||
pub context_menu_ui: ContextMenuUi,
|
||||
pub last_move_pos: Option<Point>,
|
||||
pub left_down: bool,
|
||||
pub left_press_pos: Option<Point>,
|
||||
@@ -57,7 +61,31 @@ pub struct SelectionState {
|
||||
pub middle_last_press_time: Option<Instant>,
|
||||
}
|
||||
|
||||
/// Transient state of the open right-click context menu.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct ContextMenuUi {
|
||||
/// The accordion submenu currently expanded (at most one).
|
||||
pub open_submenu: Option<crate::ui::popup::context_menu::SubmenuId>,
|
||||
/// Keyboard highlight as an index into `ContextMenu::selectable()`.
|
||||
/// `None` until an arrow key / mnemonic is pressed, so a mouse user is
|
||||
/// never shown a highlighted row that is not under the pointer.
|
||||
pub highlighted: Option<usize>,
|
||||
}
|
||||
|
||||
impl SelectionState {
|
||||
/// Open the context menu at `at`, discarding the previous menu's
|
||||
/// expanded-submenu / highlight state.
|
||||
pub fn open_context_menu(&mut self, at: Point) {
|
||||
self.context_menu = Some(at);
|
||||
self.context_menu_ui = ContextMenuUi::default();
|
||||
}
|
||||
|
||||
/// Close the context menu (no-op when it is not open).
|
||||
pub fn close_context_menu(&mut self) {
|
||||
self.context_menu = None;
|
||||
self.context_menu_ui = ContextMenuUi::default();
|
||||
}
|
||||
|
||||
/// End every left-button selection gesture without disturbing the previous
|
||||
/// completed-window record or command-owned preview marquee. Grip editing
|
||||
/// owns the left button while engaged and calls this before/after placement
|
||||
|
||||
@@ -113,7 +113,7 @@ pub fn create_pipelines(
|
||||
) -> (wgpu::RenderPipeline, wgpu::RenderPipeline) {
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("circle.wgsl"),
|
||||
source: wgpu::ShaderSource::Wgsl(include_str!("../../shaders/circle.wgsl").into()),
|
||||
source: wgpu::ShaderSource::Wgsl(draw_order_shader!("circle.wgsl").into()),
|
||||
});
|
||||
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("circle.pipeline.layout"),
|
||||
@@ -145,7 +145,7 @@ pub fn create_pipelines(
|
||||
},
|
||||
depth_stencil: Some(wgpu::DepthStencilState {
|
||||
format: wgpu::TextureFormat::Depth24PlusStencil8,
|
||||
depth_write_enabled: Some(false),
|
||||
depth_write_enabled: Some(true),
|
||||
depth_compare: Some(wgpu::CompareFunction::LessEqual),
|
||||
stencil: content_stencil.clone(),
|
||||
bias: wgpu::DepthBiasState::default(),
|
||||
@@ -349,7 +349,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn circle_shader_validates_with_naga() {
|
||||
let source = include_str!("../../shaders/circle.wgsl");
|
||||
let source = draw_order_shader!("circle.wgsl");
|
||||
let module = naga::front::wgsl::parse_str(source).expect("circle.wgsl parses cleanly");
|
||||
let mut validator = naga::valid::Validator::new(
|
||||
naga::valid::ValidationFlags::all(),
|
||||
|
||||
@@ -0,0 +1,454 @@
|
||||
//! Pixel regressions for projection and draw-order clipping, using the actual
|
||||
//! wire pipelines in both storage and WebGL-compatible packed modes.
|
||||
use super::*;
|
||||
use crate::scene::view::camera::{Camera, Projection};
|
||||
use iced::futures::executor::block_on;
|
||||
|
||||
const SIZE: u32 = 512;
|
||||
|
||||
fn pixels(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
pipeline: &mut Pipeline,
|
||||
uniforms: &Uniforms,
|
||||
wires: &[WireModel],
|
||||
depths: &rustc_hash::FxHashMap<u64, [f32; 2]>,
|
||||
) -> Vec<u8> {
|
||||
let size = wgpu::Extent3d {
|
||||
width: SIZE,
|
||||
height: SIZE,
|
||||
depth_or_array_layers: 1,
|
||||
};
|
||||
let target = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("draw-order regression"),
|
||||
size,
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
view_formats: &[],
|
||||
});
|
||||
let view = target.create_view(&Default::default());
|
||||
let readback = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("draw-order readback"),
|
||||
size: (SIZE * SIZE * 4) as u64,
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
pipeline.ensure_depth_texture(device, Size::new(SIZE, SIZE));
|
||||
pipeline.upload_uniforms(device, queue, uniforms);
|
||||
pipeline.gpu_wires = std::sync::Arc::new(WireGpu::from_batch(
|
||||
device,
|
||||
queue,
|
||||
wires,
|
||||
depths,
|
||||
pipeline.wire_const_bgl.as_ref(),
|
||||
));
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
let rect = Rectangle {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: SIZE,
|
||||
height: SIZE,
|
||||
};
|
||||
pipeline.render(
|
||||
&mut encoder,
|
||||
&view,
|
||||
Rectangle::with_size(Size::new(SIZE as f32, SIZE as f32)),
|
||||
rect,
|
||||
rect,
|
||||
[0., 0., 0., 1.],
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
);
|
||||
encoder.copy_texture_to_buffer(
|
||||
wgpu::TexelCopyTextureInfo {
|
||||
texture: &target,
|
||||
mip_level: 0,
|
||||
origin: wgpu::Origin3d::ZERO,
|
||||
aspect: wgpu::TextureAspect::All,
|
||||
},
|
||||
wgpu::TexelCopyBufferInfo {
|
||||
buffer: &readback,
|
||||
layout: wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(SIZE * 4),
|
||||
rows_per_image: Some(SIZE),
|
||||
},
|
||||
},
|
||||
size,
|
||||
);
|
||||
queue.submit([encoder.finish()]);
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
readback.slice(..).map_async(wgpu::MapMode::Read, move |r| {
|
||||
tx.send(r).unwrap();
|
||||
});
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
rx.recv().unwrap().unwrap();
|
||||
let result = readback.slice(..).get_mapped_range().to_vec();
|
||||
readback.unmap();
|
||||
result
|
||||
}
|
||||
|
||||
fn color_at(bytes: &[u8], ndc_x: f32) -> [u32; 3] {
|
||||
let x = ((ndc_x * 0.5 + 0.5) * SIZE as f32) as usize;
|
||||
let mut color = [0; 3];
|
||||
for col in x - 2..=x + 2 {
|
||||
let offset = (SIZE as usize / 2 * SIZE as usize + col) * 4;
|
||||
for c in 0..3 {
|
||||
color[c] += bytes[offset + c] as u32;
|
||||
}
|
||||
}
|
||||
color
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires a GPU adapter"]
|
||||
fn wipeout_respects_close_block_draw_order() {
|
||||
use acadrust::{entities::Wipeout, types::Vector2, EntityType};
|
||||
use crate::scene::{model::wire_model::TangentGeom, Scene};
|
||||
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
|
||||
let adapter = block_on(instance.request_adapter(&Default::default())).expect("GPU adapter");
|
||||
for packed in [false, true] {
|
||||
let mut limits = adapter.limits();
|
||||
if packed {
|
||||
limits.max_storage_buffers_per_shader_stage = 0;
|
||||
}
|
||||
let (device, queue) = block_on(adapter.request_device(&wgpu::DeviceDescriptor {
|
||||
required_limits: limits,
|
||||
..Default::default()
|
||||
}))
|
||||
.expect("GPU device");
|
||||
let validation = device.push_error_scope(wgpu::ErrorFilter::Validation);
|
||||
let mut pipeline = Pipeline::new(&device, &queue, wgpu::TextureFormat::Rgba8Unorm);
|
||||
let bounds = Rectangle::with_size(Size::new(SIZE as f32, SIZE as f32));
|
||||
let mut uniforms = Uniforms::new(&Camera::default(), bounds, false);
|
||||
uniforms.view_rot = glam::Mat4::IDENTITY;
|
||||
uniforms.eye_high = [0.; 3];
|
||||
uniforms.eye_low = [0.; 3];
|
||||
let mut scene = Scene::new();
|
||||
scene.add_entity(EntityType::Wipeout(Wipeout::polygonal(
|
||||
&[
|
||||
Vector2::new(-0.7, -0.7),
|
||||
Vector2::new(0.7, -0.7),
|
||||
Vector2::new(0.7, 0.7),
|
||||
Vector2::new(-0.7, 0.7),
|
||||
],
|
||||
0.5,
|
||||
)));
|
||||
let mut masks = scene.wipeout_models_arc().as_ref().clone();
|
||||
assert_eq!(masks.len(), 1);
|
||||
masks[0].color = [0., 0., 0., 1.];
|
||||
masks[0].draw_depth = 0.1;
|
||||
pipeline.upload_wipeouts(&device, &queue, &masks);
|
||||
// Block children share a narrow sub-range of their insert's order.
|
||||
// The mask must hide earlier geometry without erasing later geometry.
|
||||
let depths = [(1, [0.0998, 0.]), (2, [0.1002, 0.])].into_iter().collect();
|
||||
for kind in ["line", "circle", "arc", "ellipse"] {
|
||||
let wires: Vec<_> = [(-0.3, 1), (0.3, 2)]
|
||||
.into_iter()
|
||||
.map(|(x, id)| {
|
||||
let mut wire = WireModel::solid(
|
||||
id.to_string(),
|
||||
Vec::new(),
|
||||
[1.; 4],
|
||||
false,
|
||||
);
|
||||
let center = [x as f64 - 0.1, 0., 0.5];
|
||||
let axis_x = [1., 0., 0.];
|
||||
let axis_y = [0., 1., 0.];
|
||||
match kind {
|
||||
"circle" => wire.tangent_geoms.push(TangentGeom::PlanarCircle {
|
||||
center, axis_x, axis_y, radius: 0.1,
|
||||
}),
|
||||
"arc" => wire.tangent_geoms.push(TangentGeom::Arc {
|
||||
center, axis_x, axis_y, radius: 0.1,
|
||||
start_angle: -std::f64::consts::FRAC_PI_2,
|
||||
end_angle: std::f64::consts::FRAC_PI_2,
|
||||
}),
|
||||
"ellipse" => wire.tangent_geoms.push(TangentGeom::PlanarEllipse {
|
||||
center, major_axis: [0.1, 0., 0.], normal: [0., 0., 1.],
|
||||
minor_axis_ratio: 0.6, start_param: 0., end_param: std::f64::consts::TAU,
|
||||
}),
|
||||
_ => wire.points = vec![[x, -0.65, 0.5], [x, 0.65, 0.5]],
|
||||
}
|
||||
wire
|
||||
})
|
||||
.collect();
|
||||
pipeline.gpu_circles = std::sync::Arc::new(pipeline.upload_circles(&device, &queue, &wires, &depths));
|
||||
pipeline.gpu_ellipses = std::sync::Arc::new(pipeline.upload_ellipses(&device, &queue, &wires, &depths));
|
||||
let bytes = pixels(&device, &queue, &mut pipeline, &uniforms, &wires, &depths);
|
||||
assert_eq!(color_at(&bytes, -0.3), [0; 3], "mask must cover earlier {kind}");
|
||||
assert_ne!(color_at(&bytes, 0.3), [0; 3], "mask erased later {kind}, packed={packed}");
|
||||
}
|
||||
assert!(block_on(validation.pop()).is_none(), "GPU validation failed");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires a GPU adapter"]
|
||||
fn perspective_draw_order_keeps_flat_lines_visible() {
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
|
||||
let adapter = block_on(instance.request_adapter(&Default::default())).expect("GPU adapter");
|
||||
let bounds = Rectangle::with_size(Size::new(SIZE as f32, SIZE as f32));
|
||||
let mut failures = Vec::new();
|
||||
for packed in [false, true] {
|
||||
let mut limits = adapter.limits();
|
||||
if packed {
|
||||
limits.max_storage_buffers_per_shader_stage = 0;
|
||||
}
|
||||
let (device, queue) = block_on(adapter.request_device(&wgpu::DeviceDescriptor {
|
||||
required_limits: limits,
|
||||
..Default::default()
|
||||
}))
|
||||
.expect("GPU device");
|
||||
let validation = device.push_error_scope(wgpu::ErrorFilter::Validation);
|
||||
let mut pipeline = Pipeline::new(&device, &queue, wgpu::TextureFormat::Rgba8Unorm);
|
||||
for projection in [Projection::Orthographic, Projection::Perspective] {
|
||||
let camera = Camera {
|
||||
distance: 102.03651,
|
||||
fov_y: 0.47108996,
|
||||
rotation: glam::Quat::IDENTITY,
|
||||
target: glam::DVec3::ZERO,
|
||||
projection,
|
||||
..Camera::default()
|
||||
};
|
||||
// Same 17-entity draw-order labels as the reported drawing. Its
|
||||
// missing line (45EB) occupies index 1 with depth -0.5794896.
|
||||
let depths = (0..17)
|
||||
.map(|i| {
|
||||
(
|
||||
i + 1,
|
||||
[
|
||||
(-800_000_000_i64 + 88_888_888 * (i + 1) as i64) as f32
|
||||
/ 1_073_741_824.,
|
||||
0.,
|
||||
],
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let wires: Vec<_> = (0..17)
|
||||
.map(|i| {
|
||||
let x = (-0.8 + i as f32 * 0.1) * camera.ortho_size();
|
||||
let h = camera.ortho_size() * 0.65;
|
||||
WireModel::solid(
|
||||
(i + 1).to_string(),
|
||||
vec![[x, -h, 0.], [x, h, 0.]],
|
||||
if i < 2 { [1., 0.3, 0.2, 1.] } else { [1.; 4] },
|
||||
false,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let bytes = pixels(
|
||||
&device,
|
||||
&queue,
|
||||
&mut pipeline,
|
||||
&Uniforms::new(&camera, bounds, false),
|
||||
&wires,
|
||||
&depths,
|
||||
);
|
||||
if let Ok(path) = std::env::var("OCS_DEPTH_TEST_IMAGES") {
|
||||
let path = std::path::Path::new(&path);
|
||||
std::fs::create_dir_all(path).unwrap();
|
||||
image::save_buffer(
|
||||
path.join(format!(
|
||||
"{projection:?}-{}.png",
|
||||
if packed { "packed" } else { "storage" }
|
||||
)),
|
||||
&bytes,
|
||||
SIZE,
|
||||
SIZE,
|
||||
image::ColorType::Rgba8,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
for i in 0..17 {
|
||||
if color_at(&bytes, -0.8 + i as f32 * 0.1) == [0; 3] {
|
||||
failures.push(format!("{projection:?}, packed={packed}, line {i}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
block_on(validation.pop()).is_none(),
|
||||
"GPU validation failed"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
failures.is_empty(),
|
||||
"visible lines were clipped: {failures:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_draw_order_shaders_validate() {
|
||||
for source in [
|
||||
draw_order_shader!("wire.wgsl"),
|
||||
draw_order_shader!("wire_indexed.wgsl"),
|
||||
draw_order_shader!("block_wire.wgsl"),
|
||||
draw_order_shader!("block_wire_storage.wgsl"),
|
||||
draw_order_shader!("circle.wgsl"),
|
||||
draw_order_shader!("ellipse.wgsl"),
|
||||
draw_order_shader!("text.wgsl"),
|
||||
draw_order_shader!("block_text.wgsl"),
|
||||
draw_order_shader!("face3d.wgsl"),
|
||||
draw_order_shader!("block_face3d.wgsl"),
|
||||
draw_order_shader!("hatch.wgsl"),
|
||||
draw_order_shader!("hatch_texture.wgsl"),
|
||||
draw_order_shader!("image.wgsl"),
|
||||
draw_order_shader!("wipeout.wgsl"),
|
||||
] {
|
||||
let module = naga::front::wgsl::parse_str(source).expect("composed shader parses");
|
||||
naga::valid::Validator::new(
|
||||
naga::valid::ValidationFlags::all(),
|
||||
naga::valid::Capabilities::all(),
|
||||
)
|
||||
.validate(&module)
|
||||
.expect("composed shader validates");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires a GPU adapter"]
|
||||
fn draw_order_preserves_clip_boundaries_sorting_and_mesh_occlusion() {
|
||||
use crate::scene::model::mesh_model::{MeshLodSet, MeshModel};
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
|
||||
let adapter = block_on(instance.request_adapter(&Default::default())).expect("GPU adapter");
|
||||
for packed in [false, true] {
|
||||
let mut limits = adapter.limits();
|
||||
if packed {
|
||||
limits.max_storage_buffers_per_shader_stage = 0;
|
||||
}
|
||||
let (device, queue) = block_on(adapter.request_device(&wgpu::DeviceDescriptor {
|
||||
required_limits: limits,
|
||||
..Default::default()
|
||||
}))
|
||||
.expect("GPU device");
|
||||
let validation = device.push_error_scope(wgpu::ErrorFilter::Validation);
|
||||
let mut pipeline = Pipeline::new(&device, &queue, wgpu::TextureFormat::Rgba8Unorm);
|
||||
let bounds = Rectangle::with_size(Size::new(SIZE as f32, SIZE as f32));
|
||||
let mut uniforms = Uniforms::new(&Camera::default(), bounds, false);
|
||||
uniforms.view_rot = glam::Mat4::IDENTITY;
|
||||
uniforms.eye_high = [0.; 3];
|
||||
uniforms.eye_low = [0.; 3];
|
||||
let line = |id: u64, x: f32, z: f32, color: [f32; 4]| {
|
||||
WireModel::solid(
|
||||
id.to_string(),
|
||||
vec![[x, -0.65, z], [x, 0.65, z]],
|
||||
color,
|
||||
false,
|
||||
)
|
||||
};
|
||||
// Both bias signs, the paper's -2 order, and already-clipped geometry.
|
||||
let cases = [
|
||||
(0.00001, 2., true),
|
||||
(0.00001, -2., true),
|
||||
(0.99999, 2., true),
|
||||
(0.99999, -2., true),
|
||||
(-0.00001, -2., false),
|
||||
(1.00001, 2., false),
|
||||
(0., 2., true),
|
||||
(1., -2., true),
|
||||
(0.5, 0., true),
|
||||
];
|
||||
let wires: Vec<_> = cases
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, (z, _, _))| line(i as u64 + 1, -0.8 + i as f32 * 0.2, *z, [1.; 4]))
|
||||
.collect();
|
||||
let depths = cases
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, (_, order, _))| (i as u64 + 1, [*order, 0.]))
|
||||
.collect();
|
||||
let bytes = pixels(&device, &queue, &mut pipeline, &uniforms, &wires, &depths);
|
||||
for (i, (z, order, visible)) in cases.iter().enumerate() {
|
||||
assert_eq!(
|
||||
color_at(&bytes, -0.8 + i as f32 * 0.2) != [0; 3],
|
||||
*visible,
|
||||
"packed={packed}, depth={z}, order={order}"
|
||||
);
|
||||
}
|
||||
|
||||
// Coplanar draw order must work close to both planes, for negative
|
||||
// ranks too, regardless of the order in which the GPU sees the wires.
|
||||
for z in [0.0001, 0.5, 0.9999] {
|
||||
for (back, front) in [(-0.7, 0.7), (-2., -0.9), (0.3, 0.8)] {
|
||||
let depths = rustc_hash::FxHashMap::from_iter([(1, [back, 0.]), (2, [front, 0.])]);
|
||||
let mut wires = [
|
||||
line(1, 0., z, [1., 0., 0., 1.]),
|
||||
line(2, 0., z, [0., 1., 0., 1.]),
|
||||
];
|
||||
for _ in 0..2 {
|
||||
let bytes = pixels(&device, &queue, &mut pipeline, &uniforms, &wires, &depths);
|
||||
let [red, green, _] = color_at(&bytes, 0.);
|
||||
assert!(
|
||||
green > 0 && red == 0,
|
||||
"draw order reversed at z={z}, packed={packed}"
|
||||
);
|
||||
wires.reverse();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A real triangle mesh still occludes a wire behind it. A wire in front
|
||||
// stays visible: neutral draw order must retain geometric depth.
|
||||
pipeline.upload_mesh_batch(
|
||||
&device,
|
||||
&queue,
|
||||
&[MeshLodSet::from_single(MeshModel {
|
||||
name: "100".into(),
|
||||
verts: vec![
|
||||
[-0.6, -0.8, 0.5],
|
||||
[0.6, -0.8, 0.5],
|
||||
[0.6, 0.8, 0.5],
|
||||
[-0.6, 0.8, 0.5],
|
||||
],
|
||||
verts_low: Vec::new(),
|
||||
normals: vec![[0., 0., 1.]; 4],
|
||||
indices: vec![0, 1, 2, 0, 2, 3],
|
||||
triangle_material_handles: Vec::new(),
|
||||
triangle_colors: Vec::new(),
|
||||
color: [0., 1., 0., 1.],
|
||||
selected: false,
|
||||
})],
|
||||
);
|
||||
let mesh_only = pixels(
|
||||
&device,
|
||||
&queue,
|
||||
&mut pipeline,
|
||||
&uniforms,
|
||||
&[],
|
||||
&Default::default(),
|
||||
);
|
||||
assert_ne!(
|
||||
color_at(&mesh_only, -0.3),
|
||||
[0; 3],
|
||||
"mesh must produce pixels"
|
||||
);
|
||||
let bytes = pixels(
|
||||
&device,
|
||||
&queue,
|
||||
&mut pipeline,
|
||||
&uniforms,
|
||||
&[
|
||||
line(1, -0.3, 0.6, [1., 0., 0., 1.]),
|
||||
line(2, 0.3, 0.4, [0., 0., 1., 1.]),
|
||||
],
|
||||
&Default::default(),
|
||||
);
|
||||
assert_eq!(
|
||||
color_at(&bytes, -0.3),
|
||||
color_at(&mesh_only, -0.3),
|
||||
"mesh failed to occlude the rear wire, packed={packed}"
|
||||
);
|
||||
let [red, _, blue] = color_at(&bytes, 0.3);
|
||||
assert!(blue > red, "mesh hid the front wire, packed={packed}");
|
||||
assert!(
|
||||
block_on(validation.pop()).is_none(),
|
||||
"GPU validation failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -113,7 +113,7 @@ pub fn create_pipelines(
|
||||
) -> (wgpu::RenderPipeline, wgpu::RenderPipeline) {
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("ellipse.wgsl"),
|
||||
source: wgpu::ShaderSource::Wgsl(include_str!("../../shaders/ellipse.wgsl").into()),
|
||||
source: wgpu::ShaderSource::Wgsl(draw_order_shader!("ellipse.wgsl").into()),
|
||||
});
|
||||
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("ellipse.pipeline.layout"),
|
||||
@@ -145,7 +145,7 @@ pub fn create_pipelines(
|
||||
},
|
||||
depth_stencil: Some(wgpu::DepthStencilState {
|
||||
format: wgpu::TextureFormat::Depth24PlusStencil8,
|
||||
depth_write_enabled: Some(false),
|
||||
depth_write_enabled: Some(true),
|
||||
depth_compare: Some(wgpu::CompareFunction::LessEqual),
|
||||
stencil: content_stencil.clone(),
|
||||
bias: wgpu::DepthBiasState::default(),
|
||||
@@ -305,7 +305,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn ellipse_shader_validates_with_naga() {
|
||||
let source = include_str!("../../shaders/ellipse.wgsl");
|
||||
let source = draw_order_shader!("ellipse.wgsl");
|
||||
let module = naga::front::wgsl::parse_str(source).expect("ellipse.wgsl parses cleanly");
|
||||
let mut validator = naga::valid::Validator::new(
|
||||
naga::valid::ValidationFlags::all(),
|
||||
|
||||
@@ -78,9 +78,9 @@ impl HatchGpu {
|
||||
"hatch.texture.shader"
|
||||
}),
|
||||
source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(if uses_storage {
|
||||
include_str!("../../../shaders/hatch.wgsl")
|
||||
draw_order_shader!("hatch.wgsl")
|
||||
} else {
|
||||
include_str!("../../../shaders/hatch_texture.wgsl")
|
||||
draw_order_shader!("hatch_texture.wgsl")
|
||||
})),
|
||||
});
|
||||
let vertex_layouts = if uses_storage {
|
||||
|
||||
+45
-22
@@ -1,5 +1,22 @@
|
||||
// WGSL has no includes. Compose one shared draw-order function into each 2D
|
||||
// shader at compile time, including the sources used by shader validation tests.
|
||||
macro_rules! draw_order_shader {
|
||||
($file:literal) => {
|
||||
concat!(
|
||||
include_str!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/src/shaders/draw_order.wgsl"
|
||||
)),
|
||||
"\n",
|
||||
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/shaders/", $file))
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod gpu_tests;
|
||||
#[cfg(test)]
|
||||
mod depth_tests;
|
||||
mod device_capabilities;
|
||||
pub mod circle_gpu;
|
||||
pub mod ellipse_gpu;
|
||||
@@ -36,6 +53,13 @@ use crate::scene::model::image_model::ImageModel;
|
||||
use crate::scene::model::mesh_model::MeshLodSet;
|
||||
use crate::scene::model::wire_model::WireModel;
|
||||
|
||||
/// Worst-case raster depth bias (in 24-bit quanta, toward the camera) block
|
||||
/// text is required to clear. The wipeout pipeline currently carries no bias
|
||||
/// (a raster bias can jump a block's narrow depth band and erase its
|
||||
/// foreground), so this is a safety budget: the block-text regression asserts
|
||||
/// a larger margin over whatever bias the pipeline could reintroduce.
|
||||
pub const WIPEOUT_DEPTH_BIAS_QUANTA: i32 = 2;
|
||||
|
||||
struct SilhouetteChunk {
|
||||
vertex_buffer: wgpu::Buffer,
|
||||
instance_buffer: wgpu::Buffer,
|
||||
@@ -614,18 +638,18 @@ impl Pipeline {
|
||||
label: Some("wire.shader"),
|
||||
source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(match wire_mode {
|
||||
wire_gpu::WirePipelineMode::IndexedStorage => {
|
||||
include_str!("../../shaders/wire_indexed.wgsl")
|
||||
draw_order_shader!("wire_indexed.wgsl")
|
||||
}
|
||||
wire_gpu::WirePipelineMode::Packed => include_str!("../../shaders/wire.wgsl"),
|
||||
wire_gpu::WirePipelineMode::Packed => draw_order_shader!("wire.wgsl"),
|
||||
})),
|
||||
});
|
||||
let block_wire_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("block_wire.shader"),
|
||||
source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(
|
||||
if wire_mode.uses_storage() {
|
||||
include_str!("../../shaders/block_wire_storage.wgsl")
|
||||
draw_order_shader!("block_wire_storage.wgsl")
|
||||
} else {
|
||||
include_str!("../../shaders/block_wire.wgsl")
|
||||
draw_order_shader!("block_wire.wgsl")
|
||||
},
|
||||
)),
|
||||
});
|
||||
@@ -1011,8 +1035,8 @@ impl Pipeline {
|
||||
|
||||
let wipeout_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("wipeout.shader"),
|
||||
source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(include_str!(
|
||||
"../../shaders/wipeout.wgsl"
|
||||
source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(draw_order_shader!(
|
||||
"wipeout.wgsl"
|
||||
))),
|
||||
});
|
||||
|
||||
@@ -1038,16 +1062,15 @@ impl Pipeline {
|
||||
depth_write_enabled: Some(true),
|
||||
depth_compare: Some(wgpu::CompareFunction::LessEqual),
|
||||
stencil: content_stencil.clone(),
|
||||
// Bias TOWARD the camera: a wipeout must win against geometry at
|
||||
// its own depth (a block's wipeout + shapes are coincident at
|
||||
// Z=0). A positive bias pushed the mask behind, so LessEqual
|
||||
// rejected it and the geometry showed through. Tiny enough that
|
||||
// meaningfully-nearer geometry still occludes the mask.
|
||||
bias: wgpu::DepthBiasState {
|
||||
constant: -8,
|
||||
slope_scale: -1.0,
|
||||
clamp: 0.0,
|
||||
},
|
||||
// The shader already applies draw order. A raster bias can
|
||||
// jump across a block's narrow range and erase its foreground
|
||||
// — block text clears its siblings' wipes by only 9-16 depth
|
||||
// quanta, so the pipeline must stay bias-free. Coincident ties
|
||||
// still resolve toward the mask, which draws later under
|
||||
// LessEqual. WIPEOUT_DEPTH_BIAS_QUANTA is the budget the
|
||||
// block-text regression asserts against if a bias ever
|
||||
// returns.
|
||||
bias: wgpu::DepthBiasState::default(),
|
||||
}),
|
||||
multisample: wgpu::MultisampleState {
|
||||
count: MSAA_SAMPLES,
|
||||
@@ -1826,14 +1849,14 @@ impl Pipeline {
|
||||
// ── Face3D pipeline ────────────────────────────────────────────────
|
||||
let face3d_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("face3d.shader"),
|
||||
source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(include_str!(
|
||||
"../../shaders/face3d.wgsl"
|
||||
source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(draw_order_shader!(
|
||||
"face3d.wgsl"
|
||||
))),
|
||||
});
|
||||
let block_face3d_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("block_face3d.shader"),
|
||||
source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(include_str!(
|
||||
"../../shaders/block_face3d.wgsl"
|
||||
source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(draw_order_shader!(
|
||||
"block_face3d.wgsl"
|
||||
))),
|
||||
});
|
||||
|
||||
@@ -2038,8 +2061,8 @@ impl Pipeline {
|
||||
|
||||
let image_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("image.shader"),
|
||||
source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(include_str!(
|
||||
"../../shaders/image.wgsl"
|
||||
source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(draw_order_shader!(
|
||||
"image.wgsl"
|
||||
))),
|
||||
});
|
||||
|
||||
|
||||
@@ -341,7 +341,7 @@ pub fn create_pipelines(
|
||||
) {
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("text.wgsl"),
|
||||
source: wgpu::ShaderSource::Wgsl(include_str!("../../shaders/text.wgsl").into()),
|
||||
source: wgpu::ShaderSource::Wgsl(draw_order_shader!("text.wgsl").into()),
|
||||
});
|
||||
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("text.pipeline.layout"),
|
||||
@@ -390,7 +390,7 @@ pub fn create_pipelines(
|
||||
};
|
||||
let block_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("block_text.wgsl"),
|
||||
source: wgpu::ShaderSource::Wgsl(include_str!("../../shaders/block_text.wgsl").into()),
|
||||
source: wgpu::ShaderSource::Wgsl(draw_order_shader!("block_text.wgsl").into()),
|
||||
});
|
||||
let create_block = |label, depth_write_enabled, depth_compare| {
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
|
||||
@@ -142,24 +142,39 @@ impl CubeRegion {
|
||||
|
||||
/// Unit eye-direction vector (from target toward the camera) that
|
||||
/// looks straight at this region. Used by `Camera::snap_to_direction`
|
||||
/// which derives the full orientation by re-using the current
|
||||
/// camera's up vector, projected onto the plane perpendicular to
|
||||
/// this direction — so clicking an edge spins the cube around the
|
||||
/// edge without rolling the user's "up" sense.
|
||||
/// / `snap_to_face` which derive a deterministic horizon (world +Z,
|
||||
/// except top/bottom where north +Y is used) so every cube click
|
||||
/// lands repeatably.
|
||||
pub fn snap_direction(self) -> glam::Vec3 {
|
||||
let c = region_centroids()[self.id()];
|
||||
glam::Vec3::new(c[0], c[1], c[2]).normalize_or(glam::Vec3::Z)
|
||||
}
|
||||
|
||||
pub fn opposite(self) -> CubeRegion {
|
||||
match self {
|
||||
CubeRegion::Face(FACE_TOP) => CubeRegion::Face(FACE_BOTTOM),
|
||||
CubeRegion::Face(FACE_BOTTOM) => CubeRegion::Face(FACE_TOP),
|
||||
CubeRegion::Face(FACE_FRONT) => CubeRegion::Face(FACE_BACK),
|
||||
CubeRegion::Face(FACE_BACK) => CubeRegion::Face(FACE_FRONT),
|
||||
CubeRegion::Face(FACE_RIGHT) => CubeRegion::Face(FACE_LEFT),
|
||||
CubeRegion::Face(FACE_LEFT) => CubeRegion::Face(FACE_RIGHT),
|
||||
other => other,
|
||||
// Generic opposite: the region whose centroid is nearest to -self.
|
||||
// Covers faces, edges and corners so "already there → flip" works
|
||||
// for every cube click, not just faces.
|
||||
let centroids = region_centroids();
|
||||
let c = centroids[self.id()];
|
||||
let neg = [-c[0], -c[1], -c[2]];
|
||||
let mut best = self.id();
|
||||
let mut best_d = f32::MAX;
|
||||
for (i, cc) in centroids.iter().enumerate() {
|
||||
let dx = cc[0] - neg[0];
|
||||
let dy = cc[1] - neg[1];
|
||||
let dz = cc[2] - neg[2];
|
||||
let d = dx * dx + dy * dy + dz * dz;
|
||||
if d < best_d {
|
||||
best_d = d;
|
||||
best = i;
|
||||
}
|
||||
}
|
||||
if best < 6 {
|
||||
CubeRegion::Face(best)
|
||||
} else if best < 18 {
|
||||
CubeRegion::Edge(best)
|
||||
} else {
|
||||
CubeRegion::Corner(best)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1460,7 +1460,7 @@ mod block_wire_storage_tests {
|
||||
/// real device, so the stride is checked against the Rust record instead.
|
||||
#[test]
|
||||
fn segment_struct_matches_the_rust_record() {
|
||||
let module = module(include_str!("../../shaders/block_wire_storage.wgsl"));
|
||||
let module = module(draw_order_shader!("block_wire_storage.wgsl"));
|
||||
validate(&module);
|
||||
let mut layouter = naga::proc::Layouter::default();
|
||||
layouter
|
||||
@@ -1484,8 +1484,8 @@ mod block_wire_storage_tests {
|
||||
/// fragment behaviour. Only the geometry source differs.
|
||||
#[test]
|
||||
fn both_block_wire_shaders_expose_the_same_entry_points() {
|
||||
let packed = module(include_str!("../../shaders/block_wire.wgsl"));
|
||||
let storage = module(include_str!("../../shaders/block_wire_storage.wgsl"));
|
||||
let packed = module(draw_order_shader!("block_wire.wgsl"));
|
||||
let storage = module(draw_order_shader!("block_wire_storage.wgsl"));
|
||||
validate(&packed);
|
||||
let names = |m: &naga::Module| {
|
||||
let mut n: Vec<String> = m.entry_points.iter().map(|e| e.name.clone()).collect();
|
||||
@@ -1498,8 +1498,8 @@ mod block_wire_storage_tests {
|
||||
#[test]
|
||||
fn both_block_wire_shaders_honor_fixed_screen_width() {
|
||||
for source in [
|
||||
include_str!("../../shaders/block_wire.wgsl"),
|
||||
include_str!("../../shaders/block_wire_storage.wgsl"),
|
||||
draw_order_shader!("block_wire.wgsl"),
|
||||
draw_order_shader!("block_wire_storage.wgsl"),
|
||||
] {
|
||||
assert!(source.contains("if world_hw < 0.0"));
|
||||
assert!(source.contains(
|
||||
@@ -1510,7 +1510,7 @@ mod block_wire_storage_tests {
|
||||
|
||||
#[test]
|
||||
fn wire_shader_validates_with_naga() {
|
||||
let source = include_str!("../../shaders/wire.wgsl");
|
||||
let source = draw_order_shader!("wire.wgsl");
|
||||
let module = naga::front::wgsl::parse_str(source).expect("wire.wgsl parses cleanly");
|
||||
let mut validator = naga::valid::Validator::new(
|
||||
naga::valid::ValidationFlags::all(),
|
||||
@@ -1523,7 +1523,7 @@ mod block_wire_storage_tests {
|
||||
|
||||
#[test]
|
||||
fn wire_indexed_shader_validates_with_naga() {
|
||||
let source = include_str!("../../shaders/wire_indexed.wgsl");
|
||||
let source = draw_order_shader!("wire_indexed.wgsl");
|
||||
let module = naga::front::wgsl::parse_str(source).expect("wire_indexed.wgsl parses cleanly");
|
||||
let mut validator = naga::valid::Validator::new(
|
||||
naga::valid::ValidationFlags::all(),
|
||||
|
||||
@@ -674,10 +674,7 @@ pub(crate) fn clip_boundary_polygon_for_document(
|
||||
z: f32,
|
||||
) -> Vec<[f32; 3]> {
|
||||
use std::f64::consts::TAU;
|
||||
let Some(entity) = document
|
||||
.entities()
|
||||
.find(|e| e.common().handle == handle)
|
||||
else {
|
||||
let Some(entity) = document.get_entity(handle) else {
|
||||
return vec![];
|
||||
};
|
||||
// Circles and ellipses tessellate directly — their `to_render` returns a
|
||||
|
||||
+29
-2
@@ -12,6 +12,20 @@ pub(crate) fn pe_url_of(entity: &EntityType) -> Option<&str> {
|
||||
.filter(|text| !text.is_empty())
|
||||
}
|
||||
|
||||
/// Optional description stored after the URL in the standard PE_URL record.
|
||||
pub(crate) fn pe_url_description_of(entity: &EntityType) -> Option<&str> {
|
||||
let record = entity.common().extended_data.get_record("PE_URL")?;
|
||||
record
|
||||
.values
|
||||
.iter()
|
||||
.filter_map(|value| match value {
|
||||
acadrust::xdata::XDataValue::String(text) => Some(text.trim()),
|
||||
_ => None,
|
||||
})
|
||||
.nth(1)
|
||||
.filter(|text| !text.is_empty())
|
||||
}
|
||||
|
||||
impl Scene {
|
||||
// ── Selection ─────────────────────────────────────────────────────────
|
||||
/// Treat a classic LEADER and its attached annotation as one logical object.
|
||||
@@ -680,7 +694,9 @@ impl Scene {
|
||||
])
|
||||
} else {
|
||||
match prop.value {
|
||||
PropValue::PlainText(_) => QSelectValueEditor::Text,
|
||||
PropValue::PlainText(_) | PropValue::Hyperlink(_) => {
|
||||
QSelectValueEditor::Text
|
||||
}
|
||||
PropValue::ReadOnly(ref value)
|
||||
| PropValue::ReadOnlyWithTooltip { ref value, .. }
|
||||
| PropValue::EditText(ref value) => {
|
||||
@@ -882,7 +898,8 @@ impl Scene {
|
||||
PropValue::ReadOnly(s)
|
||||
| PropValue::ReadOnlyWithTooltip { value: s, .. }
|
||||
| PropValue::EditText(s)
|
||||
| PropValue::PlainText(s) => s,
|
||||
| PropValue::PlainText(s)
|
||||
| PropValue::Hyperlink(s) => s,
|
||||
PropValue::LayerChoice(s) => s,
|
||||
PropValue::Choice { selected, .. } => selected,
|
||||
PropValue::EditChoice { value, .. } => value,
|
||||
@@ -1018,6 +1035,16 @@ impl Scene {
|
||||
hover_changed = true;
|
||||
}
|
||||
hover_changed |= self.constraint_hover_highlights.remove(&h);
|
||||
if self
|
||||
.constraint_hover_refs
|
||||
.iter()
|
||||
.any(|reference| reference.entity == h)
|
||||
{
|
||||
self.constraint_hover_refs.clear();
|
||||
self.constraint_hover_wires.clear();
|
||||
self.constraint_hover_highlights.clear();
|
||||
hover_changed = true;
|
||||
}
|
||||
self.hatches.remove(&h);
|
||||
self.images.remove(&h);
|
||||
self.meshes.remove(&h);
|
||||
|
||||
+60
-50
@@ -84,13 +84,6 @@ impl Default for Camera {
|
||||
}
|
||||
}
|
||||
|
||||
pub const OPENGL_TO_WGPU: Mat4 = glam::mat4(
|
||||
glam::vec4(1.0, 0.0, 0.0, 0.0),
|
||||
glam::vec4(0.0, 1.0, 0.0, 0.0),
|
||||
glam::vec4(0.0, 0.0, 0.5, 0.0),
|
||||
glam::vec4(0.0, 0.0, 0.5, 1.0),
|
||||
);
|
||||
|
||||
impl Camera {
|
||||
// ── Eye position ───────────────────────────────────────────────────────
|
||||
|
||||
@@ -238,13 +231,9 @@ impl Camera {
|
||||
|
||||
/// Orthographic near/far that CENTRE the target plane at ndc-z ≈ 0.5.
|
||||
///
|
||||
/// The draw-order depth bias shifts clip-z by ±`DRAW_ORDER_BIAS` (0.001).
|
||||
/// The old range (`near = distance*0.001`, `far = distance*1000`) parked the
|
||||
/// geometry at ndc-z ≈ 0.001 — right on the near plane — so a front-biased
|
||||
/// entity landed exactly at z = 0 and got clipped the moment f32 rounding
|
||||
/// (worse at high zoom) tipped it past the plane, making the drawing vanish.
|
||||
/// A symmetric range gives the bias half the depth buffer of headroom on
|
||||
/// each side; ortho permits a negative near.
|
||||
/// A symmetric range gives draw-order offsets headroom on each side and
|
||||
/// keeps the target away from both clipping planes. The shared shader
|
||||
/// bounds those offsets near either plane; ortho permits a negative near.
|
||||
fn ortho_depth_range(&self) -> (f32, f32) {
|
||||
// Generous headroom based on the current screen size so that rotating any
|
||||
// geometry visible on screen in 3D never penetrates the near/far planes.
|
||||
@@ -296,7 +285,9 @@ impl Camera {
|
||||
orthographic(-w, w, -h, h, near, far)
|
||||
}
|
||||
};
|
||||
OPENGL_TO_WGPU * proj * view
|
||||
// The DirectX projection functions already produce WebGPU's [0, 1]
|
||||
// depth range. An OpenGL remap here would compress it to [0.5, 1].
|
||||
proj * view
|
||||
}
|
||||
|
||||
/// Project a world point to screen pixels with full f64 precision: the
|
||||
@@ -654,44 +645,29 @@ impl Camera {
|
||||
/// Snap to a canonical view direction (called by ViewCubeSnap).
|
||||
/// `eye_dir` is the unit vector from the target toward the camera.
|
||||
///
|
||||
/// Up vector resolution:
|
||||
/// 1. Take the current up.
|
||||
/// 2. Pick the world axis (±X, ±Y, ±Z) whose dot product with the
|
||||
/// current up is highest — skipping any axis (anti-)parallel to
|
||||
/// the new gaze direction.
|
||||
/// 3. Project that axis onto the plane ⊥ `new_eye` and use that as
|
||||
/// the new up.
|
||||
///
|
||||
/// Result: small tilts collapse onto the nearest world axis (so the
|
||||
/// view always lands cleanly aligned), while genuine flips of the
|
||||
/// up-sense (e.g. orbited upside-down) are preserved.
|
||||
/// Deterministic horizon: when looking close to ±Z (top/bottom) the
|
||||
/// up is north (+Y), otherwise world up (+Z), projected onto the
|
||||
/// plane ⊥ `new_eye`. This matches the turntable `orbit` (which never
|
||||
/// banks) so a cube corner always lands with the same roll and the
|
||||
/// base never appears rotated. The sign is flipped only if the
|
||||
/// current view is intentionally upside-down, preserving that sense.
|
||||
pub fn snap_to_direction(&mut self, eye_dir: Vec3, ucs: glam::Mat4) {
|
||||
let new_eye = eye_dir.normalize_or(Vec3::Z);
|
||||
let cur_up = self.rotation * Vec3::Y;
|
||||
// Candidate up axes are the UCS axes, not world X/Y/Z, so a face snap
|
||||
// lands the view square to the user's coordinate system (in-plane roll
|
||||
// included). Identity `ucs` reproduces the world-aligned snap.
|
||||
let ux = ucs.transform_vector3(Vec3::X).normalize_or(Vec3::X);
|
||||
let uy = ucs.transform_vector3(Vec3::Y).normalize_or(Vec3::Y);
|
||||
let uz = ucs.transform_vector3(Vec3::Z).normalize_or(Vec3::Z);
|
||||
let cardinals = [ux, -ux, uy, -uy, uz, -uz];
|
||||
let mut best_score = f32::NEG_INFINITY;
|
||||
let mut best_up = uz;
|
||||
for axis in cardinals {
|
||||
// Skip axes (nearly) collinear with the new gaze — they can't
|
||||
// serve as up because the projection onto the plane would
|
||||
// vanish.
|
||||
if axis.dot(new_eye).abs() > 0.999 {
|
||||
continue;
|
||||
}
|
||||
let score = axis.dot(cur_up);
|
||||
if score > best_score {
|
||||
best_score = score;
|
||||
best_up = axis;
|
||||
}
|
||||
}
|
||||
// Project the chosen axis onto the plane ⊥ new_eye and normalize.
|
||||
let projected = best_up - new_eye * best_up.dot(new_eye);
|
||||
// Deterministic horizon: top/bottom views (eye ≈ ±Z) use north (+Y)
|
||||
// as up; every other direction uses world up (+Z). This keeps the
|
||||
// cube's "Top Front Right" etc. repeatable and aligned with the
|
||||
// turntable orbit (which never banks), and avoids the previous
|
||||
// adaptive choice that picked the nearest cardinal to the current
|
||||
// up — from a top view that was +Y, so an oblique top corner kept
|
||||
// Y as up and tilted the base (roll) instead of keeping the horizon
|
||||
// level. Preserve the sign only to keep an intentionally inverted
|
||||
// (upside-down) view inverted.
|
||||
let cur_up = self.rotation * Vec3::Y;
|
||||
let raw_ref = if new_eye.dot(uz).abs() > 0.9 { uy } else { uz };
|
||||
let up_ref = if cur_up.dot(raw_ref) < 0.0 { -raw_ref } else { raw_ref };
|
||||
let projected = up_ref - new_eye * up_ref.dot(new_eye);
|
||||
let new_up = projected.normalize_or(if new_eye.dot(uz).abs() < 0.99 {
|
||||
(uz - new_eye * uz.dot(new_eye)).normalize()
|
||||
} else {
|
||||
@@ -761,7 +737,7 @@ impl Camera {
|
||||
// ── Internal helpers ───────────────────────────────────────────────────
|
||||
|
||||
/// Derive yaw and pitch from the current quaternion.
|
||||
fn sync_yaw_pitch(&mut self) {
|
||||
pub(crate) fn sync_yaw_pitch(&mut self) {
|
||||
let eye_dir = self.rotation * Vec3::Z;
|
||||
self.pitch = eye_dir.z.clamp(-1.0, 1.0).asin();
|
||||
self.yaw = if eye_dir.x.abs() < 1e-6 && eye_dir.y.abs() < 1e-6 {
|
||||
@@ -798,6 +774,40 @@ pub fn yaw_pitch_to_quat(yaw: f32, pitch: f32, roll: f32) -> Quat {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn projection_uses_the_webgpu_depth_range_once() {
|
||||
let bounds = Rectangle::with_size(iced::Size::new(800., 600.));
|
||||
for projection in [Projection::Orthographic, Projection::Perspective] {
|
||||
let camera = Camera {
|
||||
projection,
|
||||
rotation: Quat::IDENTITY,
|
||||
..Camera::default()
|
||||
};
|
||||
let (near, far) = match projection {
|
||||
Projection::Perspective => (camera.distance * 0.001, camera.distance * 1000.),
|
||||
Projection::Orthographic => camera.ortho_depth_range(),
|
||||
};
|
||||
let matrix = camera.view_proj_rte(bounds);
|
||||
let depth = |distance: f32| {
|
||||
let clip = matrix * glam::vec4(0., 0., -distance, 1.);
|
||||
clip.z / clip.w
|
||||
};
|
||||
assert!(depth(near).abs() < 1e-5, "{projection:?}: near must map to 0");
|
||||
assert!(
|
||||
(depth(far) - 1.).abs() < 1e-5,
|
||||
"{projection:?}: far must map to 1"
|
||||
);
|
||||
assert!(
|
||||
depth(near - (far - near) * 1e-7) < 0.,
|
||||
"near clipping boundary moved"
|
||||
);
|
||||
assert!(depth(far * 2.) > 1., "far clipping boundary moved");
|
||||
if projection == Projection::Orthographic {
|
||||
assert!((depth(camera.distance) - 0.5).abs() < 1e-5);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A drawing 140 units wide carrying one entity 800 km below its plane must
|
||||
/// still zoom to the 140 units — the outlier belongs to the depth range, not
|
||||
/// to the zoom. (Sizing both from the 3-D diagonal made the drawing a dot.)
|
||||
|
||||
@@ -3102,14 +3102,19 @@ impl Scene {
|
||||
let Some(style) = self.display_plot_style() else {
|
||||
return None;
|
||||
};
|
||||
// Depth generation rides in the key: the fills below compose their
|
||||
// depth from the live depth map, which can move independently of the
|
||||
// wire set (a DRAWORDER edit re-ranks without retessellating).
|
||||
let key = (
|
||||
source_gen,
|
||||
style.name.to_ascii_lowercase(),
|
||||
pattern_scale.to_bits(),
|
||||
self.draw_depth_generation(),
|
||||
);
|
||||
if let Some(hatches) = self.styled_wire_fill_cache.borrow().get(&key) {
|
||||
return Some(Arc::clone(hatches));
|
||||
}
|
||||
let depths = self.draw_depth_map();
|
||||
let mut hatches = Vec::new();
|
||||
for wire in wires.iter().filter(|wire| wire.fill_is_2d_solid && wire.aci > 0) {
|
||||
let Some(pattern) = style
|
||||
@@ -3161,7 +3166,14 @@ impl Scene {
|
||||
line_weight_px,
|
||||
angle_offset: 0.0,
|
||||
scale: pattern_scale,
|
||||
draw_depth: wire.depth_override.unwrap_or(0.0),
|
||||
// Compose against the scene graph exactly like the wire
|
||||
// pipeline does. The raw depth_override is a per-block
|
||||
// child label (or None for top-level wires) — either way
|
||||
// it would place the fill outside its host's depth band
|
||||
// and let sibling wipes/masks bury it.
|
||||
draw_depth: crate::scene::pipeline::wire_gpu::wire_draw_depth(
|
||||
wire, &depths,
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -4254,15 +4266,20 @@ impl Scene {
|
||||
Arc::new(Vec::new())
|
||||
};
|
||||
let preview_wires = if !show_live_overlay
|
||||
|| (self.interim_wire.is_none() && self.preview_wires.is_empty())
|
||||
|| (self.interim_wire.is_none()
|
||||
&& self.preview_wires.is_empty()
|
||||
&& self.constraint_hover_wires.is_empty())
|
||||
{
|
||||
Arc::new(Vec::new())
|
||||
} else {
|
||||
let mut v: Vec<WireModel> = Vec::with_capacity(self.preview_wires.len() + 1);
|
||||
let mut v: Vec<WireModel> = Vec::with_capacity(
|
||||
self.preview_wires.len() + self.constraint_hover_wires.len() + 1,
|
||||
);
|
||||
if let Some(iw) = &self.interim_wire {
|
||||
v.push(iw.clone());
|
||||
}
|
||||
v.extend(self.preview_wires.iter().cloned());
|
||||
v.extend(self.constraint_hover_wires.iter().cloned());
|
||||
Arc::new(v)
|
||||
};
|
||||
let preview_hatches = if show_live_overlay {
|
||||
|
||||
@@ -0,0 +1,423 @@
|
||||
//! Pick dimension geometry through a layout viewport. Resolve block instances
|
||||
//! in model space, then project the picked entity onto the paper sheet.
|
||||
|
||||
use super::*;
|
||||
|
||||
use crate::command::EntityTransform;
|
||||
use crate::scene::viewport_ref::ViewportFrame;
|
||||
use acadrust::types::{Matrix4, Transform};
|
||||
#[cfg(test)]
|
||||
use glam::DVec2;
|
||||
use glam::DVec3;
|
||||
|
||||
/// A model entity resolved by clicking inside a paper-space content viewport.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ViewportDimensionPick {
|
||||
/// The viewport the click looked through.
|
||||
pub frame: ViewportFrame,
|
||||
/// Handle of the innermost model entity that owns the picked geometry.
|
||||
pub entity_handle: Handle,
|
||||
/// INSERT handles from the outermost instance down to the instance that
|
||||
/// directly contains `entity_handle`; empty for top-level model geometry.
|
||||
pub block_path: Vec<Handle>,
|
||||
/// The picked geometry with the block-instance transform *and* the
|
||||
/// viewport model→paper transform applied, so paper-space dimension
|
||||
/// commands can consume it unchanged.
|
||||
pub paper_entity: EntityType,
|
||||
/// The click position on the sheet.
|
||||
pub paper_point: DVec3,
|
||||
/// The click position in model space.
|
||||
pub model_point: DVec3,
|
||||
}
|
||||
|
||||
impl Scene {
|
||||
/// Resolve an explicit dimension object pick made on the sheet but landing
|
||||
/// inside a content viewport, against the *model* geometry that viewport
|
||||
/// displays.
|
||||
///
|
||||
/// `aperture_paper` is the pick aperture in paper units.
|
||||
pub fn dimension_pick_through_viewport(
|
||||
&self,
|
||||
paper: DVec3,
|
||||
aperture_paper: f64,
|
||||
) -> Option<ViewportDimensionPick> {
|
||||
for frame in self.viewport_frames_at_paper_point(paper) {
|
||||
let model_point = frame.paper_to_model(paper);
|
||||
let aperture_model =
|
||||
(aperture_paper.max(0.0) * frame.paper_to_model_length_factor()).max(1e-9);
|
||||
|
||||
let Some(top) = self.nearest_model_wire_handle(&frame, model_point, aperture_model)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Some((entity, block_path)) =
|
||||
self.resolve_measurable_entity(top, model_point, Some(frame.viewport))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if !planar_pick_distance(&entity, model_point).is_some_and(|d| d <= aperture_model) {
|
||||
continue;
|
||||
}
|
||||
let mut paper_entity = entity;
|
||||
crate::scene::view::dispatch::apply_transform(
|
||||
&mut paper_entity,
|
||||
&EntityTransform::Affine(viewport_model_to_paper_transform(&frame)),
|
||||
);
|
||||
let entity_handle = paper_entity.as_entity().handle();
|
||||
return Some(ViewportDimensionPick {
|
||||
frame,
|
||||
entity_handle: if entity_handle.is_valid() {
|
||||
entity_handle
|
||||
} else {
|
||||
top
|
||||
},
|
||||
block_path,
|
||||
paper_entity,
|
||||
paper_point: paper,
|
||||
model_point,
|
||||
});
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Nearest resident model wire of `viewport` to `model_point`, within
|
||||
/// `aperture` model units. Wires are the same tessellation the viewport
|
||||
/// draws, so this matches what the user sees.
|
||||
fn nearest_model_wire_handle(
|
||||
&self,
|
||||
frame: &ViewportFrame,
|
||||
model_point: DVec3,
|
||||
aperture: f64,
|
||||
) -> Option<Handle> {
|
||||
let viewport = frame.viewport;
|
||||
let camera = self.camera_for_viewport(viewport)?;
|
||||
let bounds = iced::Rectangle {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
width: 1000.0,
|
||||
height: 1000.0,
|
||||
};
|
||||
let radius_px =
|
||||
(aperture / (2.0 * camera.ortho_size() as f64) * bounds.height as f64) as f32;
|
||||
let wires = self.interaction_pick_candidates_near(
|
||||
self.model_wires_for_viewport_arc(viewport, 0.0),
|
||||
model_point,
|
||||
camera.view_proj_rte(bounds),
|
||||
camera.eye(),
|
||||
bounds,
|
||||
radius_px.max(1.0),
|
||||
);
|
||||
let mut best: Option<(f64, Handle)> = None;
|
||||
for wire in wires.iter() {
|
||||
if !wire.display_visible {
|
||||
continue;
|
||||
}
|
||||
let Some(handle) = Self::handle_from_wire_name(&wire.name) else {
|
||||
continue;
|
||||
};
|
||||
let offset = wire
|
||||
.render_instance
|
||||
.as_ref()
|
||||
.map(|inst| DVec3::from_array(inst.translation))
|
||||
.unwrap_or(DVec3::ZERO);
|
||||
let distance = wire_polyline_nearest(wire, offset, model_point);
|
||||
if let Some((distance, nearest)) = distance {
|
||||
if !self.viewport_displays_paper_point(
|
||||
viewport,
|
||||
frame.model_to_paper(nearest).truncate(),
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if distance <= aperture && best.as_ref().is_none_or(|(d, _)| distance < *d) {
|
||||
best = Some((distance, handle));
|
||||
}
|
||||
}
|
||||
}
|
||||
best.map(|(_, handle)| handle)
|
||||
}
|
||||
|
||||
/// Turn a picked handle into a measurable planar entity in model (WCS)
|
||||
/// coordinates, descending through block instances and baking the
|
||||
/// instance transform into the returned clone.
|
||||
pub(crate) fn resolve_measurable_entity(
|
||||
&self,
|
||||
handle: Handle,
|
||||
model_point: DVec3,
|
||||
viewport: Option<Handle>,
|
||||
) -> Option<(EntityType, Vec<Handle>)> {
|
||||
self.resolve_measurable_feature(handle, model_point, viewport, None)
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_measurable_feature(
|
||||
&self,
|
||||
handle: Handle,
|
||||
model_point: DVec3,
|
||||
viewport: Option<Handle>,
|
||||
kind: Option<crate::snap::SnapType>,
|
||||
) -> Option<(EntityType, Vec<Handle>)> {
|
||||
let entity = self.document.get_entity(handle)?;
|
||||
match entity {
|
||||
EntityType::Insert(insert) => self.descend_block_instance(
|
||||
insert,
|
||||
Transform::identity(),
|
||||
model_point,
|
||||
0,
|
||||
viewport,
|
||||
kind,
|
||||
),
|
||||
other => {
|
||||
feature_pick_distance(other, model_point, kind)?;
|
||||
Some((other.clone(), Vec::new()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Depth-first search for the nearest measurable entity inside an INSERT.
|
||||
/// Returns the entity already transformed into WCS plus the INSERT path.
|
||||
fn descend_block_instance(
|
||||
&self,
|
||||
insert: &acadrust::entities::Insert,
|
||||
outer: Transform,
|
||||
model_point: DVec3,
|
||||
depth: usize,
|
||||
viewport: Option<Handle>,
|
||||
kind: Option<crate::snap::SnapType>,
|
||||
) -> Option<(EntityType, Vec<Handle>)> {
|
||||
const MAX_DEPTH: usize = 8;
|
||||
if depth >= MAX_DEPTH {
|
||||
return None;
|
||||
}
|
||||
if insert.row_count > 1 || insert.column_count > 1 {
|
||||
return None;
|
||||
}
|
||||
let insert_handle = insert.common.handle;
|
||||
let local = crate::scene::render_graph::insert_transform(&self.document, insert);
|
||||
let combined = local.then(&outer);
|
||||
let block = self.document.block_records.get(&insert.block_name)?;
|
||||
|
||||
let mut best: Option<(f64, EntityType, Vec<Handle>)> = None;
|
||||
for &child in &block.entity_handles {
|
||||
let Some(child_entity) = self.document.get_entity(child) else {
|
||||
continue;
|
||||
};
|
||||
let common = child_entity.common();
|
||||
let layer = self.document.layers.get(&common.layer);
|
||||
let frozen = viewport.and_then(|h| self.document.get_entity(h)).is_some_and(|entity| {
|
||||
matches!(entity, EntityType::Viewport(vp) if layer.is_some_and(|l| vp.frozen_layers.contains(&l.handle)))
|
||||
});
|
||||
if common.invisible || layer.is_some_and(|l| l.flags.off || l.flags.frozen) || frozen {
|
||||
continue;
|
||||
}
|
||||
if let EntityType::Insert(child_insert) = child_entity {
|
||||
if let Some((entity, mut nested_path)) = self.descend_block_instance(
|
||||
child_insert,
|
||||
combined,
|
||||
model_point,
|
||||
depth + 1,
|
||||
viewport,
|
||||
kind,
|
||||
) {
|
||||
if let Some(distance) = feature_pick_distance(&entity, model_point, kind) {
|
||||
if best.as_ref().is_none_or(|(d, _, _)| distance < *d) {
|
||||
let mut full = vec![insert_handle];
|
||||
full.append(&mut nested_path);
|
||||
best = Some((distance, entity, full));
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// A nonuniform block transform turns circular geometry into an
|
||||
// ellipse. The entity transform API retains the circle type, so
|
||||
// decline this object pick instead of manufacturing a radius.
|
||||
let curved = matches!(child_entity, EntityType::Circle(_) | EntityType::Arc(_))
|
||||
|| matches!(child_entity, EntityType::LwPolyline(p) if p.vertices.iter().any(|v| v.bulge != 0.0));
|
||||
let m = &combined.matrix.m;
|
||||
let x = DVec3::new(m[0][0], m[1][0], m[2][0]);
|
||||
let y = DVec3::new(m[0][1], m[1][1], m[2][1]);
|
||||
let magnitude = x.length_squared().max(y.length_squared());
|
||||
if curved
|
||||
&& kind != Some(crate::snap::SnapType::Center)
|
||||
&& (magnitude < 1e-24
|
||||
|| (x.length_squared() - y.length_squared()).abs() > magnitude * 1e-10
|
||||
|| x.dot(y).abs() > magnitude * 1e-10)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let mut placed = child_entity.clone();
|
||||
crate::scene::view::dispatch::apply_transform(
|
||||
&mut placed,
|
||||
&EntityTransform::Affine(combined),
|
||||
);
|
||||
let Some(distance) = feature_pick_distance(&placed, model_point, kind) else {
|
||||
continue;
|
||||
};
|
||||
if best.as_ref().is_none_or(|(d, _, _)| distance < *d) {
|
||||
best = Some((distance, placed, vec![insert_handle]));
|
||||
}
|
||||
}
|
||||
let (_, entity, found_path) = best?;
|
||||
Some((entity, found_path))
|
||||
}
|
||||
}
|
||||
|
||||
/// Model→paper for a viewport, as an affine entity transform. Z passes
|
||||
/// through unchanged so planar geometry keeps its elevation.
|
||||
pub fn viewport_model_to_paper_transform(frame: &ViewportFrame) -> Transform {
|
||||
let (sin, cos) = frame.twist.sin_cos();
|
||||
let s = frame.scale;
|
||||
let a = cos * s;
|
||||
let b = sin * s;
|
||||
// t = paper_center - R*s*model_target
|
||||
let tx = frame.paper_center.x - (a * frame.model_target.x - b * frame.model_target.y);
|
||||
let ty = frame.paper_center.y - (b * frame.model_target.x + a * frame.model_target.y);
|
||||
Transform::from_matrix(Matrix4 {
|
||||
m: [
|
||||
[a, -b, 0.0, tx],
|
||||
[b, a, 0.0, ty],
|
||||
[0.0, 0.0, 1.0, 0.0],
|
||||
[0.0, 0.0, 0.0, 1.0],
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
/// Distance from `point` to a wire's polyline, in XY. `None` when the wire has
|
||||
/// no usable segment.
|
||||
fn wire_polyline_nearest(
|
||||
wire: &crate::scene::WireModel,
|
||||
offset: DVec3,
|
||||
point: DVec3,
|
||||
) -> Option<(f64, DVec3)> {
|
||||
let count = wire.points.len();
|
||||
if count == 0 {
|
||||
return None;
|
||||
}
|
||||
let at = |i: usize| -> DVec3 {
|
||||
let hi = wire.points[i];
|
||||
let lo = wire.points_low.get(i).copied().unwrap_or([0.0; 3]);
|
||||
DVec3::new(
|
||||
hi[0] as f64 + lo[0] as f64,
|
||||
hi[1] as f64 + lo[1] as f64,
|
||||
hi[2] as f64 + lo[2] as f64,
|
||||
) + offset
|
||||
};
|
||||
let mut best = f64::INFINITY;
|
||||
let mut best_point = DVec3::ZERO;
|
||||
let mut previous: Option<DVec3> = None;
|
||||
for i in 0..count {
|
||||
let current = at(i);
|
||||
if !current.x.is_finite() || !current.y.is_finite() {
|
||||
previous = None;
|
||||
continue;
|
||||
}
|
||||
let nearest = previous.map_or(current, |start| {
|
||||
let segment = cadkernel::geom2d::Curve::Line(cadkernel::geom2d::Line {
|
||||
start: start.truncate().to_array(),
|
||||
end: current.truncate().to_array(),
|
||||
});
|
||||
let near = cadkernel::geom2d::closest_point(&segment, point.truncate().to_array());
|
||||
start.lerp(current, near.t)
|
||||
});
|
||||
let distance = (nearest - point).truncate().length();
|
||||
if distance < best {
|
||||
best = distance;
|
||||
best_point = nearest;
|
||||
}
|
||||
previous = Some(current);
|
||||
}
|
||||
best.is_finite().then_some((best, best_point))
|
||||
}
|
||||
|
||||
/// Distance from `point` to the planar geometry object-pick dimensioning
|
||||
/// supports. `None` for entity types that cannot be dimensioned by picking.
|
||||
pub fn planar_pick_distance(entity: &EntityType, point: DVec3) -> Option<f64> {
|
||||
let planar = crate::entities::curve::entity_curve(entity)?;
|
||||
let normal = DVec3::from_array(planar.plane.normal()?);
|
||||
if normal.cross(DVec3::Z).length_squared() > 1e-12 {
|
||||
return None;
|
||||
}
|
||||
let uv = planar.plane.project(point.to_array())?;
|
||||
let nearest = cadkernel::geom2d::closest_point(&planar.curve, uv);
|
||||
Some(
|
||||
(DVec3::from_array(planar.plane.point_at(nearest.point)) - point)
|
||||
.truncate()
|
||||
.length(),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn frame() -> ViewportFrame {
|
||||
ViewportFrame {
|
||||
viewport: Handle::NULL,
|
||||
paper_center: DVec2::new(100.0, 50.0),
|
||||
model_target: DVec2::new(1000.0, 2000.0),
|
||||
scale: 0.1,
|
||||
twist: 0.4,
|
||||
locked: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn affine_transform_matches_the_frame_mapping() {
|
||||
let f = frame();
|
||||
let t = viewport_model_to_paper_transform(&f);
|
||||
for model in [
|
||||
DVec3::new(1000.0, 2000.0, 0.0),
|
||||
DVec3::new(1100.0, 2000.0, 0.0),
|
||||
DVec3::new(940.5, 2113.25, 0.0),
|
||||
] {
|
||||
let expected = f.model_to_paper(model);
|
||||
let actual = t.apply(acadrust::types::Vector3::new(model.x, model.y, model.z));
|
||||
assert!(
|
||||
(actual.x - expected.x).abs() < 1e-9 && (actual.y - expected.y).abs() < 1e-9,
|
||||
"{actual:?} != {expected:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Distance to the particular feature accepted by a snap, rather than to the
|
||||
/// nearest curve. A circle's center is deliberately not on its circumference.
|
||||
pub(crate) fn feature_pick_distance(
|
||||
entity: &EntityType,
|
||||
point: DVec3,
|
||||
kind: Option<crate::snap::SnapType>,
|
||||
) -> Option<f64> {
|
||||
use crate::snap::SnapType as S;
|
||||
let points: Vec<acadrust::types::Vector3> = match kind {
|
||||
Some(S::Center) => match entity {
|
||||
EntityType::Circle(c) => vec![c.center_wcs()],
|
||||
EntityType::Arc(a) => vec![a.center_wcs()],
|
||||
EntityType::Ellipse(e) => vec![e.center],
|
||||
_ => return None,
|
||||
},
|
||||
Some(S::Endpoint) => crate::scene::dimension_assoc::source_points(entity),
|
||||
Some(S::Node) => match entity {
|
||||
EntityType::Point(p) => vec![p.location],
|
||||
_ => return None,
|
||||
},
|
||||
Some(S::Midpoint | S::Quadrant) => {
|
||||
let planar = crate::entities::curve::entity_curve(entity)?;
|
||||
cadkernel::geom2d::snap::characteristic_points(&planar.curve)
|
||||
.into_iter()
|
||||
.filter(|p| match kind {
|
||||
Some(S::Midpoint) => p.kind == cadkernel::geom2d::snap::SnapKind::Midpoint,
|
||||
_ => p.kind == cadkernel::geom2d::snap::SnapKind::Quadrant,
|
||||
})
|
||||
.map(|p| {
|
||||
let p = planar.plane.point_at(p.point);
|
||||
acadrust::types::Vector3::new(p[0], p[1], p[2])
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
_ => return planar_pick_distance(entity, point),
|
||||
};
|
||||
points
|
||||
.into_iter()
|
||||
.map(|p| (DVec3::new(p.x, p.y, p.z) - point).truncate().length())
|
||||
.min_by(f64::total_cmp)
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
//! Coordinates and feature identity shared by viewport acquisition and dimensions.
|
||||
|
||||
use acadrust::types::Handle;
|
||||
use glam::{DMat3, DVec2, DVec3};
|
||||
|
||||
use crate::command::DimensionAssociationSource;
|
||||
use crate::snap::SnapType;
|
||||
|
||||
/// Planar orthographic mapping between a layout viewport's paper rectangle and
|
||||
/// the model plane it displays. Handles pan (`model_target` / `paper_center`),
|
||||
/// scale (paper units per model unit) and in-plane rotation (`twist`,
|
||||
/// radians, counter-clockwise, applied to model geometry before scaling).
|
||||
///
|
||||
/// Constructed by `Scene::viewport_frame`. All arithmetic is `f64`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct ViewportFrame {
|
||||
/// Viewport entity handle.
|
||||
pub viewport: Handle,
|
||||
/// Paper-space centre of the viewport rectangle.
|
||||
pub paper_center: DVec2,
|
||||
/// Model-space point displayed at `paper_center`.
|
||||
pub model_target: DVec2,
|
||||
/// Paper units per model unit (e.g. 0.1 for a 1:10 viewport).
|
||||
pub scale: f64,
|
||||
/// In-plane rotation (DXF `twist_angle`), radians.
|
||||
pub twist: f64,
|
||||
/// `true` when the viewport is display-locked (snapping still allowed).
|
||||
pub locked: bool,
|
||||
}
|
||||
|
||||
impl ViewportFrame {
|
||||
/// Model -> paper, as a 2-D affine matrix (homogeneous, column vectors).
|
||||
pub fn model_to_paper_matrix(&self) -> DMat3 {
|
||||
let (s, c) = self.twist.sin_cos();
|
||||
let rs = DMat3::from_cols(
|
||||
DVec3::new(c * self.scale, s * self.scale, 0.0),
|
||||
DVec3::new(-s * self.scale, c * self.scale, 0.0),
|
||||
DVec3::new(0.0, 0.0, 1.0),
|
||||
);
|
||||
let t_model = DMat3::from_translation(-self.model_target);
|
||||
let t_paper = DMat3::from_translation(self.paper_center);
|
||||
t_paper * rs * t_model
|
||||
}
|
||||
|
||||
/// Paper -> model (inverse of [`Self::model_to_paper_matrix`]).
|
||||
pub fn paper_to_model_matrix(&self) -> DMat3 {
|
||||
self.model_to_paper_matrix().inverse()
|
||||
}
|
||||
|
||||
/// Project a model-space point onto the paper sheet. `z` passes through.
|
||||
pub fn model_to_paper(&self, p: DVec3) -> DVec3 {
|
||||
let q = self.model_to_paper_matrix().transform_point2(p.truncate());
|
||||
DVec3::new(q.x, q.y, p.z)
|
||||
}
|
||||
|
||||
/// Lift a paper-sheet point into model space. `z` passes through.
|
||||
pub fn paper_to_model(&self, p: DVec3) -> DVec3 {
|
||||
let q = self.paper_to_model_matrix().transform_point2(p.truncate());
|
||||
DVec3::new(q.x, q.y, p.z)
|
||||
}
|
||||
|
||||
/// Model -> paper for a direction (rotation + scale, no translation).
|
||||
pub fn model_to_paper_dir(&self, d: DVec2) -> DVec2 {
|
||||
self.model_to_paper_matrix().transform_vector2(d)
|
||||
}
|
||||
|
||||
/// Factor a paper-space length must be multiplied by to recover the model
|
||||
/// length it displays (`1 / scale`). This is the *viewport compensation*
|
||||
/// used by [`MeasurementScale`].
|
||||
pub fn paper_to_model_length_factor(&self) -> f64 {
|
||||
1.0 / self.scale
|
||||
}
|
||||
}
|
||||
|
||||
/// Identity of the geometry a snap landed on, in model space, including the
|
||||
/// block-instance path when the entity lives inside a block reference.
|
||||
///
|
||||
/// `source.handle` is the innermost entity (the one owning the feature).
|
||||
/// `block_path` is the ordered list of INSERT handles from the outermost
|
||||
/// instance in the space down to the instance that directly contains the
|
||||
/// entity; empty for top-level geometry.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct SnapSourceRef {
|
||||
pub source: DimensionAssociationSource,
|
||||
pub block_path: Vec<Handle>,
|
||||
pub snap_type: SnapType,
|
||||
/// The other entity/instance path for a true intersection.
|
||||
pub intersection: Option<Box<SnapSourceRef>>,
|
||||
}
|
||||
|
||||
/// A snap accepted by a point-input command, with enough information for
|
||||
/// dimension commands to distinguish *placement* (paper) from *measurement*
|
||||
/// (model) and for association creation to record where it came from.
|
||||
///
|
||||
/// Invariant: when `viewport` is `Some`, `paper_point` is the point on the
|
||||
/// sheet and `model_point` is that same point in model space through the
|
||||
/// viewport frame. When `viewport` is `None` the snap was on paper-space (or
|
||||
/// model-space) geometry directly and `paper_point == model_point`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct AcceptedSnap {
|
||||
/// Point in the coordinate system of the current space (paper when a
|
||||
/// layout is active).
|
||||
pub paper_point: DVec3,
|
||||
/// Point in model space (identical to `paper_point` when `viewport` is
|
||||
/// `None`).
|
||||
pub model_point: DVec3,
|
||||
/// The viewport the snap looked through, if any.
|
||||
pub viewport: Option<Handle>,
|
||||
/// The frame in effect when the snap was accepted (so a later measurement
|
||||
/// uses the same scale even if the viewport is changed afterwards).
|
||||
pub frame: Option<ViewportFrame>,
|
||||
/// Geometry identity, when the snap landed on an entity feature (not a
|
||||
/// grid/free point).
|
||||
pub source: Option<SnapSourceRef>,
|
||||
}
|
||||
|
||||
impl AcceptedSnap {
|
||||
/// A plain point in the current space with no viewport and no source.
|
||||
pub fn free(point: DVec3) -> Self {
|
||||
Self {
|
||||
paper_point: point,
|
||||
model_point: point,
|
||||
viewport: None,
|
||||
frame: None,
|
||||
source: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn through_viewport(&self) -> bool {
|
||||
self.viewport.is_some()
|
||||
}
|
||||
|
||||
/// `snap.world` is always in the command's current space. A projected
|
||||
/// viewport hit also retains its original model coordinate explicitly.
|
||||
pub fn from_snap(snap: &crate::snap::SnapResult, frame: Option<ViewportFrame>) -> Self {
|
||||
let frame = frame.filter(|frame| snap.viewport == Some(frame.viewport));
|
||||
let source_ref = |source| SnapSourceRef {
|
||||
source,
|
||||
block_path: Vec::new(),
|
||||
snap_type: snap.snap_type,
|
||||
intersection: None,
|
||||
};
|
||||
Self {
|
||||
paper_point: snap.world,
|
||||
model_point: frame.map_or(snap.world, |frame| {
|
||||
snap.model_point
|
||||
.unwrap_or_else(|| frame.paper_to_model(snap.world))
|
||||
}),
|
||||
viewport: frame.map(|f| f.viewport),
|
||||
frame,
|
||||
source: snap.source.map(|source| SnapSourceRef {
|
||||
intersection: snap
|
||||
.secondary_source
|
||||
.map(|source| Box::new(source_ref(source))),
|
||||
..source_ref(source)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// The paper point with the viewport context of `self`, but no geometry
|
||||
/// identity — used when a command constrains a snapped point (ortho/polar,
|
||||
/// axis lock) so the recorded point matches what was committed.
|
||||
pub fn with_paper_point(mut self, paper_point: DVec3) -> Self {
|
||||
// Compare in the sheet plane only: paper space is flat and callers
|
||||
// clamp `z` to 0, which must not be mistaken for the point having been
|
||||
// moved off the snapped feature (and must not discard the snapped
|
||||
// model point's real elevation).
|
||||
let moved = self
|
||||
.paper_point
|
||||
.truncate()
|
||||
.distance_squared(paper_point.truncate())
|
||||
> 1e-18;
|
||||
if moved {
|
||||
// The committed point is no longer the feature that was snapped.
|
||||
self.source = None;
|
||||
self.model_point = match &self.frame {
|
||||
Some(frame) => frame.paper_to_model(paper_point),
|
||||
None => paper_point,
|
||||
};
|
||||
}
|
||||
self.paper_point = paper_point;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Length dimensions store raw distances in their owning space. The displayed
|
||||
/// value uses `user_lfac * viewport_compensation`; angular dimensions are exempt.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct MeasurementScale {
|
||||
pub user_lfac: f64,
|
||||
pub viewport_compensation: f64,
|
||||
}
|
||||
|
||||
impl MeasurementScale {
|
||||
/// OCS bookkeeping keeps user scaling separate from the changing viewport
|
||||
/// scale. Standard DIMLFAC still carries the full factor for other readers.
|
||||
pub const APP_ID: &'static str = "OCS_VIEWPORT_MEASUREMENT";
|
||||
|
||||
pub fn read(data: &acadrust::xdata::ExtendedData) -> Option<Self> {
|
||||
use acadrust::xdata::XDataValue;
|
||||
let record = data.get_record(Self::APP_ID)?;
|
||||
let [XDataValue::Integer16(1), XDataValue::Real(user), XDataValue::Real(compensation)] =
|
||||
record.values.as_slice()
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
if !user.is_finite() || *user <= 0.0 || !compensation.is_finite() || *compensation <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
Some(Self {
|
||||
user_lfac: *user,
|
||||
viewport_compensation: *compensation,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn write_to_entity(self, entity: &mut acadrust::EntityType) {
|
||||
use acadrust::xdata::{ExtendedData, ExtendedDataRecord, XDataValue};
|
||||
crate::entities::dim_override::set_on_entity(
|
||||
entity,
|
||||
crate::entities::dim_override::DIMLFAC,
|
||||
Some(XDataValue::Real(-self.paper_factor())),
|
||||
);
|
||||
let common = entity.common_mut();
|
||||
let mut data = ExtendedData::new();
|
||||
for record in common
|
||||
.extended_data
|
||||
.records()
|
||||
.iter()
|
||||
.filter(|r| r.application_name != Self::APP_ID)
|
||||
{
|
||||
data.add_record(record.clone());
|
||||
}
|
||||
let mut record = ExtendedDataRecord::new(Self::APP_ID);
|
||||
record.add_value(XDataValue::Integer16(1));
|
||||
record.add_value(XDataValue::Real(self.user_lfac));
|
||||
record.add_value(XDataValue::Real(self.viewport_compensation));
|
||||
data.add_record(record);
|
||||
// Parsed records are authoritative after changing measurement data.
|
||||
common.extended_data = data;
|
||||
}
|
||||
|
||||
/// Combined factor applied to a distance measured on the paper sheet.
|
||||
pub fn paper_factor(&self) -> f64 {
|
||||
self.user_lfac * self.viewport_compensation
|
||||
}
|
||||
|
||||
/// Positive DIMLFAC applies everywhere; negative DIMLFAC applies only on
|
||||
/// paper. Zero means one. Saved viewport compensation is already in DIMLFAC.
|
||||
pub fn user_lfac_for_space(dimlfac: f64, paper_space: bool) -> f64 {
|
||||
if dimlfac == 0.0 {
|
||||
1.0
|
||||
} else if dimlfac > 0.0 {
|
||||
dimlfac
|
||||
} else if paper_space {
|
||||
-dimlfac
|
||||
} else {
|
||||
1.0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn frame(scale: f64, twist: f64) -> ViewportFrame {
|
||||
ViewportFrame {
|
||||
viewport: Handle::NULL,
|
||||
paper_center: DVec2::new(100.0, 50.0),
|
||||
model_target: DVec2::new(1000.0, 2000.0),
|
||||
scale,
|
||||
twist,
|
||||
locked: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips_with_scale_and_twist() {
|
||||
let f = frame(0.1, 0.3);
|
||||
let m = DVec3::new(1234.5, 1987.25, 0.0);
|
||||
let p = f.model_to_paper(m);
|
||||
let back = f.paper_to_model(p);
|
||||
assert!((back - m).length() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scale_maps_lengths() {
|
||||
let f = frame(0.1, 0.0);
|
||||
let a = f.model_to_paper(DVec3::new(0.0, 0.0, 0.0));
|
||||
let b = f.model_to_paper(DVec3::new(100.0, 0.0, 0.0));
|
||||
assert!(((b - a).length() - 10.0).abs() < 1e-9);
|
||||
assert!((f.paper_to_model_length_factor() - 10.0).abs() < 1e-12);
|
||||
assert_eq!(frame(1e-14, 0.0).paper_to_model_length_factor(), 1e14);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dimlfac_sign_depends_on_dimension_space() {
|
||||
for (factor, paper, model) in [(-2.0, 2.0, 1.0), (0.0, 1.0, 1.0), (3.5, 3.5, 3.5)] {
|
||||
assert_eq!(MeasurementScale::user_lfac_for_space(factor, true), paper);
|
||||
assert_eq!(MeasurementScale::user_lfac_for_space(factor, false), model);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user