forked from 1b-agent/opencadstudio-upstream
Ports the fixes from fix/osnap-circle-arc-ellipse-intersection (and this session's audit follow-up) onto this branch. All are independent correctness/reliability fixes, unrelated to the parametric-constraint- system work also in progress here. 1. OSNAP Intersection snap used a tessellated approximation instead of exact geometry (GitHub discussion #1052): a circle/arc always tessellates at a fixed 48 segments in the resident wire set regardless of radius or zoom, so every circle/arc intersection snap was off by construction. Added exact_circle_intersections, a closed-form, frame-independent circle/arc-vs-circle/arc solver, wired into both Intersection-snap code paths ahead of the tessellated sweep. This branch's TangentGeom predates the PlanarEllipse variant added upstream, so the ellipse/line generalization from the upstream fix isn't ported here — this covers the literal reported bug (a circle-circle case) using only variants this branch already has. 2. Unbounded MINSERT allocation: array_offsets() reserved one Vec element per array instance with row/column counts parsed as unchecked u16s — a corrupt 65535x65535 pair requests ~96 GiB. Fixed with a documented instance budget (20,000), enforced at both the load-time corrupt-entity filter and a render-graph failsafe. 3. MINSERT spacing was scaled by the block's own scale: insert_instance_transform composed the offset translation before the instance transform, so x/y block scale multiplied the row/ column spacing too. Fixed by orienting the offset with rotation + OCS only, then translating the already-scaled instance transform. 4. Automation "open" skipped the finalization (block-origin normalization, raster-path resolution, source_path) and corrupt- entity purge a UI open always runs. Added io::load_bytes_finalized(path, bytes), used by automation's "open" handler, which now also reports the purge count. 5. Material maps were uploaded without checking device.limits().max_texture_dimension_2d, unlike raster-image rendering (which already tiles to that limit). A mesh material is sampled by UV, so it can't use the same tiling trick — instead, upload_rgba_texture now downscales an oversized image before upload, falling back to the 1x1 material color if malformed. 6. Sketch-constraint persistence/undo, audit-found while reviewing this branch's own constraint system: - Clearing every constraint in a scope after a prior non-empty save left the stale XRecord in place, resurrecting the deleted constraints on the next load. materialize_sketch_constraints_for_ save now actively removes the XRecord when a scope goes back to empty. - ERASE removing an entity took its constraints with it (design doc's deletion policy), but sketch_constraints lives on Scene, outside document/document.objects, so nothing captured that mutation for undo — the erased geometry came back on undo, but its constraints didn't. refresh_sketch_constraints's deletion pass now records the touched scope's before-image into the same UndoRecording the entity removal rides in, threaded through a new sketch_constraints channel on DeltaSnapshot, so one undo restores both together. HistorySnapshot::Delta is now boxed (clippy flagged the resulting size skew across undo/redo stack variants). 31 new/adapted tests across all six fixes. Full workspace suite passes except the one pre-existing, unrelated font-test failure also present on main. Co-Authored-By: Claude Sonnet 5 <[email protected]>
1284 lines
42 KiB
Rust
1284 lines
42 KiB
Rust
//! Shared render-scene traversal.
|
|
//!
|
|
//! The drawing database stays authoritative. This layer only resolves the
|
|
//! hierarchy and per-instance context needed by render backends: space roots,
|
|
//! nested block references, transforms, arrays, visibility, style inheritance,
|
|
//! draw order, and clip boundaries. Leaf entities remain responsible for
|
|
//! producing their normal wire, hatch, image, wipeout, or mesh model.
|
|
|
|
use acadrust::entities::Insert;
|
|
use acadrust::types::{Color, Matrix3, Matrix4, Transform, Vector3};
|
|
use acadrust::{CadDocument, EntityType, Handle};
|
|
use rustc_hash::{FxHashMap, FxHashSet};
|
|
|
|
use crate::scene::view::render::{
|
|
has_resolved_book_color, is_effective_layer_zero, layer_render_style_viewport,
|
|
render_style_for_block_sub_viewport, render_style_for_viewport, InheritStyle,
|
|
};
|
|
use crate::scene::BlockScalePolicy;
|
|
|
|
pub type ResolvedStyle = ([f32; 4], f32, [f32; 8], f32, u8);
|
|
|
|
/// A block record used as a render root. Model space, paper space, and an
|
|
/// ordinary definition opened for editing share the same ownership mechanism;
|
|
/// only their runtime role differs.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub struct BlockRoot {
|
|
pub record: Handle,
|
|
pub role: BlockRootRole,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub enum BlockRootRole {
|
|
ModelSpace,
|
|
PaperSpace,
|
|
DefinitionEdit,
|
|
}
|
|
|
|
/// Semantic root of one render traversal. A viewport is a projection edge from
|
|
/// its paper-space owner to model-space content, not another storage container.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub enum SceneRoot {
|
|
Block(BlockRoot),
|
|
Viewport {
|
|
paper_block: Handle,
|
|
viewport: Handle,
|
|
model_block: Handle,
|
|
},
|
|
}
|
|
|
|
impl SceneRoot {
|
|
pub fn content_block(self) -> Handle {
|
|
match self {
|
|
Self::Block(root) => root.record,
|
|
Self::Viewport { model_block, .. } => model_block,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug)]
|
|
pub struct BlockStyle {
|
|
pub insert: ResolvedStyle,
|
|
pub layer0: InheritStyle,
|
|
pub layer0_aci: u8,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug)]
|
|
pub struct InsertStyleSpec {
|
|
own: BlockStyle,
|
|
color_byblock: bool,
|
|
color_bylayer: bool,
|
|
transparency_byblock: bool,
|
|
transparency_bylayer: bool,
|
|
linetype_byblock: bool,
|
|
linetype_bylayer: bool,
|
|
lineweight_byblock: bool,
|
|
lineweight_bylayer: bool,
|
|
layer0: bool,
|
|
}
|
|
|
|
impl InsertStyleSpec {
|
|
pub fn new(document: &CadDocument, insert: &Insert, viewport: Option<Handle>) -> Self {
|
|
let entity = EntityType::Insert(insert.clone());
|
|
let has_book_color = has_resolved_book_color(document, &entity);
|
|
let linetype = &insert.common.linetype;
|
|
Self {
|
|
own: BlockStyle::for_entity(document, &entity, viewport),
|
|
color_byblock: !has_book_color && insert.common.color == Color::ByBlock,
|
|
color_bylayer: !has_book_color && insert.common.color == Color::ByLayer,
|
|
transparency_byblock: insert.common.transparency.is_by_block(),
|
|
transparency_bylayer: insert.common.transparency.is_by_layer(),
|
|
linetype_byblock: linetype.eq_ignore_ascii_case("byblock"),
|
|
linetype_bylayer: linetype.is_empty() || linetype.eq_ignore_ascii_case("bylayer"),
|
|
lineweight_byblock: matches!(
|
|
insert.common.line_weight,
|
|
acadrust::types::LineWeight::ByBlock
|
|
),
|
|
lineweight_bylayer: matches!(
|
|
insert.common.line_weight,
|
|
acadrust::types::LineWeight::ByLayer
|
|
| acadrust::types::LineWeight::Default
|
|
),
|
|
layer0: is_effective_layer_zero(&insert.common.layer),
|
|
}
|
|
}
|
|
|
|
pub fn resolve(self, parent: BlockStyle) -> BlockStyle {
|
|
let mut insert = self.own.insert;
|
|
if self.color_byblock {
|
|
insert.0[0] = parent.insert.0[0];
|
|
insert.0[1] = parent.insert.0[1];
|
|
insert.0[2] = parent.insert.0[2];
|
|
insert.4 = parent.insert.4;
|
|
} else if self.layer0 && self.color_bylayer {
|
|
insert.0[0] = parent.layer0.color[0];
|
|
insert.0[1] = parent.layer0.color[1];
|
|
insert.0[2] = parent.layer0.color[2];
|
|
insert.4 = parent.layer0_aci;
|
|
}
|
|
insert.0[3] = if self.transparency_byblock {
|
|
parent.insert.0[3]
|
|
} else if self.layer0 && self.transparency_bylayer {
|
|
parent.layer0.color[3]
|
|
} else {
|
|
insert.0[3]
|
|
};
|
|
if self.linetype_byblock {
|
|
insert.1 = parent.insert.1;
|
|
insert.2 = parent.insert.2;
|
|
} else if self.layer0 && self.linetype_bylayer {
|
|
insert.1 = parent.layer0.pat_len;
|
|
insert.2 = parent.layer0.pat;
|
|
}
|
|
if self.lineweight_byblock {
|
|
insert.3 = parent.insert.3;
|
|
} else if self.layer0 && self.lineweight_bylayer {
|
|
insert.3 = parent.layer0.lw_px;
|
|
}
|
|
BlockStyle {
|
|
insert,
|
|
layer0: if self.layer0 {
|
|
parent.layer0
|
|
} else {
|
|
self.own.layer0
|
|
},
|
|
layer0_aci: if self.layer0 {
|
|
parent.layer0_aci
|
|
} else {
|
|
self.own.layer0_aci
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
impl BlockStyle {
|
|
pub fn for_entity(
|
|
document: &CadDocument,
|
|
entity: &EntityType,
|
|
viewport: Option<Handle>,
|
|
) -> Self {
|
|
Self {
|
|
insert: render_style_for_viewport(document, entity, viewport),
|
|
layer0: layer_render_style_viewport(document, &entity.common().layer, viewport),
|
|
layer0_aci: layer_aci(document, &entity.common().layer),
|
|
}
|
|
}
|
|
|
|
pub fn for_nested(
|
|
document: &CadDocument,
|
|
insert: &Insert,
|
|
parent: Self,
|
|
viewport: Option<Handle>,
|
|
) -> Self {
|
|
InsertStyleSpec::new(document, insert, viewport).resolve(parent)
|
|
}
|
|
|
|
pub fn resolve(
|
|
self,
|
|
document: &CadDocument,
|
|
entity: &EntityType,
|
|
viewport: Option<Handle>,
|
|
) -> ResolvedStyle {
|
|
let mut resolved = render_style_for_block_sub_viewport(
|
|
document,
|
|
entity,
|
|
self.insert.0,
|
|
self.insert.1,
|
|
self.insert.2,
|
|
self.insert.3,
|
|
self.layer0,
|
|
viewport,
|
|
);
|
|
let common = entity.common();
|
|
let has_book_color = has_resolved_book_color(document, entity);
|
|
resolved.4 = if !has_book_color && common.color == Color::ByBlock {
|
|
self.insert.4
|
|
} else if !has_book_color
|
|
&& is_effective_layer_zero(&common.layer)
|
|
&& common.color == Color::ByLayer
|
|
{
|
|
self.layer0_aci
|
|
} else {
|
|
resolved.4
|
|
};
|
|
resolved
|
|
}
|
|
}
|
|
|
|
fn layer_aci(document: &CadDocument, layer: &str) -> u8 {
|
|
document
|
|
.layers
|
|
.get(layer)
|
|
.and_then(|layer| match &layer.color {
|
|
Color::Index(index) => Some(*index),
|
|
_ => None,
|
|
})
|
|
.unwrap_or(0)
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct InstanceContext {
|
|
pub transform: Transform,
|
|
pub root_handle: Handle,
|
|
pub parent_insert: Handle,
|
|
pub insert_path: Vec<Insert>,
|
|
pub clips: Vec<Vec<[f64; 2]>>,
|
|
pub block_style: Option<BlockStyle>,
|
|
pub depth_base: f32,
|
|
pub depth_scale: f32,
|
|
pub nesting_depth: usize,
|
|
pub viewport: Option<Handle>,
|
|
pub scale_policy: BlockScalePolicy,
|
|
}
|
|
|
|
impl InstanceContext {
|
|
fn direct(depth_base: f32, viewport: Option<Handle>) -> Self {
|
|
Self {
|
|
transform: Transform::identity(),
|
|
root_handle: Handle::NULL,
|
|
parent_insert: Handle::NULL,
|
|
insert_path: Vec::new(),
|
|
clips: Vec::new(),
|
|
block_style: None,
|
|
depth_base,
|
|
depth_scale: 1.0,
|
|
nesting_depth: 0,
|
|
viewport,
|
|
scale_policy: BlockScalePolicy::FromInsert,
|
|
}
|
|
}
|
|
|
|
pub fn is_instanced(&self) -> bool {
|
|
!self.root_handle.is_null()
|
|
}
|
|
|
|
pub fn style_for(&self, document: &CadDocument, entity: &EntityType) -> ResolvedStyle {
|
|
self.block_style
|
|
.map(|style| style.resolve(document, entity, self.viewport))
|
|
.unwrap_or_else(|| render_style_for_viewport(document, entity, self.viewport))
|
|
}
|
|
|
|
pub fn draw_depth(&self, handle: Handle, depths: &FxHashMap<u64, [f32; 2]>) -> f32 {
|
|
if self.is_instanced() {
|
|
self.depth_base
|
|
+ depths
|
|
.get(&handle.value())
|
|
.map_or(0.0, |depth| depth[0])
|
|
* self.depth_scale
|
|
} else {
|
|
depths
|
|
.get(&handle.value())
|
|
.map_or(self.depth_base, |depth| depth[0])
|
|
}
|
|
}
|
|
}
|
|
|
|
pub struct RenderSceneGraph<'a> {
|
|
document: &'a CadDocument,
|
|
frozen_layers: Option<&'a FxHashSet<Handle>>,
|
|
annotation_scale_handle: Option<Handle>,
|
|
annotation_scale: f32,
|
|
all_visible: bool,
|
|
depths: &'a FxHashMap<u64, [f32; 2]>,
|
|
viewport: Option<Handle>,
|
|
}
|
|
|
|
impl<'a> RenderSceneGraph<'a> {
|
|
pub fn new(
|
|
document: &'a CadDocument,
|
|
frozen_layers: Option<&'a FxHashSet<Handle>>,
|
|
annotation_scale_handle: Option<Handle>,
|
|
all_visible: bool,
|
|
depths: &'a FxHashMap<u64, [f32; 2]>,
|
|
) -> Self {
|
|
Self {
|
|
document,
|
|
frozen_layers,
|
|
annotation_scale_handle,
|
|
annotation_scale: 1.0,
|
|
all_visible,
|
|
depths,
|
|
viewport: None,
|
|
}
|
|
}
|
|
|
|
pub fn with_viewport(mut self, viewport: Option<Handle>) -> Self {
|
|
self.viewport = viewport.filter(|handle| handle.is_valid());
|
|
self
|
|
}
|
|
|
|
pub fn with_annotation_scale(mut self, annotation_scale: f32) -> Self {
|
|
self.annotation_scale = annotation_scale.max(1.0e-6);
|
|
self
|
|
}
|
|
|
|
/// Walk direct root entities and every referenced block subtree. `visible`
|
|
/// can add session-only rules such as isolate/preview hiding; returning
|
|
/// false for an Insert removes its whole subtree.
|
|
pub fn walk_root<V, F>(&self, root: SceneRoot, mut visible: V, mut leaf: F)
|
|
where
|
|
V: FnMut(&EntityType, &InstanceContext) -> bool,
|
|
F: FnMut(&EntityType, &InstanceContext),
|
|
{
|
|
let block = root.content_block();
|
|
let viewport = match root {
|
|
SceneRoot::Viewport { viewport, .. } => Some(viewport),
|
|
SceneRoot::Block(_) => self.viewport,
|
|
};
|
|
let Some(record) = self
|
|
.document
|
|
.block_records
|
|
.iter()
|
|
.find(|record| record.handle == block)
|
|
else {
|
|
return;
|
|
};
|
|
for &handle in &record.entity_handles {
|
|
let Some(source) = self.document.get_entity(handle) else {
|
|
continue;
|
|
};
|
|
let contextual = crate::scene::annotative::entity_for_annotation_context(
|
|
self.document,
|
|
source,
|
|
self.annotation_scale_handle,
|
|
);
|
|
let entity = contextual.as_ref();
|
|
let direct_depth = self
|
|
.depths
|
|
.get(&handle.value())
|
|
.map_or(0.0, |depth| depth[0]);
|
|
let context = InstanceContext::direct(direct_depth, viewport);
|
|
if !self.document_visible(entity) || !visible(entity, &context) {
|
|
continue;
|
|
}
|
|
if let EntityType::Insert(insert) = entity {
|
|
self.walk_insert_instances(
|
|
insert,
|
|
BlockScalePolicy::FromInsert,
|
|
&context,
|
|
&mut visible,
|
|
&mut leaf,
|
|
);
|
|
} else {
|
|
leaf(entity, &context);
|
|
self.walk_owned_content(entity, &context, &mut visible, &mut leaf, &mut Vec::new());
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Walk one synthetic or document-owned Insert. Used by entity renderers
|
|
/// whose content is itself a block reference.
|
|
pub fn walk_block_use<V, F>(
|
|
&self,
|
|
block_use: &BlockUse,
|
|
root_handle: Handle,
|
|
mut visible: V,
|
|
mut leaf: F,
|
|
)
|
|
where
|
|
V: FnMut(&EntityType, &InstanceContext) -> bool,
|
|
F: FnMut(&EntityType, &InstanceContext),
|
|
{
|
|
self.walk_insert_with_policy(
|
|
&block_use.insert,
|
|
root_handle,
|
|
block_use.scale_policy,
|
|
&mut visible,
|
|
&mut leaf,
|
|
);
|
|
}
|
|
|
|
fn walk_insert_with_policy<V, F>(
|
|
&self,
|
|
insert: &Insert,
|
|
root_handle: Handle,
|
|
scale_policy: BlockScalePolicy,
|
|
visible: &mut V,
|
|
leaf: &mut F,
|
|
) where
|
|
V: FnMut(&EntityType, &InstanceContext) -> bool,
|
|
F: FnMut(&EntityType, &InstanceContext),
|
|
{
|
|
let mut root_insert = insert.clone();
|
|
root_insert.common.handle = root_handle;
|
|
let entity = EntityType::Insert(root_insert.clone());
|
|
let depth_base = self
|
|
.depths
|
|
.get(&root_handle.value())
|
|
.map_or(0.0, |depth| depth[0]);
|
|
let context = InstanceContext::direct(depth_base, self.viewport);
|
|
if self.document_visible(&entity) && visible(&entity, &context) {
|
|
self.walk_insert_instances(
|
|
&root_insert,
|
|
scale_policy,
|
|
&context,
|
|
visible,
|
|
leaf,
|
|
);
|
|
}
|
|
}
|
|
|
|
|
|
fn walk_insert_instances<V, F>(
|
|
&self,
|
|
insert: &Insert,
|
|
scale_policy: BlockScalePolicy,
|
|
parent: &InstanceContext,
|
|
visible: &mut V,
|
|
leaf: &mut F,
|
|
) where
|
|
V: FnMut(&EntityType, &InstanceContext) -> bool,
|
|
F: FnMut(&EntityType, &InstanceContext),
|
|
{
|
|
let insert_entity = EntityType::Insert(insert.clone());
|
|
let block_style = parent
|
|
.block_style
|
|
.map(|style| BlockStyle::for_nested(self.document, insert, style, self.viewport))
|
|
.unwrap_or_else(|| BlockStyle::for_entity(self.document, &insert_entity, self.viewport));
|
|
let root_handle = if parent.root_handle.is_null() {
|
|
insert.common.handle
|
|
} else {
|
|
parent.root_handle
|
|
};
|
|
let [depth_base, depth_scale] = if parent.root_handle.is_null() {
|
|
self.depths
|
|
.get(&insert.common.handle.value())
|
|
.copied()
|
|
.unwrap_or([0.0, 1.0])
|
|
} else {
|
|
let base = parent.draw_depth(insert.common.handle, self.depths);
|
|
let count = self
|
|
.document
|
|
.block_records
|
|
.get(&insert.block_name)
|
|
.map_or(1, |record| record.entity_handles.len().max(1));
|
|
[base, parent.depth_scale / (count as f32 + 1.0)]
|
|
};
|
|
|
|
for offset in array_offsets(insert) {
|
|
let local = insert_instance_transform(
|
|
self.document,
|
|
insert,
|
|
offset,
|
|
self.annotation_scale,
|
|
scale_policy,
|
|
);
|
|
let transform = local.then(&parent.transform);
|
|
let mut context = parent.clone();
|
|
context.transform = transform;
|
|
context.root_handle = root_handle;
|
|
context.parent_insert = insert.common.handle;
|
|
context.insert_path.push(insert.clone());
|
|
context.block_style = Some(block_style);
|
|
context.depth_base = depth_base;
|
|
context.depth_scale = depth_scale;
|
|
context.nesting_depth += 1;
|
|
context.scale_policy = scale_policy;
|
|
if let Some(filter) = crate::scene::pick::xclip::insert_spatial_filter(
|
|
self.document,
|
|
insert,
|
|
) {
|
|
let polygon = crate::scene::pick::xclip::world_clip_polygon_for_transform(
|
|
filter,
|
|
&transform,
|
|
);
|
|
if polygon.len() >= 3 {
|
|
context.clips.push(polygon);
|
|
}
|
|
}
|
|
let mut stack = context
|
|
.insert_path
|
|
.iter()
|
|
.map(|insert| insert.block_name.clone())
|
|
.collect();
|
|
self.walk_block(
|
|
&insert.block_name,
|
|
&context,
|
|
visible,
|
|
leaf,
|
|
&mut stack,
|
|
);
|
|
}
|
|
}
|
|
|
|
fn walk_block<V, F>(
|
|
&self,
|
|
block_name: &str,
|
|
context: &InstanceContext,
|
|
visible: &mut V,
|
|
leaf: &mut F,
|
|
stack: &mut Vec<String>,
|
|
) where
|
|
V: FnMut(&EntityType, &InstanceContext) -> bool,
|
|
F: FnMut(&EntityType, &InstanceContext),
|
|
{
|
|
if context.nesting_depth > 32 {
|
|
return;
|
|
}
|
|
let Some(record) = block_record_by_name(self.document, block_name) else {
|
|
return;
|
|
};
|
|
for &handle in &record.entity_handles {
|
|
let Some(source) = self.document.get_entity(handle) else {
|
|
continue;
|
|
};
|
|
let contextual = crate::scene::annotative::entity_for_annotation_context(
|
|
self.document,
|
|
source,
|
|
self.annotation_scale_handle,
|
|
);
|
|
let entity = contextual.as_ref();
|
|
if !self.document_visible(entity) || !visible(entity, context) {
|
|
continue;
|
|
}
|
|
match entity {
|
|
EntityType::Block(_)
|
|
| EntityType::BlockEnd(_)
|
|
| EntityType::AttributeDefinition(_) => {}
|
|
EntityType::Insert(nested) => {
|
|
if stack
|
|
.iter()
|
|
.any(|name| name.eq_ignore_ascii_case(&nested.block_name))
|
|
{
|
|
continue;
|
|
}
|
|
stack.push(nested.block_name.clone());
|
|
self.walk_insert_instances(
|
|
nested,
|
|
BlockScalePolicy::FromInsert,
|
|
context,
|
|
visible,
|
|
leaf,
|
|
);
|
|
stack.pop();
|
|
}
|
|
_ => {
|
|
leaf(entity, context);
|
|
self.walk_owned_content(entity, context, visible, leaf, stack);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn walk_owned_content<V, F>(
|
|
&self,
|
|
entity: &EntityType,
|
|
context: &InstanceContext,
|
|
visible: &mut V,
|
|
leaf: &mut F,
|
|
stack: &mut Vec<String>,
|
|
) where
|
|
V: FnMut(&EntityType, &InstanceContext) -> bool,
|
|
F: FnMut(&EntityType, &InstanceContext),
|
|
{
|
|
for block_use in entity_render_block_uses(self.document, entity, self.annotation_scale)
|
|
.into_iter()
|
|
.filter(|block_use| block_use.active)
|
|
{
|
|
if stack
|
|
.iter()
|
|
.any(|name| name.eq_ignore_ascii_case(&block_use.insert.block_name))
|
|
{
|
|
continue;
|
|
}
|
|
stack.push(block_use.insert.block_name.clone());
|
|
self.walk_insert_instances(
|
|
&block_use.insert,
|
|
block_use.scale_policy,
|
|
context,
|
|
visible,
|
|
leaf,
|
|
);
|
|
stack.pop();
|
|
}
|
|
}
|
|
|
|
fn document_visible(&self, entity: &EntityType) -> bool {
|
|
let common = entity.common();
|
|
if common.invisible {
|
|
return false;
|
|
}
|
|
let layer = self.document.layers.get(&common.layer);
|
|
if layer
|
|
.map(|layer| layer.flags.off || layer.flags.frozen)
|
|
.unwrap_or(false)
|
|
{
|
|
return false;
|
|
}
|
|
if self.frozen_layers.is_some_and(|frozen| {
|
|
layer.is_some_and(|layer| frozen.contains(&layer.handle))
|
|
}) {
|
|
return false;
|
|
}
|
|
!crate::scene::annotative::annotative_offscale_for(
|
|
self.document,
|
|
common,
|
|
self.annotation_scale_handle,
|
|
self.all_visible,
|
|
)
|
|
}
|
|
}
|
|
|
|
pub fn block_base_point(document: &CadDocument, block_name: &str) -> Vector3 {
|
|
document
|
|
.block_records
|
|
.iter()
|
|
.find(|record| record.name.eq_ignore_ascii_case(block_name))
|
|
.map(|record| {
|
|
document
|
|
.get_entity(record.block_entity_handle)
|
|
.and_then(|entity| match entity {
|
|
EntityType::Block(block) => Some(block.base_point),
|
|
_ => None,
|
|
})
|
|
.unwrap_or(record.base_point)
|
|
})
|
|
.unwrap_or(Vector3::ZERO)
|
|
}
|
|
|
|
pub fn insert_transform(document: &CadDocument, insert: &Insert) -> Transform {
|
|
let base = block_base_point(document, &insert.block_name);
|
|
Transform::from_translation(Vector3::new(-base.x, -base.y, -base.z))
|
|
.then(&insert.get_transform())
|
|
}
|
|
|
|
pub fn insert_transform_at_scale(
|
|
document: &CadDocument,
|
|
insert: &Insert,
|
|
annotation_scale: f32,
|
|
) -> Transform {
|
|
insert_transform_with_policy(
|
|
document,
|
|
insert,
|
|
annotation_scale,
|
|
BlockScalePolicy::FromInsert,
|
|
)
|
|
}
|
|
|
|
pub fn insert_transform_with_policy(
|
|
document: &CadDocument,
|
|
insert: &Insert,
|
|
annotation_scale: f32,
|
|
scale_policy: BlockScalePolicy,
|
|
) -> Transform {
|
|
let mut transform = insert_transform(document, insert);
|
|
if scale_policy == BlockScalePolicy::FromInsert
|
|
&& (annotation_scale - 1.0).abs() > 1.0e-6
|
|
&& insert
|
|
.common
|
|
.extended_data
|
|
.get_record("AcAnnotativeData")
|
|
.is_some()
|
|
{
|
|
let point = insert.insert_point;
|
|
let scale = Transform::from_translation(Vector3::new(-point.x, -point.y, -point.z))
|
|
.then(&Transform::from_scale(annotation_scale as f64))
|
|
.then(&Transform::from_translation(Vector3::new(
|
|
point.x, point.y, point.z,
|
|
)));
|
|
transform = transform.then(&scale);
|
|
}
|
|
transform
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
|
|
pub enum BlockRole {
|
|
Insert,
|
|
DimensionPicture,
|
|
TablePicture,
|
|
TableCell,
|
|
MultiLeaderContent,
|
|
LineStyleSymbol,
|
|
ArrowHead,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub struct BlockUse {
|
|
pub block: Handle,
|
|
pub insert: Insert,
|
|
pub role: BlockRole,
|
|
pub scale_policy: BlockScalePolicy,
|
|
/// Whether this edge belongs to the active render representation. Inactive
|
|
/// edges remain dependencies so cache invalidation and purge stay complete.
|
|
pub active: bool,
|
|
pub replaces_host_wire: bool,
|
|
pub suppress_root_points: bool,
|
|
}
|
|
|
|
pub(crate) fn block_record_by_name<'a>(
|
|
document: &'a CadDocument,
|
|
name: &str,
|
|
) -> Option<&'a acadrust::tables::BlockRecord> {
|
|
document.block_records.get(name).or_else(|| {
|
|
document
|
|
.block_records
|
|
.iter()
|
|
.find(|record| record.name.eq_ignore_ascii_case(name))
|
|
})
|
|
}
|
|
|
|
fn block_use(
|
|
document: &CadDocument,
|
|
mut insert: Insert,
|
|
role: BlockRole,
|
|
scale_policy: BlockScalePolicy,
|
|
active: bool,
|
|
replaces_host_wire: bool,
|
|
suppress_root_points: bool,
|
|
) -> BlockUse {
|
|
let block =
|
|
block_record_by_name(document, &insert.block_name).map_or(Handle::NULL, |record| {
|
|
insert.block_name.clone_from(&record.name);
|
|
record.handle
|
|
});
|
|
BlockUse {
|
|
block,
|
|
insert,
|
|
role,
|
|
scale_policy,
|
|
active,
|
|
replaces_host_wire,
|
|
suppress_root_points,
|
|
}
|
|
}
|
|
|
|
pub fn block_use_from_handle(
|
|
document: &CadDocument,
|
|
block: Handle,
|
|
role: BlockRole,
|
|
insertion: Vector3,
|
|
) -> Option<BlockUse> {
|
|
let record = document
|
|
.block_records
|
|
.iter()
|
|
.find(|record| record.handle == block)?;
|
|
Some(block_use(
|
|
document,
|
|
Insert::new(record.name.clone(), insertion),
|
|
role,
|
|
BlockScalePolicy::Applied,
|
|
true,
|
|
false,
|
|
false,
|
|
))
|
|
}
|
|
|
|
fn push_dependency_use(
|
|
uses: &mut Vec<BlockUse>,
|
|
document: &CadDocument,
|
|
block: Handle,
|
|
role: BlockRole,
|
|
) {
|
|
if block.is_null()
|
|
|| uses
|
|
.iter()
|
|
.any(|block_use| block_use.block == block && block_use.role == role)
|
|
{
|
|
return;
|
|
}
|
|
if let Some(mut block_use) =
|
|
block_use_from_handle(document, block, role, Vector3::ZERO)
|
|
{
|
|
block_use.active = false;
|
|
uses.push(block_use);
|
|
}
|
|
}
|
|
|
|
/// Every direct block edge owned by an entity. One host may have multiple
|
|
/// edges, such as block-valued table cells. Representation flags let renderers
|
|
/// choose the active picture while dependency consumers keep the full graph.
|
|
fn entity_owned_block_uses(
|
|
document: &CadDocument,
|
|
entity: &EntityType,
|
|
annotation_scale: f32,
|
|
) -> Vec<BlockUse> {
|
|
match entity {
|
|
EntityType::Insert(insert) => vec![block_use(
|
|
document,
|
|
insert.clone(),
|
|
BlockRole::Insert,
|
|
BlockScalePolicy::FromInsert,
|
|
true,
|
|
true,
|
|
false,
|
|
)],
|
|
EntityType::Dimension(dimension) => {
|
|
let name = dimension.base().block_name.trim();
|
|
if let Some(record) = block_record_by_name(document, name)
|
|
.filter(|record| !record.entity_handles.is_empty())
|
|
{
|
|
let mut insert = Insert::new(record.name.clone(), Vector3::ZERO);
|
|
insert.common = dimension.base().common.clone();
|
|
let picture = !crate::scene::annotative::is_annotative(document, entity);
|
|
vec![block_use(
|
|
document,
|
|
insert,
|
|
BlockRole::DimensionPicture,
|
|
BlockScalePolicy::FromInsert,
|
|
picture,
|
|
picture,
|
|
true,
|
|
)]
|
|
} else {
|
|
Vec::new()
|
|
}
|
|
}
|
|
EntityType::Table(table) => {
|
|
let picture_record = table.block_record_handle.and_then(|handle| {
|
|
document
|
|
.block_records
|
|
.iter()
|
|
.find(|record| record.handle == handle)
|
|
.filter(|record| !record.entity_handles.is_empty())
|
|
});
|
|
let mut uses = Vec::new();
|
|
if let Some(record) = picture_record {
|
|
let mut insert = Insert::new(record.name.clone(), table.insertion_point);
|
|
insert.rotation = table
|
|
.horizontal_direction
|
|
.y
|
|
.atan2(table.horizontal_direction.x);
|
|
insert.common = table.common.clone();
|
|
uses.push(block_use(
|
|
document,
|
|
insert,
|
|
BlockRole::TablePicture,
|
|
BlockScalePolicy::FromInsert,
|
|
true,
|
|
true,
|
|
false,
|
|
));
|
|
}
|
|
let cells_active = picture_record.is_none();
|
|
uses.extend(
|
|
crate::entities::table::block_cell_inserts(table, document, annotation_scale)
|
|
.into_iter()
|
|
.map(|insert| {
|
|
block_use(
|
|
document,
|
|
insert,
|
|
BlockRole::TableCell,
|
|
BlockScalePolicy::Applied,
|
|
cells_active,
|
|
false,
|
|
false,
|
|
)
|
|
}),
|
|
);
|
|
uses
|
|
}
|
|
EntityType::MultiLeader(multileader) => {
|
|
crate::entities::multileader::block_content_insert(document, multileader)
|
|
.map(|insert| {
|
|
vec![block_use(
|
|
document,
|
|
insert,
|
|
BlockRole::MultiLeaderContent,
|
|
BlockScalePolicy::Applied,
|
|
true,
|
|
false,
|
|
false,
|
|
)]
|
|
})
|
|
.unwrap_or_default()
|
|
}
|
|
_ => Vec::new(),
|
|
}
|
|
}
|
|
|
|
pub fn entity_render_block_uses(
|
|
document: &CadDocument,
|
|
entity: &EntityType,
|
|
annotation_scale: f32,
|
|
) -> Vec<BlockUse> {
|
|
entity_owned_block_uses(document, entity, annotation_scale)
|
|
}
|
|
|
|
pub fn entity_block_uses(
|
|
document: &CadDocument,
|
|
entity: &EntityType,
|
|
annotation_scale: f32,
|
|
) -> Vec<BlockUse> {
|
|
let mut uses = entity_owned_block_uses(document, entity, annotation_scale);
|
|
|
|
let line_type = crate::scene::view::render::linetype_name_for(document, entity);
|
|
for symbol in crate::scene::convert::dgn_linestyle::symbol_blocks(document, line_type) {
|
|
push_dependency_use(
|
|
&mut uses,
|
|
document,
|
|
symbol.block,
|
|
BlockRole::LineStyleSymbol,
|
|
);
|
|
}
|
|
|
|
match entity {
|
|
EntityType::Dimension(dimension) => {
|
|
let style_name = dimension.base().style_name.trim();
|
|
if let Some(style) = document.dim_styles.iter().find(|style| {
|
|
style.name.eq_ignore_ascii_case(style_name)
|
|
|| (style_name.is_empty() && style.name.eq_ignore_ascii_case("Standard"))
|
|
}) {
|
|
let style = crate::entities::dimension::resolved_dimension_style(
|
|
style,
|
|
dimension,
|
|
document,
|
|
);
|
|
for handle in [style.dimblk, style.dimblk1, style.dimblk2, style.dimldrblk] {
|
|
push_dependency_use(
|
|
&mut uses,
|
|
document,
|
|
handle,
|
|
BlockRole::ArrowHead,
|
|
);
|
|
}
|
|
for name in [style.dimblk_name, style.dimblk1_name, style.dimblk2_name] {
|
|
if let Some(record) = block_record_by_name(document, &name) {
|
|
push_dependency_use(
|
|
&mut uses,
|
|
document,
|
|
record.handle,
|
|
BlockRole::ArrowHead,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
EntityType::Leader(leader) => {
|
|
let style = document.dim_styles.iter().find(|style| {
|
|
style.name.eq_ignore_ascii_case(&leader.dimension_style)
|
|
|| (leader.dimension_style.trim().is_empty()
|
|
&& style.name.eq_ignore_ascii_case("Standard"))
|
|
});
|
|
let handle = crate::entities::dim_override::handle(
|
|
&leader.common.extended_data,
|
|
crate::entities::dim_override::DIMLDRBLK,
|
|
)
|
|
.or_else(|| style.map(|style| style.dimldrblk));
|
|
if let Some(handle) = handle {
|
|
push_dependency_use(
|
|
&mut uses,
|
|
document,
|
|
handle,
|
|
BlockRole::ArrowHead,
|
|
);
|
|
}
|
|
}
|
|
EntityType::MultiLeader(multileader) => {
|
|
if let Some(handle) = multileader.arrowhead_handle {
|
|
push_dependency_use(
|
|
&mut uses,
|
|
document,
|
|
handle,
|
|
BlockRole::ArrowHead,
|
|
);
|
|
}
|
|
for line in multileader
|
|
.context
|
|
.leader_roots
|
|
.iter()
|
|
.flat_map(|root| &root.lines)
|
|
{
|
|
if let Some(handle) = line.arrowhead_handle {
|
|
push_dependency_use(
|
|
&mut uses,
|
|
document,
|
|
handle,
|
|
BlockRole::ArrowHead,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
uses
|
|
}
|
|
|
|
/// Blocks referenced by document-level styles rather than a concrete entity.
|
|
pub fn document_block_uses(document: &CadDocument) -> Vec<BlockUse> {
|
|
let mut uses = Vec::new();
|
|
for style in document.dim_styles.iter() {
|
|
for handle in [style.dimblk, style.dimblk1, style.dimblk2, style.dimldrblk] {
|
|
push_dependency_use(&mut uses, document, handle, BlockRole::ArrowHead);
|
|
}
|
|
for name in [&style.dimblk_name, &style.dimblk1_name, &style.dimblk2_name] {
|
|
if let Some(record) = block_record_by_name(document, name) {
|
|
push_dependency_use(
|
|
&mut uses,
|
|
document,
|
|
record.handle,
|
|
BlockRole::ArrowHead,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
for object in document.objects.values() {
|
|
let acadrust::objects::ObjectType::MultiLeaderStyle(style) = object else {
|
|
continue;
|
|
};
|
|
if let Some(handle) = style.arrowhead_handle {
|
|
push_dependency_use(&mut uses, document, handle, BlockRole::ArrowHead);
|
|
}
|
|
if let Some(handle) = style.block_content_handle {
|
|
push_dependency_use(
|
|
&mut uses,
|
|
document,
|
|
handle,
|
|
BlockRole::MultiLeaderContent,
|
|
);
|
|
}
|
|
}
|
|
for line_type in document.line_types.iter() {
|
|
for symbol in
|
|
crate::scene::convert::dgn_linestyle::symbol_blocks(document, &line_type.name)
|
|
{
|
|
push_dependency_use(
|
|
&mut uses,
|
|
document,
|
|
symbol.block,
|
|
BlockRole::LineStyleSymbol,
|
|
);
|
|
}
|
|
}
|
|
uses
|
|
}
|
|
|
|
/// A MINSERT's row/column counts are parsed `u16`s whose unchecked product
|
|
/// (`Insert::instance_count`) would otherwise drive this function's
|
|
/// allocation and a full per-instance render-graph walk after it — a corrupt
|
|
/// or adversarial 65535x65535 pair requests over four billion instances (tens
|
|
/// of GiB) long before any GPU-side chunking limit could help. The primary
|
|
/// defense is the load-time filter in `io::is_entity_corrupt` (kept in sync
|
|
/// with this constant), which drops such an insert before it ever reaches a
|
|
/// `Scene`; this is the failsafe for an insert built or edited some other way
|
|
/// (API/scripting) that skips that path.
|
|
const MAX_MINSERT_INSTANCES: usize = 20_000;
|
|
|
|
pub fn array_offsets(insert: &Insert) -> Vec<[f64; 3]> {
|
|
if !insert.is_minsert() {
|
|
return vec![[0.0; 3]];
|
|
}
|
|
let instance_count = insert.instance_count();
|
|
if instance_count > MAX_MINSERT_INSTANCES {
|
|
eprintln!(
|
|
"MINSERT '{}' requests {} instances ({}x{}), exceeding the {} budget; rendering a single instance instead",
|
|
insert.block_name,
|
|
instance_count,
|
|
insert.row_count,
|
|
insert.column_count,
|
|
MAX_MINSERT_INSTANCES
|
|
);
|
|
return vec![[0.0; 3]];
|
|
}
|
|
let mut offsets = Vec::with_capacity(instance_count);
|
|
for row in 0..insert.row_count {
|
|
for column in 0..insert.column_count {
|
|
offsets.push([
|
|
column as f64 * insert.column_spacing,
|
|
row as f64 * insert.row_spacing,
|
|
0.0,
|
|
]);
|
|
}
|
|
}
|
|
offsets
|
|
}
|
|
|
|
pub fn insert_instance_transform(
|
|
document: &CadDocument,
|
|
insert: &Insert,
|
|
offset: [f64; 3],
|
|
annotation_scale: f32,
|
|
scale_policy: BlockScalePolicy,
|
|
) -> Transform {
|
|
let transform = insert_transform_with_policy(document, insert, annotation_scale, scale_policy);
|
|
if offset == [0.0; 3] {
|
|
transform
|
|
} else {
|
|
// Row/column spacing is defined in the insert's rotated/OCS-oriented
|
|
// space but is NOT subject to the block's x/y/z scale — this matches
|
|
// the pinned CAD model's own `Insert::array_points`, which rotates
|
|
// the offset but never scales it. Composing the raw offset ahead of
|
|
// the full (scaled) transform, as this used to do, let the block
|
|
// scale multiply the spacing too (e.g. an x-scale-2 insert with
|
|
// column spacing 10 would render 20 units between columns). Instead,
|
|
// orient the offset by rotation + OCS only, then translate the
|
|
// already-composed (scaled) instance transform by that world-space
|
|
// vector, applied after — never scaled.
|
|
let orientation = Transform::from_matrix(Matrix4::rotation_z(insert.rotation))
|
|
.then(&Transform::from_matrix(Matrix4::from_matrix3(Matrix3::arbitrary_axis(insert.normal))));
|
|
let world_offset = orientation.apply_rotation(Vector3::new(offset[0], offset[1], offset[2]));
|
|
transform.then(&Transform::from_translation(world_offset))
|
|
}
|
|
}
|
|
|
|
pub fn insert_instance_translation_delta(
|
|
document: &CadDocument,
|
|
insert: &Insert,
|
|
offset: [f64; 3],
|
|
annotation_scale: f32,
|
|
scale_policy: BlockScalePolicy,
|
|
) -> Vector3 {
|
|
let base = insert_instance_transform(
|
|
document,
|
|
insert,
|
|
[0.0; 3],
|
|
annotation_scale,
|
|
scale_policy,
|
|
)
|
|
.apply(Vector3::ZERO);
|
|
let instance = insert_instance_transform(
|
|
document,
|
|
insert,
|
|
offset,
|
|
annotation_scale,
|
|
scale_policy,
|
|
)
|
|
.apply(Vector3::ZERO);
|
|
instance - base
|
|
}
|
|
|
|
pub fn block_contains_hatch(
|
|
document: &CadDocument,
|
|
block_name: &str,
|
|
memo: &mut std::collections::HashMap<String, bool>,
|
|
) -> bool {
|
|
let key = block_name.to_ascii_lowercase();
|
|
if let Some(&contains) = memo.get(&key) {
|
|
return contains;
|
|
}
|
|
memo.insert(key.clone(), false);
|
|
let contains = block_record_by_name(document, block_name).is_some_and(|record| {
|
|
record
|
|
.entity_handles
|
|
.iter()
|
|
.any(|&handle| match document.get_entity(handle) {
|
|
Some(EntityType::Hatch(_)) => true,
|
|
Some(entity) => entity_render_block_uses(document, entity, 1.0)
|
|
.into_iter()
|
|
.filter(|block_use| block_use.active)
|
|
.any(|block_use| {
|
|
block_contains_hatch(document, &block_use.insert.block_name, memo)
|
|
}),
|
|
_ => false,
|
|
})
|
|
});
|
|
memo.insert(key, contains);
|
|
contains
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use acadrust::xdata::ExtendedDataRecord;
|
|
|
|
#[test]
|
|
fn applied_block_scale_skips_annotative_rescaling() {
|
|
let document = CadDocument::new();
|
|
let mut insert = Insert::new("BLOCK", Vector3::new(10.0, 20.0, 0.0));
|
|
insert
|
|
.common
|
|
.extended_data
|
|
.add_record(ExtendedDataRecord::new("AcAnnotativeData"));
|
|
let point = Vector3::new(1.0, 0.0, 0.0);
|
|
|
|
let from_insert = insert_transform_with_policy(
|
|
&document,
|
|
&insert,
|
|
2.0,
|
|
BlockScalePolicy::FromInsert,
|
|
)
|
|
.apply(point);
|
|
let applied = insert_transform_with_policy(
|
|
&document,
|
|
&insert,
|
|
2.0,
|
|
BlockScalePolicy::Applied,
|
|
)
|
|
.apply(point);
|
|
|
|
assert!((from_insert.x - 12.0).abs() < 1.0e-9);
|
|
assert!((applied.x - 11.0).abs() < 1.0e-9);
|
|
}
|
|
|
|
#[test]
|
|
fn array_offsets_clamps_pathological_minsert_counts() {
|
|
// A corrupt or adversarial row/column pair (parsed as u16, so this is
|
|
// reachable from a malformed file) must not drive a multi-billion-
|
|
// element allocation; the primary defense is the load-time filter in
|
|
// `io::is_entity_corrupt`, this is the render-graph-side failsafe.
|
|
let mut insert = Insert::new("BLOCK", Vector3::ZERO);
|
|
insert.row_count = u16::MAX;
|
|
insert.column_count = u16::MAX;
|
|
insert.row_spacing = 1.0;
|
|
insert.column_spacing = 1.0;
|
|
let offsets = array_offsets(&insert);
|
|
assert_eq!(offsets, vec![[0.0; 3]], "pathological instance count must clamp to a single instance");
|
|
}
|
|
|
|
#[test]
|
|
fn array_offsets_is_unaffected_for_a_reasonable_minsert() {
|
|
let mut insert = Insert::new("BLOCK", Vector3::ZERO);
|
|
insert.row_count = 3;
|
|
insert.column_count = 4;
|
|
insert.row_spacing = 5.0;
|
|
insert.column_spacing = 2.0;
|
|
let offsets = array_offsets(&insert);
|
|
assert_eq!(offsets.len(), 12);
|
|
assert_eq!(offsets[0], [0.0, 0.0, 0.0]);
|
|
assert_eq!(offsets[1], [2.0, 0.0, 0.0]);
|
|
assert_eq!(offsets[4], [0.0, 5.0, 0.0]);
|
|
}
|
|
|
|
#[test]
|
|
fn insert_instance_transform_does_not_scale_the_row_column_spacing() {
|
|
// github discussion-derived report: an INSERT with x-scale 2 and
|
|
// column spacing 10 was rendering 20 drawing units between columns
|
|
// instead of 10, because the offset translation was composed ahead
|
|
// of (and thus multiplied by) the block scale.
|
|
let document = CadDocument::new();
|
|
let mut insert = Insert::new("BLOCK", Vector3::new(100.0, 0.0, 0.0));
|
|
insert.set_x_scale(2.0);
|
|
insert.row_count = 1;
|
|
insert.column_count = 2;
|
|
insert.column_spacing = 10.0;
|
|
|
|
let transform = insert_instance_transform(
|
|
&document,
|
|
&insert,
|
|
[10.0, 0.0, 0.0],
|
|
1.0,
|
|
BlockScalePolicy::Applied,
|
|
);
|
|
// The block's own origin (local point 0,0,0) placed at this instance
|
|
// must land 10 units from the insert point, not 20.
|
|
let world = transform.apply(Vector3::ZERO);
|
|
assert!((world.x - 110.0).abs() < 1.0e-9, "expected spacing 10, got offset {}", world.x - 100.0);
|
|
|
|
// Block-local geometry must still pick up the block scale normally.
|
|
let block_point = transform.apply(Vector3::new(1.0, 0.0, 0.0));
|
|
assert!((block_point.x - 112.0).abs() < 1.0e-9, "block-local geometry should still be scaled by x_scale 2");
|
|
}
|
|
|
|
#[test]
|
|
fn insert_instance_transform_rotates_the_spacing_with_the_insert() {
|
|
// Matches the pinned CAD model's own `Insert::array_points`: spacing
|
|
// is rotated with the insert but never scaled.
|
|
let document = CadDocument::new();
|
|
let mut insert = Insert::new("BLOCK", Vector3::ZERO);
|
|
insert.rotation = std::f64::consts::FRAC_PI_2;
|
|
insert.row_count = 1;
|
|
insert.column_count = 2;
|
|
insert.column_spacing = 10.0;
|
|
|
|
let transform = insert_instance_transform(
|
|
&document,
|
|
&insert,
|
|
[10.0, 0.0, 0.0],
|
|
1.0,
|
|
BlockScalePolicy::Applied,
|
|
);
|
|
let world = transform.apply(Vector3::ZERO);
|
|
assert!(world.x.abs() < 1.0e-9, "a 90deg rotation should turn column spacing into Y, got x={}", world.x);
|
|
assert!((world.y - 10.0).abs() < 1.0e-9, "expected the spacing rotated onto Y, got y={}", world.y);
|
|
}
|
|
}
|