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:
Felix Riestra committed 2026-09-19 23:58:01 +02:00
commit 8648225ac5
309 files changed
+81166 -4157

No files matched your search

+37 -10
View File
@@ -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;
+212 -9
View File
@@ -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());
}
}
+5 -2
View File
@@ -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),
},
],
};
+1
View File
@@ -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;
}
+69
View File
@@ -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 {
File diff suppressed because it is too large. Load diff
+587
View File
@@ -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(&parameter) {
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)
}
+222 -34
View File
@@ -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
View File
@@ -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
View File
@@ -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() {
+125
View File
@@ -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));
}
}
+3
View File
@@ -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.
+20 -7
View File
@@ -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,
}))
}
+63
View File
@@ -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
+12
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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()?;
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+30 -2
View File
@@ -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
+3 -3
View File
@@ -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(),
+454
View File
@@ -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"
);
}
}
+3 -3
View File
@@ -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(),
+2 -2
View File
@@ -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
View File
@@ -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"
))),
});
+2 -2
View File
@@ -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 {
+27 -12
View File
@@ -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)
}
}
+7 -7
View File
@@ -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(),
+1 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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.)
+20 -3
View File
@@ -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 {
+423
View File
@@ -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)
}
+303
View File
@@ -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);
}
}
}