forked from 1b-agent/opencadstudio-upstream
Merge main into Select-Color-Redesign
Keep both sides: main's ViewBackground colour target and PREVIEW icon next to this branch's Interface target and LIST/SEARCH icons. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013rGmDUNr1hUf4zo1X3dak8
This commit is contained in:
231 files changed
+32217
-3423
No files matched your search
File diff suppressed because it is too large.
Load diff
@@ -538,14 +538,16 @@ fn locked_redefine_with_a_changed_base_moves_the_instance() {
|
||||
);
|
||||
|
||||
// The redefine path: the same contents, but a new base point (0,20,0).
|
||||
scene.redefine_block_raw(
|
||||
NAME,
|
||||
v(0.0, 20.0, 0.0),
|
||||
vec![EntityType::Line(Line::from_points(
|
||||
v(0.0, 0.0, 0.0),
|
||||
v(20.0, 0.0, 0.0),
|
||||
))],
|
||||
);
|
||||
assert!(scene
|
||||
.redefine_block_raw(
|
||||
NAME,
|
||||
v(0.0, 20.0, 0.0),
|
||||
vec![EntityType::Line(Line::from_points(
|
||||
v(0.0, 0.0, 0.0),
|
||||
v(20.0, 0.0, 0.0),
|
||||
))],
|
||||
)
|
||||
.is_ok());
|
||||
assert_close(
|
||||
block_base_point(&scene.document, NAME),
|
||||
[0.0, 20.0, 0.0],
|
||||
@@ -616,14 +618,16 @@ fn locked_noop_redefine_leaves_the_instance_where_it_is() {
|
||||
assert_close(end, [62.990381, 27.5, 0.0], 1e-6, "line end before");
|
||||
|
||||
// No-op redefine: identical contents, same base.
|
||||
scene.redefine_block_raw(
|
||||
NAME,
|
||||
base,
|
||||
vec![EntityType::Line(Line::from_points(
|
||||
v(6.0, 4.0, 0.0),
|
||||
v(16.0, 4.0, 0.0),
|
||||
))],
|
||||
);
|
||||
assert!(scene
|
||||
.redefine_block_raw(
|
||||
NAME,
|
||||
base,
|
||||
vec![EntityType::Line(Line::from_points(
|
||||
v(6.0, 4.0, 0.0),
|
||||
v(16.0, 4.0, 0.0),
|
||||
))],
|
||||
)
|
||||
.is_ok());
|
||||
assert_close(
|
||||
block_base_point(&scene.document, NAME),
|
||||
[6.0, 4.0, 0.0],
|
||||
@@ -667,9 +671,8 @@ fn locked_block_definition_dialog_prefills_the_existing_definition_base_point()
|
||||
|
||||
#[test]
|
||||
fn locked_explode_places_geometry_at_convention_positions() {
|
||||
// base-point: `explode_entity` shifts the definition's contents by -base before
|
||||
// handing them to the codec's explode, so the pieces land where the render
|
||||
// path puts them (see the module header for the derivation).
|
||||
// The document-aware codec explosion uses the definition's base point,
|
||||
// so the pieces land where the render path puts them.
|
||||
let mut scene = scene_with_bp_test();
|
||||
let insert = placed_instance(&mut scene);
|
||||
let pieces = crate::modules::draw::modify::explode::explode_entity(
|
||||
@@ -679,7 +682,7 @@ fn locked_explode_places_geometry_at_convention_positions() {
|
||||
let mut line_start = None;
|
||||
let mut line_end = None;
|
||||
let mut circle_center = None;
|
||||
for piece in pieces {
|
||||
for piece in &pieces {
|
||||
match piece {
|
||||
EntityType::Line(line) => {
|
||||
if line_end.is_none() {
|
||||
@@ -710,6 +713,20 @@ fn locked_explode_places_geometry_at_convention_positions() {
|
||||
1e-6,
|
||||
"exploded circle center",
|
||||
);
|
||||
// Exploding an INSERT drops attribute values and the
|
||||
// attribute definitions (tags) reappear as ATTDEF entities.
|
||||
let attdef_piece = pieces.iter().find_map(|p| match p {
|
||||
EntityType::AttributeDefinition(ad) => Some(ad.clone()),
|
||||
_ => None,
|
||||
});
|
||||
let ad = attdef_piece.expect("the exploded ATTDEF");
|
||||
assert_eq!(ad.tag, "TAG1");
|
||||
assert_close(
|
||||
ad.insertion_point,
|
||||
EXPECTED_ATTR_POINT,
|
||||
1e-6,
|
||||
"exploded attdef insertion point",
|
||||
);
|
||||
// Same numbers as the render path: EXPLODE and the renderer agree.
|
||||
let (render_start, render_end) = instance_line_world(&scene, BP_TEST);
|
||||
assert_close(render_end, EXPECTED_LINE_END, 1e-6, "render path: line end");
|
||||
@@ -721,6 +738,62 @@ fn locked_explode_places_geometry_at_convention_positions() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locked_minsert_explode_restores_attdefs_in_each_rotated_cell() {
|
||||
let scene = scene_with_bp_test();
|
||||
let mut insert = Insert::new(BP_TEST, v(100.0, 100.0, 0.0))
|
||||
.with_uniform_scale(2.0)
|
||||
.with_rotation(std::f64::consts::FRAC_PI_2)
|
||||
.with_array(2, 2, 10.0, 20.0);
|
||||
insert
|
||||
.attributes
|
||||
.push(AttributeEntity::new("TAG1".into(), "Instance value".into()));
|
||||
let pieces = crate::modules::draw::modify::explode::explode_entity(
|
||||
&EntityType::Insert(insert),
|
||||
&scene.document,
|
||||
);
|
||||
let definitions: Vec<_> = pieces
|
||||
.iter()
|
||||
.filter_map(|entity| match entity {
|
||||
EntityType::AttributeDefinition(definition) => Some(definition),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(definitions.len(), 4);
|
||||
for (definition, expected) in definitions.iter().zip([
|
||||
[94.0, 104.0, 0.0],
|
||||
[94.0, 114.0, 0.0],
|
||||
[74.0, 104.0, 0.0],
|
||||
[74.0, 114.0, 0.0],
|
||||
]) {
|
||||
assert_close(definition.insertion_point, expected, 1e-6, "array ATTDEF");
|
||||
assert_eq!(definition.tag, "TAG1");
|
||||
assert_eq!(definition.default_value, "");
|
||||
}
|
||||
let lines: Vec<_> = pieces
|
||||
.iter()
|
||||
.filter_map(|entity| match entity {
|
||||
EntityType::Line(line) => Some(line),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(lines.len(), 4);
|
||||
for (line, expected) in lines.iter().zip([
|
||||
[100.0, 100.0, 0.0],
|
||||
[100.0, 110.0, 0.0],
|
||||
[80.0, 100.0, 0.0],
|
||||
[80.0, 110.0, 0.0],
|
||||
]) {
|
||||
assert_close(line.start, expected, 1e-6, "array LINE");
|
||||
}
|
||||
assert_close(
|
||||
block_attdefs(&scene.document, BP_TEST)[0].insertion_point,
|
||||
[12.0, 8.0, 0.0],
|
||||
1e-6,
|
||||
"source ATTDEF stays in definition coordinates",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locked_xclip_boundary_round_trips_through_the_base_point_convention() {
|
||||
// base-point follow-up fix: the XCLIP ring is stored as WCS points plus the
|
||||
@@ -1036,3 +1109,149 @@ fn locked_nested_reference_explode_matches_the_render_path() {
|
||||
"render path: inner line end",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locked_nested_insert_with_attributes_exploded_one_level() {
|
||||
// When an outer block containing an inner INSERT and attributes is exploded one level,
|
||||
// the outer block's attributes drop their values and its ATTDEFs reappear, while
|
||||
// the inner INSERT remains an intact INSERT with its attributes preserved.
|
||||
const INNER: &str = "BP_ATTR_INNER";
|
||||
const OUTER: &str = "BP_ATTR_OUTER";
|
||||
let mut scene = Scene::new();
|
||||
|
||||
// INNER block: base (0,0,0), has a LINE and an ATTDEF
|
||||
let inner_ad = AttributeDefinition {
|
||||
insertion_point: v(1.0, 1.0, 0.0),
|
||||
height: 1.0,
|
||||
..AttributeDefinition::new(
|
||||
"INNER_TAG".to_string(),
|
||||
"Inner prompt".to_string(),
|
||||
"Inner val".to_string(),
|
||||
)
|
||||
};
|
||||
scene.define_block_raw(
|
||||
INNER,
|
||||
v(0.0, 0.0, 0.0),
|
||||
vec![
|
||||
EntityType::Line(Line::from_points(v(0.0, 0.0, 0.0), v(5.0, 0.0, 0.0))),
|
||||
EntityType::AttributeDefinition(inner_ad),
|
||||
],
|
||||
);
|
||||
|
||||
// OUTER block: base (10.0, 5.0, 0.0), has a nested INSERT of INNER (with an attribute instance) and an ATTDEF
|
||||
let mut inner_ins = Insert::new(INNER.to_string(), v(5.0, 3.0, 0.0));
|
||||
let mut inner_attr =
|
||||
AttributeEntity::new("INNER_TAG".to_string(), "Actual inner value".to_string());
|
||||
inner_attr.insertion_point = v(6.0, 4.0, 0.0);
|
||||
inner_ins.attributes.push(inner_attr);
|
||||
|
||||
let outer_ad = AttributeDefinition {
|
||||
insertion_point: v(12.0, 8.0, 0.0),
|
||||
height: 1.0,
|
||||
..AttributeDefinition::new(
|
||||
"OUTER_TAG".to_string(),
|
||||
"Outer prompt".to_string(),
|
||||
"Outer val".to_string(),
|
||||
)
|
||||
};
|
||||
|
||||
scene.define_block_raw(
|
||||
OUTER,
|
||||
v(10.0, 5.0, 0.0),
|
||||
vec![
|
||||
EntityType::Insert(inner_ins),
|
||||
EntityType::AttributeDefinition(outer_ad),
|
||||
],
|
||||
);
|
||||
|
||||
// Model space: instance of OUTER at (100, 100), 30°, scale 2
|
||||
let mut outer_instance = Insert::new(OUTER.to_string(), v(100.0, 100.0, 0.0));
|
||||
outer_instance.set_x_scale(2.0);
|
||||
outer_instance.set_y_scale(2.0);
|
||||
outer_instance.set_z_scale(2.0);
|
||||
outer_instance.rotation = DEG30;
|
||||
let mut outer_attr =
|
||||
AttributeEntity::new("OUTER_TAG".to_string(), "Actual outer value".to_string());
|
||||
outer_attr.insertion_point = v(100.464102, 107.196152, 0.0);
|
||||
outer_instance.attributes.push(outer_attr);
|
||||
|
||||
// Explode outer instance one level
|
||||
let pieces = crate::modules::draw::modify::explode::explode_entity(
|
||||
&EntityType::Insert(outer_instance),
|
||||
&scene.document,
|
||||
);
|
||||
|
||||
// Expected pieces: the nested inner INSERT (with its attribute still intact) and the outer ATTDEF
|
||||
let nested_insert = pieces
|
||||
.iter()
|
||||
.find_map(|p| match p {
|
||||
EntityType::Insert(ins) if ins.block_name == INNER => Some(ins.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.expect("nested inner INSERT among pieces");
|
||||
|
||||
assert_eq!(nested_insert.attributes.len(), 1);
|
||||
assert_eq!(nested_insert.attributes[0].tag, "INNER_TAG");
|
||||
assert_eq!(
|
||||
nested_insert.attributes[0].get_value(),
|
||||
"Actual inner value"
|
||||
);
|
||||
|
||||
let outer_attdef = pieces
|
||||
.iter()
|
||||
.find_map(|p| match p {
|
||||
EntityType::AttributeDefinition(ad) if ad.tag == "OUTER_TAG" => Some(ad.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.expect("reappeared outer ATTDEF among pieces");
|
||||
|
||||
assert_close(
|
||||
outer_attdef.insertion_point,
|
||||
EXPECTED_ATTR_POINT,
|
||||
1e-6,
|
||||
"outer attdef insertion point",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locked_redefine_block_raw_refuses_cycle() {
|
||||
let mut scene = Scene::new();
|
||||
let blk_a = "REDEF_A";
|
||||
let blk_b = "REDEF_B";
|
||||
|
||||
scene.define_block_raw(
|
||||
blk_a,
|
||||
Vector3::ZERO,
|
||||
vec![EntityType::Line(Line::from_points(
|
||||
Vector3::ZERO,
|
||||
Vector3::new(1.0, 0.0, 0.0),
|
||||
))],
|
||||
);
|
||||
|
||||
// B contains A
|
||||
scene.define_block_raw(
|
||||
blk_b,
|
||||
Vector3::ZERO,
|
||||
vec![EntityType::Insert(Insert::new(
|
||||
blk_a.to_string(),
|
||||
Vector3::ZERO,
|
||||
))],
|
||||
);
|
||||
|
||||
// Redefining A to contain B would create cycle A -> B -> A; must refuse
|
||||
let res = scene.redefine_block_raw(
|
||||
blk_a,
|
||||
Vector3::ZERO,
|
||||
vec![EntityType::Insert(Insert::new(
|
||||
blk_b.to_string(),
|
||||
Vector3::ZERO,
|
||||
))],
|
||||
);
|
||||
|
||||
assert!(res.is_err());
|
||||
let err = res.unwrap_err();
|
||||
assert!(
|
||||
err.contains("circular dependency detected"),
|
||||
"expected cycle error, got: {err}"
|
||||
);
|
||||
}
|
||||
+193
-1
@@ -16,7 +16,7 @@
|
||||
use codec::objects::ObjectType;
|
||||
use codec::tables::BlockRecord;
|
||||
use codec::types::Handle;
|
||||
use codec::{CadDocument, EntityType};
|
||||
use codec::{CadDocument, EntityType, TableEntry};
|
||||
use std::collections::HashSet;
|
||||
use std::fmt;
|
||||
|
||||
@@ -285,6 +285,29 @@ fn is_layout_record(document: &CadDocument, record: &BlockRecord) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
/// Candidate block names for the Edit Block Definition (BEDIT) dialog:
|
||||
/// excludes standard layouts (*MODEL_SPACE, *PAPER_SPACE), layout records,
|
||||
/// anonymous blocks (*U, *D, *T, etc.), and externally referenced blocks.
|
||||
/// Sorted alphabetically, case-insensitive.
|
||||
pub fn bedit_candidate_block_names(document: &CadDocument) -> Vec<String> {
|
||||
let mut names: Vec<String> = document
|
||||
.block_records
|
||||
.iter()
|
||||
.filter(|record| {
|
||||
!record.is_standard()
|
||||
&& !is_layout_record(document, record)
|
||||
&& !record.is_anonymous()
|
||||
&& !record.name.starts_with('*')
|
||||
&& !record.flags.is_xref
|
||||
&& !record.flags.is_xref_overlay
|
||||
&& !record.name.contains('|')
|
||||
})
|
||||
.map(|record| record.name.clone())
|
||||
.collect();
|
||||
names.sort_by_key(|n| n.to_ascii_lowercase());
|
||||
names
|
||||
}
|
||||
|
||||
/// The extension-dictionary key on a dynamic block's record.
|
||||
const ENHANCED_BLOCK_KEY: &str = "ACAD_ENHANCEDBLOCK";
|
||||
|
||||
@@ -598,7 +621,88 @@ fn record_of<'a>(
|
||||
.get(name)
|
||||
.ok_or_else(|| missing(name))
|
||||
}
|
||||
|
||||
/// Checks whether inserting `inserted_block` into `container_block` would create a circular
|
||||
/// block nesting dependency, directly or transitively.
|
||||
///
|
||||
/// Matching is case-insensitive, consistent with the rest of the block service.
|
||||
///
|
||||
/// Xrefs (`|` in name or xref flags) and anonymous blocks (`*` prefix) are handled as follows:
|
||||
/// - Anonymous blocks (`*U...`) are fully traversed like normal blocks because their geometry
|
||||
/// and nested inserts live in the document.
|
||||
/// - Externally referenced blocks (xrefs) have definitions that come from external files and
|
||||
/// are not edited inline in the host document, but if an xref name is tested, it is matched
|
||||
/// case-insensitively and its local block record entities (if any) are traversed.
|
||||
pub fn would_create_cycle(
|
||||
document: &CadDocument,
|
||||
container_block: &str,
|
||||
inserted_block: &str,
|
||||
) -> bool {
|
||||
let container = container_block.trim();
|
||||
let inserted = inserted_block.trim();
|
||||
if container.is_empty() || inserted.is_empty() {
|
||||
return false;
|
||||
}
|
||||
if container.eq_ignore_ascii_case(inserted) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let mut visited: HashSet<String> = HashSet::default();
|
||||
let mut stack: Vec<String> = vec![inserted.to_ascii_uppercase()];
|
||||
|
||||
while let Some(current_upper) = stack.pop() {
|
||||
if !visited.insert(current_upper.clone()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find block record matching current_upper case-insensitively
|
||||
let Some(record) = document.block_records.get(¤t_upper) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
// Check entities owned by this block record: either registered in entity_handles
|
||||
// or having owner_handle == record.handle in the document.
|
||||
let mut sub_inserts = Vec::new();
|
||||
for &handle in &record.entity_handles {
|
||||
if let Some(EntityType::Insert(sub_ins)) = document.get_entity(handle) {
|
||||
sub_inserts.push(sub_ins.block_name.trim().to_string());
|
||||
}
|
||||
}
|
||||
for entity in document.entities() {
|
||||
if entity.common().owner_handle == record.handle {
|
||||
if let EntityType::Insert(sub_ins) = entity {
|
||||
sub_inserts.push(sub_ins.block_name.trim().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for sub_name in sub_inserts {
|
||||
if sub_name.eq_ignore_ascii_case(container) {
|
||||
return true;
|
||||
}
|
||||
let sub_upper = sub_name.to_ascii_uppercase();
|
||||
if !visited.contains(&sub_upper) {
|
||||
stack.push(sub_upper);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
pub const CIRCULAR_BLOCK_DEPENDENCY_ERROR: &str =
|
||||
"Cannot insert block: circular dependency detected.";
|
||||
|
||||
/// Standard command-line error text when block insertion would create a circular dependency.
|
||||
pub fn circular_dependency_error(container_block: &str, inserted_block: &str) -> String {
|
||||
format!(
|
||||
"Cannot insert block \"{}\" into block \"{}\": circular dependency detected.",
|
||||
inserted_block, container_block
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
mod tests {
|
||||
use super::*;
|
||||
use codec::entities::{
|
||||
@@ -1097,4 +1201,92 @@ mod tests {
|
||||
"block \"A\" is open in the block editor; close the editor first"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locked_would_create_cycle_self_insert() {
|
||||
let mut scene = scene();
|
||||
define(&mut scene, "BLK_A");
|
||||
assert!(would_create_cycle(&scene.document, "BLK_A", "BLK_A"));
|
||||
assert!(would_create_cycle(&scene.document, "blk_a", "BLK_A"));
|
||||
assert!(would_create_cycle(&scene.document, "BLK_A", "blk_a"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locked_would_create_cycle_direct_pair() {
|
||||
let mut scene = scene();
|
||||
let blk_a = define(&mut scene, "BLK_A");
|
||||
define(&mut scene, "BLK_B");
|
||||
// A contains B
|
||||
add_insert(&mut scene, "BLK_B", Some(blk_a));
|
||||
// Inserting A into B would create a cycle
|
||||
assert!(would_create_cycle(&scene.document, "BLK_B", "BLK_A"));
|
||||
// Inserting B into A is not allowed either since A already inserts B
|
||||
assert!(!would_create_cycle(&scene.document, "BLK_A", "BLK_B"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locked_would_create_cycle_three_level_chain() {
|
||||
let mut scene = scene();
|
||||
let blk_a = define(&mut scene, "BLK_A");
|
||||
let blk_b = define(&mut scene, "BLK_B");
|
||||
define(&mut scene, "BLK_C");
|
||||
// A contains B, B contains C
|
||||
add_insert(&mut scene, "BLK_B", Some(blk_a));
|
||||
add_insert(&mut scene, "BLK_C", Some(blk_b));
|
||||
// Inserting A into C creates cycle C -> A -> B -> C
|
||||
assert!(would_create_cycle(&scene.document, "BLK_C", "BLK_A"));
|
||||
// Inserting B into C creates cycle C -> B -> C
|
||||
assert!(would_create_cycle(&scene.document, "BLK_C", "BLK_B"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locked_would_create_cycle_shared_child_not_a_cycle() {
|
||||
let mut scene = scene();
|
||||
let blk_a = define(&mut scene, "BLK_A");
|
||||
let blk_b = define(&mut scene, "BLK_B");
|
||||
define(&mut scene, "BLK_SHARED");
|
||||
// A contains SHARED, B contains SHARED. Inserting B into A is fine (diamond/DAG, not cycle).
|
||||
add_insert(&mut scene, "BLK_SHARED", Some(blk_a));
|
||||
add_insert(&mut scene, "BLK_SHARED", Some(blk_b));
|
||||
assert!(!would_create_cycle(&scene.document, "BLK_A", "BLK_B"));
|
||||
assert!(!would_create_cycle(&scene.document, "BLK_B", "BLK_A"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locked_would_create_cycle_case_differences() {
|
||||
let mut scene = scene();
|
||||
let blk_a = define(&mut scene, "BlockA");
|
||||
let _blk_b = define(&mut scene, "BlockB");
|
||||
add_insert(&mut scene, "BlockB", Some(blk_a));
|
||||
assert!(would_create_cycle(&scene.document, "blockb", "BLOCKA"));
|
||||
assert!(!would_create_cycle(&scene.document, "blocka", "BLOCKB"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locked_bedit_candidate_block_names_filters_and_sorts() {
|
||||
let mut scene = scene();
|
||||
define(&mut scene, "zebra");
|
||||
define(&mut scene, "Alpha");
|
||||
define(&mut scene, "Beta");
|
||||
|
||||
// Anonymous block
|
||||
let mut anon = BlockRecord::new("*U1");
|
||||
anon.handle = Handle::new(9901);
|
||||
anon.flags.anonymous = true;
|
||||
scene.document.block_records.add(anon).unwrap();
|
||||
|
||||
// Xref block
|
||||
let mut xref = BlockRecord::new("XrefBlock");
|
||||
xref.handle = Handle::new(9902);
|
||||
xref.flags.is_xref = true;
|
||||
scene.document.block_records.add(xref).unwrap();
|
||||
|
||||
// Xref dependent symbol
|
||||
let mut xref_dep = BlockRecord::new("HOST|CHILD");
|
||||
xref_dep.handle = Handle::new(9903);
|
||||
scene.document.block_records.add(xref_dep).unwrap();
|
||||
|
||||
let candidates = bedit_candidate_block_names(&scene.document);
|
||||
assert_eq!(candidates, vec!["Alpha", "Beta", "zebra"]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,742 @@
|
||||
//! Visibility states of a dynamic block, authored in the Block Editor and
|
||||
//! stored the way the format keeps them: a BLOCKVISIBILITYPARAMETER in the
|
||||
//! block's `ACAD_ENHANCEDBLOCK` evaluation graph (with its grip and the
|
||||
//! grip's two location expressions), `AcDbBlockRepETag` tags on the
|
||||
//! definition and on each of its objects, and, behind every reference that
|
||||
//! shows a state other than the definition's, an evaluated `*U` copy tagged
|
||||
//! `AcDbBlockRepBTag` and linked back by `AcDbBlockRepresentationData`.
|
||||
//!
|
||||
//! The definition itself shows the first state, so a reference to it needs
|
||||
//! no copy until another state is picked for it.
|
||||
|
||||
use codec::objects::{
|
||||
BlockElement, BlockEvalExpression, BlockEvalValue, BlockEvaluationEdge, BlockEvaluationGraph,
|
||||
BlockEvaluationNode, BlockGrip, BlockGripExpression, BlockParameterPropertyInfo,
|
||||
BlockRepresentationData, BlockVisibilityParameter, BlockVisibilityState, DynamicBlockData,
|
||||
DynamicBlockObject, ObjectType,
|
||||
};
|
||||
use codec::types::Vector3;
|
||||
use codec::xdata::{ExtendedDataRecord, XDataValue};
|
||||
use codec::{CadDocument, EntityType, Handle};
|
||||
|
||||
use super::Scene;
|
||||
|
||||
const ELEMENT_TAG: &str = "AcDbBlockRepETag";
|
||||
const REPRESENTATION_TAG: &str = "AcDbBlockRepBTag";
|
||||
const TRUE_NAME: &str = "AcDbDynamicBlockTrueName";
|
||||
const GUID: &str = "AcDbDynamicBlockGUID";
|
||||
|
||||
/// Object version the parameter, grip and expressions carry.
|
||||
const MAJOR: i32 = 33;
|
||||
const MINOR: i32 = 329;
|
||||
/// Value code of an expression that holds no value.
|
||||
const NO_VALUE: i16 = -9999;
|
||||
|
||||
/// (DXF name, class, DWG maintenance version) of the classes used here.
|
||||
const CLASSES: &[(&str, &str, i32)] = &[
|
||||
("ACAD_EVALUATION_GRAPH", "AcDbEvalGraph", 55),
|
||||
("BLOCKVISIBILITYPARAMETER", "AcDbBlockVisibilityParameter", 55),
|
||||
("BLOCKVISIBILITYGRIP", "AcDbBlockVisibilityGrip", 55),
|
||||
("BLOCKGRIPLOCATIONCOMPONENT", "AcDbBlockGripExpr", 20),
|
||||
("ACDB_DYNAMICBLOCKPURGEPREVENTER_VERSION", "AcDbDynamicBlockPurgePreventer", 61),
|
||||
("ACDB_BLOCKREPRESENTATION_DATA", "AcDbBlockRepresentationData", 58),
|
||||
];
|
||||
|
||||
/// Register the classes with the flags and versions the reference writes
|
||||
/// for them; a reader takes a class registered otherwise for a proxy.
|
||||
fn register_classes(document: &mut CadDocument) {
|
||||
for &(dxf_name, cpp_class_name, maintenance_version) in CLASSES {
|
||||
if let Some(existing) = document.classes.get_mut_by_name(dxf_name) {
|
||||
existing.proxy_flags = codec::classes::ProxyFlags(1153);
|
||||
existing.dwg_version = 25;
|
||||
existing.maintenance_version = maintenance_version;
|
||||
continue;
|
||||
}
|
||||
document.classes.add_or_update(codec::classes::DxfClass {
|
||||
dxf_name: dxf_name.to_string(),
|
||||
cpp_class_name: cpp_class_name.to_string(),
|
||||
application_name: "ObjectDBX Classes".to_string(),
|
||||
proxy_flags: codec::classes::ProxyFlags(1153),
|
||||
instance_count: 0,
|
||||
was_zombie: false,
|
||||
is_an_entity: false,
|
||||
class_number: 0,
|
||||
item_class_id: 0x1F3,
|
||||
dwg_version: 25,
|
||||
maintenance_version,
|
||||
unknown1: 0,
|
||||
unknown2: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_app_id(document: &mut CadDocument, name: &str) {
|
||||
if !document.app_ids.contains(name) {
|
||||
let mut app = codec::tables::AppId::new(name);
|
||||
app.handle = document.allocate_handle();
|
||||
let _ = document.app_ids.add(app);
|
||||
}
|
||||
}
|
||||
|
||||
fn expression(node_id: i32, value_code: i16, value: BlockEvalValue) -> BlockEvalExpression {
|
||||
BlockEvalExpression {
|
||||
parent_id: BlockEvalExpression::NO_PARENT,
|
||||
major: MAJOR,
|
||||
minor: MINOR,
|
||||
value_code,
|
||||
value,
|
||||
node_id,
|
||||
}
|
||||
}
|
||||
|
||||
fn dynamic(
|
||||
handle: Handle,
|
||||
owner: Handle,
|
||||
dxf_name: &str,
|
||||
cpp_class_name: &str,
|
||||
data: DynamicBlockData,
|
||||
) -> DynamicBlockObject {
|
||||
let mut object = DynamicBlockObject::new(dxf_name, cpp_class_name);
|
||||
object.handle = handle;
|
||||
object.owner = owner;
|
||||
object.data = data;
|
||||
object
|
||||
}
|
||||
|
||||
/// The visibility parameter of block definition `definition`.
|
||||
pub fn parameter(document: &CadDocument, definition: Handle) -> Option<&BlockVisibilityParameter> {
|
||||
document.block_visibility_param_for_def(definition)
|
||||
}
|
||||
|
||||
/// Store an edited parameter in the document and its lookup view.
|
||||
fn store(document: &mut CadDocument, parameter: BlockVisibilityParameter) {
|
||||
document
|
||||
.block_visibility_params
|
||||
.insert(parameter.handle, parameter.clone());
|
||||
document.objects.insert(
|
||||
parameter.handle,
|
||||
ObjectType::BlockVisibilityParameter(parameter),
|
||||
);
|
||||
}
|
||||
|
||||
/// The objects of a definition, block begin and end left out.
|
||||
fn members(document: &CadDocument, definition: Handle) -> Vec<Handle> {
|
||||
document
|
||||
.block_records
|
||||
.iter()
|
||||
.find(|record| record.handle == definition)
|
||||
.map(|record| {
|
||||
record
|
||||
.entity_handles
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|handle| {
|
||||
!matches!(
|
||||
document.get_entity(*handle),
|
||||
None | Some(EntityType::Block(_) | EntityType::BlockEnd(_))
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn element_tag(id: i32, handle: Handle) -> ExtendedDataRecord {
|
||||
let mut tag = ExtendedDataRecord::new(ELEMENT_TAG);
|
||||
tag.add_value(XDataValue::Integer16(1));
|
||||
tag.add_value(XDataValue::Integer32(id));
|
||||
tag.add_value(XDataValue::Handle(handle));
|
||||
tag
|
||||
}
|
||||
|
||||
fn element_id(entity: &EntityType) -> Option<i32> {
|
||||
entity
|
||||
.common()
|
||||
.extended_data
|
||||
.get_record(ELEMENT_TAG)?
|
||||
.values
|
||||
.iter()
|
||||
.find_map(|value| match value {
|
||||
XDataValue::Integer32(id) => Some(*id),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
fn set_entity_tag(document: &mut CadDocument, handle: Handle, record: ExtendedDataRecord) {
|
||||
let app = document
|
||||
.app_ids
|
||||
.get(&record.application_name)
|
||||
.map(|app| app.handle.value());
|
||||
if let Some(entity) = document.get_entity_mut(handle) {
|
||||
let xdata = &mut entity.common_mut().extended_data;
|
||||
// The fresh record, not bytes kept from a read, is what gets written.
|
||||
if let Some(app) = app {
|
||||
xdata.raw_dwg_eed.retain(|(owner, _)| *owner != app);
|
||||
}
|
||||
xdata.upsert_record(record);
|
||||
}
|
||||
}
|
||||
|
||||
/// Give every object of the definition its element tag, numbering the new
|
||||
/// ones after the highest id in use, and record the next id on the block.
|
||||
fn tag_members(document: &mut CadDocument, definition: Handle) {
|
||||
let members = members(document, definition);
|
||||
let mut next = members
|
||||
.iter()
|
||||
.filter_map(|handle| document.get_entity(*handle).and_then(element_id))
|
||||
.max()
|
||||
.map_or(0, |id| id + 1);
|
||||
for handle in members {
|
||||
let id = match document.get_entity(handle).and_then(element_id) {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
next += 1;
|
||||
next - 1
|
||||
}
|
||||
};
|
||||
set_entity_tag(document, handle, element_tag(id, handle));
|
||||
}
|
||||
let mut tag = ExtendedDataRecord::new(ELEMENT_TAG);
|
||||
tag.add_value(XDataValue::Integer16(1));
|
||||
tag.add_value(XDataValue::Integer32(next));
|
||||
document.set_object_xdata_record(definition, tag);
|
||||
}
|
||||
|
||||
/// A registry-format GUID for a new dynamic block.
|
||||
fn new_guid() -> String {
|
||||
let mut bytes = [0u8; 16];
|
||||
if getrandom::fill(&mut bytes).is_err() {
|
||||
let seed = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0, |elapsed| elapsed.as_nanos());
|
||||
bytes = seed.to_le_bytes();
|
||||
}
|
||||
let hex: String = bytes.iter().map(|byte| format!("{byte:02X}")).collect();
|
||||
format!(
|
||||
"{{{}-{}-{}-{}-{}}}",
|
||||
&hex[0..8],
|
||||
&hex[8..12],
|
||||
&hex[12..16],
|
||||
&hex[16..20],
|
||||
&hex[20..32]
|
||||
)
|
||||
}
|
||||
|
||||
/// Make block definition `definition` dynamic with a visibility parameter
|
||||
/// whose grip sits at `location`, one state showing every object. Returns
|
||||
/// the parameter, or the one the block has already.
|
||||
pub fn create(document: &mut CadDocument, definition: Handle, location: Vector3) -> Option<Handle> {
|
||||
if let Some(existing) = parameter(document, definition) {
|
||||
return Some(existing.handle);
|
||||
}
|
||||
let name = document
|
||||
.block_records
|
||||
.iter()
|
||||
.find(|record| record.handle == definition)?
|
||||
.name
|
||||
.clone();
|
||||
register_classes(document);
|
||||
for app in [ELEMENT_TAG, REPRESENTATION_TAG, TRUE_NAME, GUID] {
|
||||
ensure_app_id(document, app);
|
||||
}
|
||||
let dictionary = document.ensure_extension_dictionary(definition);
|
||||
let [graph, parameter, grip, x, y, preventer] =
|
||||
[(); 6].map(|_| document.allocate_handle());
|
||||
|
||||
store(
|
||||
document,
|
||||
BlockVisibilityParameter {
|
||||
handle: parameter,
|
||||
owner: graph,
|
||||
eval_parent_id: BlockEvalExpression::NO_PARENT,
|
||||
eval_major: MAJOR,
|
||||
eval_minor: MINOR,
|
||||
eval_value_code: NO_VALUE,
|
||||
eval_value: BlockEvalValue::None,
|
||||
eval_node_id: 1,
|
||||
element_name: "Visibility State".to_string(),
|
||||
element_major: MAJOR,
|
||||
element_minor: MINOR,
|
||||
element_eed_1071: 0,
|
||||
show_properties: true,
|
||||
chain_actions: false,
|
||||
name: "Visibility1".to_string(),
|
||||
description: String::new(),
|
||||
def_point: location,
|
||||
property_info: [
|
||||
BlockParameterPropertyInfo::default(),
|
||||
BlockParameterPropertyInfo::default(),
|
||||
],
|
||||
property_info_count: 2,
|
||||
is_initialized: true,
|
||||
unknown_bool: false,
|
||||
all_blocks: Vec::new(),
|
||||
states: vec![BlockVisibilityState {
|
||||
name: "VisibilityState0".to_string(),
|
||||
..Default::default()
|
||||
}],
|
||||
},
|
||||
);
|
||||
let grip_object = dynamic(
|
||||
grip,
|
||||
graph,
|
||||
"BLOCKVISIBILITYGRIP",
|
||||
"AcDbBlockVisibilityGrip",
|
||||
DynamicBlockData::VisibilityGrip(BlockGrip {
|
||||
element: BlockElement {
|
||||
eval: expression(2, NO_VALUE, BlockEvalValue::None),
|
||||
name: "Grip".to_string(),
|
||||
major: MAJOR,
|
||||
minor: MINOR,
|
||||
eed_1071: 0,
|
||||
},
|
||||
flags_91: 3,
|
||||
flags_92: 4,
|
||||
location,
|
||||
insert_cycling: false,
|
||||
insert_cycling_weight: -1,
|
||||
}),
|
||||
);
|
||||
document
|
||||
.objects
|
||||
.insert(grip, ObjectType::DynamicBlock(grip_object));
|
||||
for (handle, node, axis) in [(x, 3, "UpdatedX"), (y, 4, "UpdatedY")] {
|
||||
let component = dynamic(
|
||||
handle,
|
||||
graph,
|
||||
"BLOCKGRIPLOCATIONCOMPONENT",
|
||||
"AcDbBlockGripExpr",
|
||||
DynamicBlockData::GripLocationComponent(BlockGripExpression {
|
||||
eval: expression(node, 40, BlockEvalValue::Real(0.0)),
|
||||
grip_type: 1,
|
||||
expression: axis.to_string(),
|
||||
}),
|
||||
);
|
||||
document
|
||||
.objects
|
||||
.insert(handle, ObjectType::DynamicBlock(component));
|
||||
}
|
||||
// The grip drives the parameter, whose position the two expressions
|
||||
// read: node 0 the parameter, 1 the grip, 2 and 3 the expressions.
|
||||
let node = |id: i32, expression: Handle, node_data: [i32; 4]| BlockEvaluationNode {
|
||||
id,
|
||||
edge_flags: 32,
|
||||
next_id: id + 1,
|
||||
expression,
|
||||
node_data,
|
||||
active_cycles: None,
|
||||
};
|
||||
let edge = |id: i32, source_node: i32, destination_node: i32, outgoing_edges| {
|
||||
BlockEvaluationEdge {
|
||||
id,
|
||||
next_id: 0,
|
||||
incoming_edge: 1,
|
||||
source_node,
|
||||
destination_node,
|
||||
outgoing_edges,
|
||||
}
|
||||
};
|
||||
let mut graph_object = dynamic(
|
||||
graph,
|
||||
dictionary,
|
||||
"ACAD_EVALUATION_GRAPH",
|
||||
"AcDbEvalGraph",
|
||||
DynamicBlockData::EvaluationGraph(BlockEvaluationGraph {
|
||||
first_node_id: 4,
|
||||
first_node_id_copy: 4,
|
||||
nodes: vec![
|
||||
node(0, parameter, [0, 0, 1, 2]),
|
||||
node(1, grip, [-1, -1, 0, 0]),
|
||||
node(2, x, [1, 1, -1, -1]),
|
||||
node(3, y, [2, 2, -1, -1]),
|
||||
],
|
||||
edges: vec![
|
||||
edge(0, 1, 0, [-1; 5]),
|
||||
edge(1, 0, 2, [-1, -1, -1, 2, -1]),
|
||||
edge(2, 0, 3, [-1, -1, 1, -1, -1]),
|
||||
],
|
||||
}),
|
||||
);
|
||||
graph_object.reactors.push(dictionary);
|
||||
document
|
||||
.objects
|
||||
.insert(graph, ObjectType::DynamicBlock(graph_object));
|
||||
let mut preventer_object = dynamic(
|
||||
preventer,
|
||||
dictionary,
|
||||
"ACDB_DYNAMICBLOCKPURGEPREVENTER_VERSION",
|
||||
"AcDbDynamicBlockPurgePreventer",
|
||||
DynamicBlockData::Representation(BlockRepresentationData {
|
||||
flags: 1,
|
||||
block: definition,
|
||||
}),
|
||||
);
|
||||
preventer_object.reactors.push(dictionary);
|
||||
document
|
||||
.objects
|
||||
.insert(preventer, ObjectType::DynamicBlock(preventer_object));
|
||||
if let Some(ObjectType::Dictionary(dictionary)) = document.objects.get_mut(&dictionary) {
|
||||
dictionary.add_entry("ACAD_ENHANCEDBLOCK", graph);
|
||||
dictionary.add_entry("AcDbDynamicBlockRoundTripPurgePreventer", preventer);
|
||||
}
|
||||
|
||||
let mut true_name = ExtendedDataRecord::new(TRUE_NAME);
|
||||
true_name.add_value(XDataValue::String(name));
|
||||
document.set_object_xdata_record(definition, true_name);
|
||||
let mut guid = ExtendedDataRecord::new(GUID);
|
||||
guid.add_value(XDataValue::String(new_guid()));
|
||||
document.set_object_xdata_record(definition, guid);
|
||||
tag_members(document, definition);
|
||||
Some(parameter)
|
||||
}
|
||||
|
||||
/// The names of the states of `parameter`, in list order.
|
||||
pub fn state_names(document: &CadDocument, parameter: Handle) -> Vec<String> {
|
||||
match document.objects.get(¶meter) {
|
||||
Some(ObjectType::BlockVisibilityParameter(parameter)) => {
|
||||
parameter.states.iter().map(|state| state.name.clone()).collect()
|
||||
}
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn edit<R>(
|
||||
document: &mut CadDocument,
|
||||
parameter: Handle,
|
||||
change: impl FnOnce(&mut BlockVisibilityParameter) -> R,
|
||||
) -> Option<R> {
|
||||
let Some(ObjectType::BlockVisibilityParameter(current)) = document.objects.get(¶meter)
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
let mut edited = current.clone();
|
||||
let result = change(&mut edited);
|
||||
store(document, edited);
|
||||
Some(result)
|
||||
}
|
||||
|
||||
/// Add state `name`, showing what state `like` shows. Returns its index, or
|
||||
/// `None` when the name is taken.
|
||||
pub fn add_state(
|
||||
document: &mut CadDocument,
|
||||
parameter: Handle,
|
||||
name: &str,
|
||||
like: usize,
|
||||
) -> Option<usize> {
|
||||
edit(document, parameter, |parameter| {
|
||||
if parameter
|
||||
.states
|
||||
.iter()
|
||||
.any(|state| state.name.eq_ignore_ascii_case(name))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let visible_blocks = parameter
|
||||
.states
|
||||
.get(like)
|
||||
.map(|state| state.visible_blocks.clone())
|
||||
.unwrap_or_default();
|
||||
parameter.states.push(BlockVisibilityState {
|
||||
name: name.to_string(),
|
||||
visible_blocks,
|
||||
visible_params: Vec::new(),
|
||||
});
|
||||
Some(parameter.states.len() - 1)
|
||||
})
|
||||
.flatten()
|
||||
}
|
||||
|
||||
/// Rename state `index`; false when the new name is taken.
|
||||
pub fn rename_state(
|
||||
document: &mut CadDocument,
|
||||
parameter: Handle,
|
||||
index: usize,
|
||||
name: &str,
|
||||
) -> bool {
|
||||
edit(document, parameter, |parameter| {
|
||||
let taken = parameter
|
||||
.states
|
||||
.iter()
|
||||
.enumerate()
|
||||
.any(|(other, state)| other != index && state.name.eq_ignore_ascii_case(name));
|
||||
match parameter.states.get_mut(index) {
|
||||
Some(state) if !taken => {
|
||||
state.name = name.to_string();
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Delete state `index`; the last state stays.
|
||||
pub fn delete_state(document: &mut CadDocument, parameter: Handle, index: usize) -> bool {
|
||||
edit(document, parameter, |parameter| {
|
||||
if parameter.states.len() <= 1 || index >= parameter.states.len() {
|
||||
return false;
|
||||
}
|
||||
parameter.states.remove(index);
|
||||
true
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Show (`visible`) or hide `handles` in state `index`. An object the
|
||||
/// parameter did not govern yet is shown in every other state, as it was.
|
||||
pub fn set_visible(
|
||||
document: &mut CadDocument,
|
||||
parameter: Handle,
|
||||
index: usize,
|
||||
handles: &[Handle],
|
||||
visible: bool,
|
||||
) {
|
||||
let definition = owner_definition(document, parameter);
|
||||
edit(document, parameter, |parameter| {
|
||||
for &handle in handles {
|
||||
if !parameter.all_blocks.contains(&handle) {
|
||||
parameter.all_blocks.push(handle);
|
||||
for state in &mut parameter.states {
|
||||
state.visible_blocks.push(handle);
|
||||
}
|
||||
}
|
||||
let Some(state) = parameter.states.get_mut(index) else {
|
||||
continue;
|
||||
};
|
||||
state.visible_blocks.retain(|other| *other != handle);
|
||||
if visible {
|
||||
state.visible_blocks.push(handle);
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Some(definition) = definition {
|
||||
tag_members(document, definition);
|
||||
}
|
||||
}
|
||||
|
||||
/// The block definition `parameter` belongs to (graph, dictionary, record).
|
||||
fn owner_definition(document: &CadDocument, parameter: Handle) -> Option<Handle> {
|
||||
let graph = match document.objects.get(¶meter)? {
|
||||
ObjectType::BlockVisibilityParameter(parameter) => parameter.owner,
|
||||
_ => return None,
|
||||
};
|
||||
let dictionary = match document.objects.get(&graph)? {
|
||||
ObjectType::DynamicBlock(graph) => graph.owner,
|
||||
_ => return None,
|
||||
};
|
||||
match document.objects.get(&dictionary)? {
|
||||
ObjectType::Dictionary(dictionary) => Some(dictionary.owner),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Show the definition's objects as state `index` does.
|
||||
pub fn apply_state(document: &mut CadDocument, parameter: Handle, index: usize) {
|
||||
let Some(ObjectType::BlockVisibilityParameter(current)) = document.objects.get(¶meter)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let Some(state) = current.states.get(index) else {
|
||||
return;
|
||||
};
|
||||
let shown: Vec<(Handle, bool)> = current
|
||||
.all_blocks
|
||||
.iter()
|
||||
.map(|handle| (*handle, state.visible_blocks.contains(handle)))
|
||||
.collect();
|
||||
for (handle, visible) in shown {
|
||||
if let Some(entity) = document.get_entity_mut(handle) {
|
||||
entity.common_mut().invisible = !visible;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The state whose objects are the ones the definition shows now.
|
||||
pub fn shown_state(document: &CadDocument, parameter: Handle) -> usize {
|
||||
let Some(ObjectType::BlockVisibilityParameter(parameter)) = document.objects.get(¶meter)
|
||||
else {
|
||||
return 0;
|
||||
};
|
||||
parameter
|
||||
.states
|
||||
.iter()
|
||||
.position(|state| {
|
||||
parameter.all_blocks.iter().all(|handle| {
|
||||
let shown = document
|
||||
.get_entity(*handle)
|
||||
.is_some_and(|entity| !entity.common().invisible);
|
||||
shown == state.visible_blocks.contains(handle)
|
||||
})
|
||||
})
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// After the Block Editor: drop objects that were erased, tag new ones and
|
||||
/// leave the definition showing its first state.
|
||||
pub fn finish_editing(document: &mut CadDocument, definition: Handle) {
|
||||
let Some(handle) = parameter(document, definition).map(|parameter| parameter.handle) else {
|
||||
return;
|
||||
};
|
||||
let live: Vec<Handle> = members(document, definition);
|
||||
edit(document, handle, |parameter| {
|
||||
parameter.all_blocks.retain(|handle| live.contains(handle));
|
||||
for state in &mut parameter.states {
|
||||
state.visible_blocks.retain(|handle| live.contains(handle));
|
||||
}
|
||||
});
|
||||
tag_members(document, definition);
|
||||
apply_state(document, handle, 0);
|
||||
}
|
||||
|
||||
impl Scene {
|
||||
/// Give a reference to a dynamic definition its own evaluated copy, so a
|
||||
/// state picked for it leaves the definition and the other references
|
||||
/// alone. Returns the copy's name; a reference already showing a copy,
|
||||
/// or not dynamic, keeps its block and gets `None`.
|
||||
pub fn visibility_representation(&mut self, insert: Handle) -> Option<String> {
|
||||
let Some(EntityType::Insert(reference)) = self.document.get_entity(insert) else {
|
||||
return None;
|
||||
};
|
||||
let definition = self.document.block_records.get(&reference.block_name)?.handle;
|
||||
parameter(&self.document, definition)?;
|
||||
let name = {
|
||||
let next = self
|
||||
.document
|
||||
.block_records
|
||||
.iter()
|
||||
.filter_map(|record| record.name.strip_prefix("*U")?.parse::<u64>().ok())
|
||||
.max()
|
||||
.map_or(1, |n| n + 1);
|
||||
format!("*U{next}")
|
||||
};
|
||||
let base = self
|
||||
.document
|
||||
.block_records
|
||||
.iter()
|
||||
.find(|record| record.handle == definition)
|
||||
.and_then(|record| match self.document.get_entity(record.block_entity_handle) {
|
||||
Some(EntityType::Block(block)) => Some(block.base_point),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or(Vector3::ZERO);
|
||||
let originals = members(&self.document, definition);
|
||||
let copies: Vec<EntityType> = originals
|
||||
.iter()
|
||||
.filter_map(|handle| self.document.get_entity(*handle).cloned())
|
||||
.collect();
|
||||
self.define_block_raw(&name, base, copies);
|
||||
let record = self.document.block_records.get_mut(&name)?;
|
||||
record.flags.anonymous = true;
|
||||
let copy = record.handle;
|
||||
// The copies keep their element ids, now naming themselves.
|
||||
for handle in members(&self.document, copy) {
|
||||
if let Some(id) = self.document.get_entity(handle).and_then(element_id) {
|
||||
set_entity_tag(&mut self.document, handle, element_tag(id, handle));
|
||||
}
|
||||
}
|
||||
let mut tag = ExtendedDataRecord::new(REPRESENTATION_TAG);
|
||||
tag.add_value(XDataValue::Integer16(1));
|
||||
tag.add_value(XDataValue::Handle(definition));
|
||||
self.document.set_object_xdata_record(copy, tag);
|
||||
self.document.ensure_extension_dictionary(copy);
|
||||
|
||||
// The reference: AcDbBlockRepresentation / AcDbRepData names the
|
||||
// definition it was evaluated from.
|
||||
let holder = self.document.ensure_extension_dictionary(insert);
|
||||
let representation = self.document.allocate_handle();
|
||||
let data = self.document.allocate_handle();
|
||||
let mut dictionary = codec::objects::Dictionary::new();
|
||||
dictionary.handle = representation;
|
||||
dictionary.owner = holder;
|
||||
dictionary.reactors.push(holder);
|
||||
dictionary.add_entry("AcDbRepData", data);
|
||||
self.document
|
||||
.objects
|
||||
.insert(representation, ObjectType::Dictionary(dictionary));
|
||||
if let Some(ObjectType::Dictionary(holder)) = self.document.objects.get_mut(&holder) {
|
||||
holder.add_entry("AcDbBlockRepresentation", representation);
|
||||
}
|
||||
let mut link = dynamic(
|
||||
data,
|
||||
representation,
|
||||
"ACDB_BLOCKREPRESENTATION_DATA",
|
||||
"AcDbBlockRepresentationData",
|
||||
DynamicBlockData::Representation(BlockRepresentationData {
|
||||
flags: 1,
|
||||
block: definition,
|
||||
}),
|
||||
);
|
||||
link.reactors.push(representation);
|
||||
self.document
|
||||
.objects
|
||||
.insert(data, ObjectType::DynamicBlock(link));
|
||||
self.document.block_representations.insert(data, definition);
|
||||
if let Some(EntityType::Insert(reference)) = self.document.get_entity_mut(insert) {
|
||||
reference.block_name = name.clone();
|
||||
}
|
||||
for record in self.document.block_records.iter_mut() {
|
||||
if record.handle == definition {
|
||||
record.insert_handles.retain(|handle| *handle != insert);
|
||||
} else if record.handle == copy {
|
||||
record.insert_handles.push(insert);
|
||||
}
|
||||
}
|
||||
self.bump_geometry();
|
||||
Some(name)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use codec::entities::{Circle, Insert, Line};
|
||||
|
||||
/// A block given visibility states round-trips them, and a reference
|
||||
/// given its own copy still finds the definition and its states.
|
||||
#[test]
|
||||
fn states_and_a_reference_copy_survive_dwg_and_dxf() {
|
||||
let mut scene = Scene::new();
|
||||
scene.define_block_raw(
|
||||
"door",
|
||||
Vector3::ZERO,
|
||||
vec![
|
||||
EntityType::Line(Line::from_coords(0.0, 0.0, 0.0, 1.0, 0.0, 0.0)),
|
||||
EntityType::Circle(Circle::from_center_radius(Vector3::new(0.5, 0.5, 0.0), 0.25)),
|
||||
],
|
||||
);
|
||||
let definition = scene.document.block_records.get("door").unwrap().handle;
|
||||
let parameter = create(&mut scene.document, definition, Vector3::ZERO).unwrap();
|
||||
let open = add_state(&mut scene.document, parameter, "Open", 0).unwrap();
|
||||
let circle = members(&scene.document, definition)[1];
|
||||
set_visible(&mut scene.document, parameter, open, &[circle], false);
|
||||
finish_editing(&mut scene.document, definition);
|
||||
|
||||
let insert = scene.add_entity(EntityType::Insert(Insert::new("door", Vector3::ZERO)));
|
||||
let copy = scene.visibility_representation(insert).expect("a copy");
|
||||
assert!(copy.starts_with("*U"));
|
||||
|
||||
for ext in ["dwg", "dxf"] {
|
||||
let bytes = crate::io::save_to_bytes(&scene.document, ext, scene.document.version)
|
||||
.unwrap_or_else(|error| panic!("save {ext}: {error}"));
|
||||
let read = crate::io::load_bytes(&format!("door.{ext}"), bytes)
|
||||
.unwrap_or_else(|error| panic!("load {ext}: {error}"));
|
||||
let insert = read
|
||||
.entities()
|
||||
.find_map(|entity| match entity {
|
||||
EntityType::Insert(insert) if insert.block_name.starts_with("*U") => {
|
||||
Some(insert.common.handle)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_else(|| panic!("{ext}: reference to the copy"));
|
||||
let (found, parameter) = read
|
||||
.dynamic_visibility_for_insert(insert)
|
||||
.unwrap_or_else(|| panic!("{ext}: no visibility parameter"));
|
||||
let record = read.block_records.iter().find(|r| r.handle == found).unwrap();
|
||||
assert_eq!(record.name, "door", "{ext}");
|
||||
let names: Vec<&str> = parameter.states.iter().map(|s| s.name.as_str()).collect();
|
||||
assert_eq!(names, ["VisibilityState0", "Open"], "{ext}");
|
||||
assert_eq!(parameter.states[1].visible_blocks.len(), 0, "{ext}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1004,6 +1004,7 @@ impl Scene {
|
||||
} else {
|
||||
self.paper_sheet_wires_arc()
|
||||
};
|
||||
let wires = crate::scene::model::instance_model::expand_stubs(&wires);
|
||||
for wire in wires.iter() {
|
||||
let Some(handle) = Self::handle_from_wire_name(&wire.name) else {
|
||||
continue;
|
||||
|
||||
Vendored
+243
-18
@@ -178,6 +178,7 @@ pub struct BlockCache {
|
||||
HashMap<ExpansionPrototypeKey, Arc<std::sync::Mutex<Option<Arc<CachedExpansion>>>>>,
|
||||
>,
|
||||
clip_prototypes: std::sync::Mutex<HashMap<(u64, Vec<u64>), u64>>,
|
||||
cycle_warnings: std::sync::Mutex<HashSet<String>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||
@@ -193,7 +194,10 @@ struct ExpansionPrototypeKey {
|
||||
#[derive(Debug)]
|
||||
struct CachedExpansion {
|
||||
translation: [f64; 3],
|
||||
/// The wires every further reference repeats one by one.
|
||||
wires: Arc<Vec<WireModel>>,
|
||||
/// The part a further reference carries as one stub wire, and its XY box.
|
||||
stubbed: Option<(Arc<Vec<WireModel>>, [f32; 4])>,
|
||||
}
|
||||
|
||||
impl BlockCache {
|
||||
@@ -219,6 +223,22 @@ impl BlockCache {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn record_cycle_warning(&self, block_name: &str) -> bool {
|
||||
let mut warnings = self
|
||||
.cycle_warnings
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
warnings.insert(block_name.to_ascii_uppercase())
|
||||
}
|
||||
|
||||
pub fn drain_cycle_warnings(&self) -> Vec<String> {
|
||||
let mut warnings = self
|
||||
.cycle_warnings
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
warnings.drain().collect()
|
||||
}
|
||||
|
||||
pub fn defn(&self, block_name: &str) -> Option<&Arc<BlockDefn>> {
|
||||
self.defns.get(block_name).or_else(|| {
|
||||
self.defns
|
||||
@@ -1038,7 +1058,20 @@ pub fn expand_insert(
|
||||
scale_policy,
|
||||
);
|
||||
let name = ins_handle.value().to_string();
|
||||
let prototype_key = if !ins.is_array() && cache.prototype_blocks.contains(&ins.block_name) {
|
||||
// Mask-bearing blocks must not reuse translated GPU prototypes: ordinary
|
||||
// strokes and wipeout fills have different upload paths. Their shared
|
||||
// source_id plus per-copy translated geometry can make a moved INSERT
|
||||
// display stale wipeout geometry or disappear as the view changes.
|
||||
// Expand these from their definition until both GPU paths share a
|
||||
// verified identical instancing convention.
|
||||
let has_masks = defn.subs.iter().any(|sub| match sub {
|
||||
LocalSub::Wire(wire) => wire.canvas_color,
|
||||
LocalSub::Nested(_) => false,
|
||||
});
|
||||
let prototype_key = if !ins.is_array()
|
||||
&& !has_masks
|
||||
&& cache.prototype_blocks.contains(&ins.block_name)
|
||||
{
|
||||
Some(expansion_prototype_key(
|
||||
ins,
|
||||
&xform,
|
||||
@@ -1083,16 +1116,26 @@ pub fn expand_insert(
|
||||
translation[1] - cached.translation[1],
|
||||
translation[2] - cached.translation[2],
|
||||
];
|
||||
return Some(
|
||||
cached
|
||||
.wires
|
||||
.iter()
|
||||
.map(|wire| translated_prototype_wire(wire, &name, delta))
|
||||
.collect(),
|
||||
);
|
||||
let mut wires: Vec<WireModel> = cached
|
||||
.wires
|
||||
.iter()
|
||||
.map(|wire| translated_prototype_wire(wire, &name, delta))
|
||||
.collect();
|
||||
if let Some((prototype, aabb)) = &cached.stubbed {
|
||||
let mut stub = crate::scene::model::instance_model::stub_wire(
|
||||
Arc::clone(prototype),
|
||||
*aabb,
|
||||
delta,
|
||||
0.0,
|
||||
);
|
||||
stub.name = name.to_string();
|
||||
stub.selected = selected;
|
||||
wires.push(stub);
|
||||
}
|
||||
return Some(wires);
|
||||
}
|
||||
let mut batches = Batches::default();
|
||||
let mut visited: Vec<String> = Vec::with_capacity(8);
|
||||
let mut visited: Vec<String> = vec![ins.block_name.clone()];
|
||||
|
||||
let offsets = crate::scene::render_graph::array_offsets(ins);
|
||||
let cell_xform = |offset: &[f64; 3]| {
|
||||
@@ -1213,9 +1256,20 @@ pub fn expand_insert(
|
||||
});
|
||||
}
|
||||
}
|
||||
// Further references carry the block-drawn part as one stub instead
|
||||
// of a wire per primitive (#1698).
|
||||
let (stubbed, repeated): (Vec<_>, Vec<_>) = wires
|
||||
.iter()
|
||||
.cloned()
|
||||
.partition(crate::scene::model::instance_model::stubbable);
|
||||
let stubbed = (!stubbed.is_empty()).then(|| {
|
||||
let aabb = crate::scene::model::instance_model::prototype_aabb(&stubbed);
|
||||
(Arc::new(stubbed), aabb)
|
||||
});
|
||||
let cached = Arc::new(CachedExpansion {
|
||||
translation,
|
||||
wires: Arc::new(wires.clone()),
|
||||
wires: Arc::new(repeated),
|
||||
stubbed,
|
||||
});
|
||||
**guard = Some(cached);
|
||||
}
|
||||
@@ -1282,9 +1336,24 @@ fn expansion_prototype_key(
|
||||
}
|
||||
}
|
||||
|
||||
fn translated_prototype_wire(source: &WireModel, name: &str, delta: [f64; 3]) -> WireModel {
|
||||
pub(crate) fn translated_prototype_wire(
|
||||
source: &WireModel,
|
||||
name: &str,
|
||||
delta: [f64; 3],
|
||||
) -> WireModel {
|
||||
let mut wire = source.clone();
|
||||
wire.name = name.to_string();
|
||||
if let Some(stub) = &source.stub {
|
||||
wire.stub = Some(Arc::new(crate::scene::model::instance_model::InstanceStub {
|
||||
prototype: Arc::clone(&stub.prototype),
|
||||
delta: [
|
||||
stub.delta[0] + delta[0],
|
||||
stub.delta[1] + delta[1],
|
||||
stub.delta[2] + delta[2],
|
||||
],
|
||||
depth_delta: stub.depth_delta,
|
||||
}));
|
||||
}
|
||||
if let Some(instance) = wire.render_instance.as_mut() {
|
||||
for axis in 0..3 {
|
||||
instance.translation[axis] += delta[axis];
|
||||
@@ -1462,6 +1531,29 @@ pub(crate) fn fade_toward_bg(color: [f32; 4], bg: [f32; 4]) -> [f32; 4] {
|
||||
]
|
||||
}
|
||||
|
||||
/// XFADECTL: how far objects outside the REFEDIT working set fade toward the
|
||||
/// background, in percent (0–90, default 70).
|
||||
static REFEDIT_FADE: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(70);
|
||||
|
||||
pub fn refedit_fade_ctl() -> i32 {
|
||||
REFEDIT_FADE.load(std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub fn set_refedit_fade_ctl(value: i32) {
|
||||
REFEDIT_FADE.store(value.clamp(0, 90), std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// A colour outside the REFEDIT working set, faded by XFADECTL.
|
||||
pub(crate) fn refedit_fade(color: [f32; 4], bg: [f32; 4]) -> [f32; 4] {
|
||||
let t = refedit_fade_ctl().clamp(0, 90) as f32 / 100.0;
|
||||
[
|
||||
color[0] * (1.0 - t) + bg[0] * t,
|
||||
color[1] * (1.0 - t) + bg[1] * t,
|
||||
color[2] * (1.0 - t) + bg[2] * t,
|
||||
color[3],
|
||||
]
|
||||
}
|
||||
|
||||
/// XDWGFADECTL: how far referenced drawings fade toward the background, in
|
||||
/// percent (0–90); zero or negative shows them unfaded.
|
||||
static XREF_FADE: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(50);
|
||||
@@ -1593,7 +1685,10 @@ struct NestedPrototypeKey {
|
||||
struct NestedPrototype {
|
||||
translation: [f64; 3],
|
||||
depth_base: f32,
|
||||
/// The wires every further copy repeats one by one.
|
||||
wires: Arc<Vec<WireModel>>,
|
||||
/// The part a further copy carries as one stub wire, and its XY box.
|
||||
stubbed: Option<(Arc<Vec<WireModel>>, [f32; 4])>,
|
||||
}
|
||||
|
||||
/// Hard cap on point count for a single batched WireModel. Above this the
|
||||
@@ -1749,6 +1844,7 @@ impl Batches {
|
||||
fill_is_3d: b.fill_is_3d,
|
||||
fill_is_2d_solid: b.fill_is_2d_solid,
|
||||
render_instance: None,
|
||||
stub: None,
|
||||
pick_tris: b.pick_tris,
|
||||
pick_tris_low: b.pick_tris_low,
|
||||
dash_from_start: false,
|
||||
@@ -1939,7 +2035,16 @@ fn expand_defn(
|
||||
emit_wire(attachment, &attachment_transform, &inner_ctx, out, d_range);
|
||||
}
|
||||
}
|
||||
if visited.iter().any(|n| n == &nref.block_name) {
|
||||
if visited
|
||||
.iter()
|
||||
.any(|n| n.eq_ignore_ascii_case(&nref.block_name))
|
||||
{
|
||||
if ctx.cache.record_cycle_warning(&nref.block_name) {
|
||||
eprintln!(
|
||||
"Warning: circular block reference detected for block \"{}\"; cutting cycle",
|
||||
nref.block_name
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let Some(nested_defn) = ctx.cache.defn(&nref.block_name) else {
|
||||
@@ -2074,11 +2179,22 @@ fn expand_defn(
|
||||
let depth_delta = nested_range.0 - cached.depth_base;
|
||||
out.extra_wires.extend(cached.wires.iter().map(|wire| {
|
||||
let mut wire = translated_prototype_wire(wire, "", delta);
|
||||
if let Some(depth) = wire.depth_override.as_mut() {
|
||||
*depth += depth_delta;
|
||||
}
|
||||
crate::scene::model::instance_model::shift_depth(
|
||||
&mut wire,
|
||||
depth_delta,
|
||||
);
|
||||
wire
|
||||
}));
|
||||
if let Some((prototype, aabb)) = &cached.stubbed {
|
||||
out.extra_wires.push(
|
||||
crate::scene::model::instance_model::stub_wire(
|
||||
Arc::clone(prototype),
|
||||
*aabb,
|
||||
delta,
|
||||
depth_delta,
|
||||
),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
let mut sub = Batches::default();
|
||||
expand_defn(
|
||||
@@ -2106,12 +2222,23 @@ fn expand_defn(
|
||||
}
|
||||
}
|
||||
out.extra_wires.extend(wires.iter().cloned());
|
||||
// Further copies carry the block-drawn part as one
|
||||
// stub instead of a wire per primitive (#1698).
|
||||
let (stubbed, repeated): (Vec<_>, Vec<_>) = wires
|
||||
.into_iter()
|
||||
.partition(crate::scene::model::instance_model::stubbable);
|
||||
let stubbed = (!stubbed.is_empty()).then(|| {
|
||||
let aabb =
|
||||
crate::scene::model::instance_model::prototype_aabb(&stubbed);
|
||||
(Arc::new(stubbed), aabb)
|
||||
});
|
||||
out.nested_prototypes.insert(
|
||||
key,
|
||||
NestedPrototype {
|
||||
translation,
|
||||
depth_base: nested_range.0,
|
||||
wires: Arc::new(wires),
|
||||
wires: Arc::new(repeated),
|
||||
stubbed,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -2618,8 +2745,13 @@ fn emit_wire(
|
||||
// MTEXT background (a dimension's DIMTFILL box) left at the bare insert
|
||||
// level sat in front of its own text whenever that text's rank was
|
||||
// below zero.
|
||||
let local_depth = (lw.world_width > 0.0 || lw.is_fill_only || !lw.text_verts.is_empty())
|
||||
.then(|| d_range.0 + lw.local_rank * d_range.1);
|
||||
// Every block member must retain its block-local draw rank, including
|
||||
// ordinary thin LINE/LWPOLYLINE wires. Previously these wires had no
|
||||
// depth_override, so they were drawn at the INSERT's bare depth while
|
||||
// wipeout/hatch fills were positioned at the member rank. COPY and grip
|
||||
// moves rebuild instance batches and exposed this mismatch in model,
|
||||
// paper-space and PDF output.
|
||||
let local_depth = Some(d_range.0 + lw.local_rank * d_range.1);
|
||||
let plot_visible = ctx.plot_visible && lw.plot_visible && (!lw.plot_l0 || ctx.l0_plottable);
|
||||
|
||||
// Batched wires share one dash distance, which runs on across the NaN
|
||||
@@ -3466,4 +3598,97 @@ mod metrics_depth_tests {
|
||||
assert_ne!(root.metrics.aabb_local, [0.0; 4]);
|
||||
assert_eq!(root.metrics.aabb_local, tail.metrics.aabb_local);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locked_cyclic_block_document_renders_without_hanging_and_warns_once() {
|
||||
// Document built in code with pre-existing cycle:
|
||||
// A contains B, B contains A
|
||||
let mut document = CadDocument::new();
|
||||
let blk_a = add_block(&mut document, "CYC_A");
|
||||
let blk_b = add_block(&mut document, "CYC_B");
|
||||
|
||||
add_owned(
|
||||
&mut document,
|
||||
blk_a,
|
||||
EntityType::Insert(Insert::new("CYC_B", Vector3::ZERO)),
|
||||
);
|
||||
add_owned(
|
||||
&mut document,
|
||||
blk_b,
|
||||
EntityType::Insert(Insert::new("CYC_A", Vector3::ZERO)),
|
||||
);
|
||||
// Add a line in A so there is some actual geometry
|
||||
add_owned(
|
||||
&mut document,
|
||||
blk_a,
|
||||
EntityType::Line(Line::from_points(
|
||||
Vector3::ZERO,
|
||||
Vector3::new(10.0, 0.0, 0.0),
|
||||
)),
|
||||
);
|
||||
|
||||
// Model space references CYC_A
|
||||
document
|
||||
.add_entity(EntityType::Insert(Insert::new("CYC_A", Vector3::ZERO)))
|
||||
.unwrap();
|
||||
|
||||
// Building the cache computes defns and metrics without hanging
|
||||
let cache = BlockCache::build(
|
||||
&document,
|
||||
1.0,
|
||||
None,
|
||||
true,
|
||||
[0.0, 0.0, 0.0, 1.0],
|
||||
None,
|
||||
&HashMap::default(),
|
||||
);
|
||||
|
||||
// Verify defns exist
|
||||
assert!(cache.defns.contains_key("CYC_A"));
|
||||
assert!(cache.defns.contains_key("CYC_B"));
|
||||
|
||||
// Expanding / tessellating into wires does not hang and cuts the cycle
|
||||
let ins = Insert::new("CYC_A", Vector3::ZERO);
|
||||
let inherit_style = crate::scene::view::render::InheritStyle {
|
||||
color: [1.0, 1.0, 1.0, 1.0],
|
||||
pat_len: 1.0,
|
||||
pat: [0.0; 8],
|
||||
lw_px: 1.0,
|
||||
};
|
||||
|
||||
let result = expand_insert(
|
||||
&document,
|
||||
&cache,
|
||||
&ins,
|
||||
Handle::new(1),
|
||||
[1.0, 1.0, 1.0, 1.0],
|
||||
7,
|
||||
1.0,
|
||||
[0.0; 8],
|
||||
1.0,
|
||||
inherit_style,
|
||||
7,
|
||||
true,
|
||||
false,
|
||||
1.0,
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
[0.0, 0.0, 0.0, 1.0],
|
||||
1.0,
|
||||
crate::scene::BlockScalePolicy::FromInsert,
|
||||
false,
|
||||
);
|
||||
|
||||
assert!(result.is_some());
|
||||
|
||||
// Warning was recorded once
|
||||
let warnings = cache.drain_cycle_warnings();
|
||||
assert_eq!(warnings.len(), 1);
|
||||
assert_eq!(warnings[0], "CYC_A");
|
||||
|
||||
// Subsequent expansion does not warn again because warned set retains it
|
||||
let warnings_after = cache.drain_cycle_warnings();
|
||||
assert!(warnings_after.is_empty());
|
||||
}
|
||||
}
|
||||
+248
-2
@@ -214,8 +214,80 @@ impl Scene {
|
||||
|
||||
/// Restore camera to a named view from the document view table.
|
||||
pub fn restore_named_view(&mut self, view: &codec::tables::View) {
|
||||
use glam::Vec3;
|
||||
let cam = &mut *self.camera.borrow_mut();
|
||||
let before = {
|
||||
let cam = self.camera.borrow();
|
||||
(cam.rotation, cam.target, cam.distance)
|
||||
};
|
||||
self.restore_named_view_camera(view);
|
||||
let unchanged = {
|
||||
let cam = self.camera.borrow();
|
||||
cam.rotation.abs_diff_eq(before.0, 1e-6)
|
||||
&& cam.target.abs_diff_eq(before.1, 1e-6 * before.1.length().max(1.0))
|
||||
&& (cam.distance - before.2).abs() <= 1e-6 * before.2.abs().max(1.0)
|
||||
};
|
||||
// The model viewport takes a copy of the view's background; a view
|
||||
// without one clears it, unless the view is what the viewport already
|
||||
// shows (the reference leaves the viewport as it is then).
|
||||
if self.current_layout != "Model" && self.active_viewport.is_none() && view.paper_space {
|
||||
self.set_sheet_background(view);
|
||||
}
|
||||
if self.current_layout == "Model" {
|
||||
if view.background_handle.is_valid() || !unchanged {
|
||||
let background = copy_background(&mut self.document, view.background_handle);
|
||||
self.set_active_background(background);
|
||||
}
|
||||
if view.visual_style_handle.is_valid() {
|
||||
if let Some(handle) = self.active_tile_vport_handle() {
|
||||
if let Some(value) = self.document.vports.iter_mut().find(|v| v.handle == handle) {
|
||||
value.visual_style_handle = view.visual_style_handle;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn restore_named_view_camera(&mut self, view: &codec::tables::View) {
|
||||
named_view_camera(view, &mut self.camera.borrow_mut());
|
||||
self.camera_generation += 1;
|
||||
}
|
||||
|
||||
/// Restores a model-space view into a paper-space viewport: its camera,
|
||||
/// a copy of the view's background (or none) and the view's visual style.
|
||||
pub fn restore_named_view_in_viewport(&mut self, view: &codec::tables::View, viewport: Handle) {
|
||||
let mut camera = self.navigation_camera();
|
||||
named_view_camera(view, &mut camera);
|
||||
self.apply_navigation_camera(camera);
|
||||
let background = copy_background(&mut self.document, view.background_handle);
|
||||
let mut old = Handle::NULL;
|
||||
if let Some(EntityType::Viewport(value)) = self.document.get_entity_mut(viewport) {
|
||||
old = std::mem::replace(&mut value.background_handle, background);
|
||||
if view.visual_style_handle.is_valid() {
|
||||
value.visual_style_handle = view.visual_style_handle;
|
||||
}
|
||||
}
|
||||
erase_background(&mut self.document, old);
|
||||
self.notify_viewport_changed(viewport);
|
||||
self.camera_generation += 1;
|
||||
}
|
||||
|
||||
/// The paper-space viewport of the current layout takes a copy of a
|
||||
/// paper-space view's background (kept with the layout; the sheet itself
|
||||
/// is not drawn over).
|
||||
fn set_sheet_background(&mut self, view: &codec::tables::View) {
|
||||
let sheet = self.current_layout_sheet_viewport_handle();
|
||||
let background = copy_background(&mut self.document, view.background_handle);
|
||||
let old = match self.document.get_entity_mut(sheet) {
|
||||
Some(EntityType::Viewport(value)) => std::mem::replace(&mut value.background_handle, background),
|
||||
_ => background,
|
||||
};
|
||||
erase_background(&mut self.document, old);
|
||||
}
|
||||
}
|
||||
|
||||
/// Points a camera along a named view: direction, target and height.
|
||||
fn named_view_camera(view: &codec::tables::View, cam: &mut view::camera::Camera) {
|
||||
use glam::Vec3;
|
||||
{
|
||||
// The stored direction points from the target toward the eye.
|
||||
let eye_dir = Vec3::new(
|
||||
view.direction.x as f32,
|
||||
@@ -242,7 +314,71 @@ impl Scene {
|
||||
} else {
|
||||
cam.distance
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
impl Scene {
|
||||
/// The background the active model-space viewport shows (null for the
|
||||
/// drawing area colour).
|
||||
pub fn active_background(&self) -> Handle {
|
||||
if self.current_layout != "Model" {
|
||||
// Inside a viewport, that viewport's; the sheet's is not saved
|
||||
// with a paper-space view.
|
||||
return match self.active_viewport.and_then(|handle| self.document.get_entity(handle)) {
|
||||
Some(EntityType::Viewport(value)) => value.background_handle,
|
||||
_ => Handle::NULL,
|
||||
};
|
||||
}
|
||||
self.active_tile_vport_handle()
|
||||
.and_then(|handle| self.document.vports.iter().find(|value| value.handle == handle))
|
||||
.map_or(Handle::NULL, |value| value.background_handle)
|
||||
}
|
||||
|
||||
/// The visual style of the active Model tile's viewport record.
|
||||
pub fn active_visual_style(&self) -> Handle {
|
||||
if self.current_layout != "Model" {
|
||||
return match self.active_viewport.and_then(|handle| self.document.get_entity(handle)) {
|
||||
Some(EntityType::Viewport(value)) => value.visual_style_handle,
|
||||
_ => Handle::NULL,
|
||||
};
|
||||
}
|
||||
self.active_tile_vport_handle()
|
||||
.and_then(|handle| self.document.vports.iter().find(|value| value.handle == handle))
|
||||
.map_or(Handle::NULL, |value| value.visual_style_handle)
|
||||
}
|
||||
|
||||
/// Records the visual style named `name` on the active Model tile's
|
||||
/// viewport record, when the drawing has it.
|
||||
pub fn set_active_visual_style_named(&mut self, name: &str) {
|
||||
let style = self.document.objects.iter().find_map(|(handle, object)| match object {
|
||||
codec::objects::ObjectType::VisualStyle(style) if style.description == name => Some(*handle),
|
||||
_ => None,
|
||||
});
|
||||
let Some(style) = style else { return };
|
||||
if self.current_layout != "Model" {
|
||||
if let Some(handle) = self.active_viewport {
|
||||
if let Some(EntityType::Viewport(value)) = self.document.get_entity_mut(handle) {
|
||||
value.visual_style_handle = style;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
let Some(handle) = self.active_tile_vport_handle() else { return };
|
||||
if let Some(value) = self.document.vports.iter_mut().find(|value| value.handle == handle) {
|
||||
value.visual_style_handle = style;
|
||||
}
|
||||
}
|
||||
|
||||
fn set_active_background(&mut self, background: Handle) {
|
||||
let Some(handle) = self.active_tile_vport_handle() else { return };
|
||||
let Some(value) = self.document.vports.iter_mut().find(|value| value.handle == handle) else {
|
||||
return;
|
||||
};
|
||||
let old = std::mem::replace(&mut value.background_handle, background);
|
||||
self.camera_generation += 1;
|
||||
if old != background {
|
||||
erase_background(&mut self.document, old);
|
||||
}
|
||||
}
|
||||
|
||||
/// Save the current camera state into a new named view entry.
|
||||
@@ -1242,7 +1378,20 @@ impl Scene {
|
||||
// Every paper layout has a full-screen sheet viewport that holds
|
||||
// its view; create one if a loaded file lacks it.
|
||||
let layout = self.current_layout.clone();
|
||||
// A layout opening for the first time, as the reference tells
|
||||
// it: its page setup was never initialized (a file's layout
|
||||
// with no paper), or it was created in this session and not
|
||||
// opened yet. Its page setup is initialized and, with
|
||||
// LAYOUTCREATEVIEWPORT, it gets its first viewport.
|
||||
let unopened = self.unopened_layouts.remove(&layout);
|
||||
let initialized = self.initialize_layout_page_setup(&layout);
|
||||
if unopened && !initialized {
|
||||
self.fit_layout_sheet(&layout);
|
||||
}
|
||||
self.ensure_sheet_viewport(&layout);
|
||||
if unopened || initialized {
|
||||
self.add_layout_default_viewport(&layout);
|
||||
}
|
||||
self.apply_sheet_viewport_camera()
|
||||
};
|
||||
if restored {
|
||||
@@ -1638,3 +1787,100 @@ impl Scene {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The drawing's ACAD_BACKGROUND dictionary, made when it has none.
|
||||
fn background_dictionary(document: &mut codec::CadDocument) -> Option<Handle> {
|
||||
use codec::objects::{Dictionary, ObjectType};
|
||||
let root = document.header.named_objects_dict_handle;
|
||||
let Some(ObjectType::Dictionary(root_dictionary)) = document.objects.get(&root) else {
|
||||
return None;
|
||||
};
|
||||
if let Some(existing) = root_dictionary.get("ACAD_BACKGROUND") {
|
||||
if matches!(document.objects.get(&existing), Some(ObjectType::Dictionary(_))) {
|
||||
return Some(existing);
|
||||
}
|
||||
}
|
||||
let handle = document.allocate_handle();
|
||||
let mut dictionary = Dictionary::new();
|
||||
dictionary.handle = handle;
|
||||
dictionary.owner = root;
|
||||
dictionary.reactors.push(root);
|
||||
document.objects.insert(handle, ObjectType::Dictionary(dictionary));
|
||||
if let Some(ObjectType::Dictionary(root_dictionary)) = document.objects.get_mut(&root) {
|
||||
root_dictionary.add_entry("ACAD_BACKGROUND", handle);
|
||||
}
|
||||
Some(handle)
|
||||
}
|
||||
|
||||
/// A new background object in ACAD_BACKGROUND, keyed by its own handle as
|
||||
/// the reference keys it. Null when the drawing has no named-objects root.
|
||||
pub fn add_background(
|
||||
document: &mut codec::CadDocument,
|
||||
data: codec::objects::ClassObjectData,
|
||||
) -> Handle {
|
||||
use codec::objects::{ClassObject, ObjectType};
|
||||
let Some(dictionary) = background_dictionary(document) else {
|
||||
return Handle::NULL;
|
||||
};
|
||||
let handle = document.allocate_handle();
|
||||
let mut object = ClassObject::new(data);
|
||||
object.handle = handle;
|
||||
object.owner = dictionary;
|
||||
object.reactors = vec![dictionary];
|
||||
document.objects.insert(handle, ObjectType::ClassObject(object));
|
||||
if let Some(ObjectType::Dictionary(entries)) = document.objects.get_mut(&dictionary) {
|
||||
entries.add_entry(format!("{:X}", handle.value()), handle);
|
||||
}
|
||||
handle
|
||||
}
|
||||
|
||||
/// Erases a background object once no view or viewport refers to it (the
|
||||
/// reference erases a view's background when it is replaced, cleared or the
|
||||
/// view deleted, and a viewport's when the viewport takes another).
|
||||
pub fn erase_background(document: &mut codec::CadDocument, handle: Handle) {
|
||||
use codec::objects::{ClassObjectData as D, ObjectType};
|
||||
if !handle.is_valid()
|
||||
|| document.views.iter().any(|view| view.background_handle == handle)
|
||||
|| document.vports.iter().any(|vport| vport.background_handle == handle)
|
||||
|| document.entities().any(|entity| {
|
||||
matches!(entity, codec::EntityType::Viewport(viewport) if viewport.background_handle == handle)
|
||||
})
|
||||
{
|
||||
return;
|
||||
}
|
||||
let owner = match document.objects.get(&handle) {
|
||||
Some(ObjectType::ClassObject(object))
|
||||
if matches!(
|
||||
object.data,
|
||||
D::SolidBackground(_) | D::GradientBackground(_) | D::ImageBackground(_)
|
||||
| D::GroundPlaneBackground(_) | D::SkyLightBackground(_) | D::IblBackground(_)
|
||||
) =>
|
||||
{
|
||||
object.owner
|
||||
}
|
||||
_ => return,
|
||||
};
|
||||
document.objects.remove(&handle);
|
||||
if let Some(ObjectType::Dictionary(entries)) = document.objects.get_mut(&owner) {
|
||||
entries.entries.retain(|(_, entry)| *entry != handle);
|
||||
}
|
||||
}
|
||||
|
||||
/// A copy of a background object (the reference gives each view and
|
||||
/// viewport its own); null for none.
|
||||
pub fn copy_background(document: &mut codec::CadDocument, handle: Handle) -> Handle {
|
||||
use codec::objects::{ClassObjectData as D, ObjectType};
|
||||
match document.objects.get(&handle) {
|
||||
Some(ObjectType::ClassObject(object))
|
||||
if matches!(
|
||||
object.data,
|
||||
D::SolidBackground(_) | D::GradientBackground(_) | D::ImageBackground(_)
|
||||
| D::GroundPlaneBackground(_) | D::SkyLightBackground(_) | D::IblBackground(_)
|
||||
) =>
|
||||
{
|
||||
let data = object.data.clone();
|
||||
add_background(document, data)
|
||||
}
|
||||
_ => Handle::NULL,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
//! COMPARE: what differs between this drawing and another one. Objects are
|
||||
//! matched by what they draw — type, layer and their tessellated geometry —
|
||||
//! so a copy that kept its geometry matches whatever its handle, and an
|
||||
//! object that moved or changed shows as removed in one place and added in
|
||||
//! the other.
|
||||
|
||||
use codec::{CadDocument, Handle};
|
||||
use glam::DVec3;
|
||||
use rustc_hash::FxHashMap;
|
||||
|
||||
use crate::scene::{Scene, WireModel};
|
||||
|
||||
/// The result of a comparison, ready to draw over the current drawing.
|
||||
pub struct Comparison {
|
||||
/// Objects only this drawing has.
|
||||
pub added: usize,
|
||||
/// Objects only the other drawing has.
|
||||
pub removed: usize,
|
||||
/// Their outlines: added in green, removed in red.
|
||||
pub overlay: Vec<WireModel>,
|
||||
/// Each difference's extent, in reading order, to step through.
|
||||
pub spots: Vec<(DVec3, DVec3)>,
|
||||
}
|
||||
|
||||
const ADDED: [f32; 4] = [0.25, 0.85, 0.35, 1.0];
|
||||
const REMOVED: [f32; 4] = [0.95, 0.3, 0.25, 1.0];
|
||||
|
||||
fn wires_by_entity(scene: &Scene) -> FxHashMap<Handle, Vec<WireModel>> {
|
||||
let mut map: FxHashMap<Handle, Vec<WireModel>> = FxHashMap::default();
|
||||
for wire in scene.entity_wires().iter() {
|
||||
if let Some(handle) = Scene::handle_from_wire_name(&wire.name) {
|
||||
map.entry(handle).or_default().push(wire.clone());
|
||||
}
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
fn point(wire: &WireModel, index: usize) -> DVec3 {
|
||||
let high = wire.points[index];
|
||||
let low = wire.points_low.get(index).copied().unwrap_or([0.0; 3]);
|
||||
DVec3::new(
|
||||
f64::from(high[0]) + f64::from(low[0]),
|
||||
f64::from(high[1]) + f64::from(low[1]),
|
||||
f64::from(high[2]) + f64::from(low[2]),
|
||||
)
|
||||
}
|
||||
|
||||
/// What an object draws, as a comparable key: its type, its layer and its
|
||||
/// outline points rounded to 1e-4 drawing units.
|
||||
fn signature(document: &CadDocument, handle: Handle, wires: &[WireModel]) -> String {
|
||||
let mut key = match document.get_entity(handle) {
|
||||
Some(entity) => format!(
|
||||
"{}|{}",
|
||||
crate::entities::names::ui_name(entity),
|
||||
entity.common().layer
|
||||
),
|
||||
None => String::new(),
|
||||
};
|
||||
for wire in wires {
|
||||
key.push('#');
|
||||
for index in 0..wire.points.len() {
|
||||
let p = point(wire, index);
|
||||
if !p.is_finite() {
|
||||
key.push('/');
|
||||
continue;
|
||||
}
|
||||
let q = (p * 1e4).round();
|
||||
key.push_str(&format!("{},{},{};", q.x, q.y, q.z));
|
||||
}
|
||||
}
|
||||
key
|
||||
}
|
||||
|
||||
fn extent(wires: &[WireModel]) -> Option<(DVec3, DVec3)> {
|
||||
let mut bounds: Option<(DVec3, DVec3)> = None;
|
||||
for wire in wires {
|
||||
for index in 0..wire.points.len() {
|
||||
let p = point(wire, index);
|
||||
if !p.is_finite() {
|
||||
continue;
|
||||
}
|
||||
bounds = Some(match bounds {
|
||||
None => (p, p),
|
||||
Some((min, max)) => (min.min(p), max.max(p)),
|
||||
});
|
||||
}
|
||||
}
|
||||
bounds
|
||||
}
|
||||
|
||||
/// Compare `current` with the drawing `other`, in the space `current`
|
||||
/// shows.
|
||||
pub fn compare(current: &Scene, other: CadDocument) -> Comparison {
|
||||
let mut other_scene = Scene::new();
|
||||
other_scene.document = other;
|
||||
other_scene.bump_geometry();
|
||||
let here = wires_by_entity(current);
|
||||
let there = wires_by_entity(&other_scene);
|
||||
|
||||
let mut unmatched: FxHashMap<String, Vec<Handle>> = FxHashMap::default();
|
||||
for (handle, wires) in &there {
|
||||
unmatched
|
||||
.entry(signature(&other_scene.document, *handle, wires))
|
||||
.or_default()
|
||||
.push(*handle);
|
||||
}
|
||||
let mut overlay = Vec::new();
|
||||
let mut spots = Vec::new();
|
||||
let mut added = 0;
|
||||
let mut mark = |wires: &[WireModel], color: [f32; 4]| {
|
||||
if let Some(bounds) = extent(wires) {
|
||||
spots.push(bounds);
|
||||
}
|
||||
overlay.extend(wires.iter().cloned().map(|mut wire| {
|
||||
wire.color = color;
|
||||
wire.selected = false;
|
||||
wire.line_weight_px = wire.line_weight_px.max(2.0);
|
||||
wire
|
||||
}));
|
||||
};
|
||||
for (handle, wires) in &here {
|
||||
let key = signature(¤t.document, *handle, wires);
|
||||
match unmatched.get_mut(&key).and_then(Vec::pop) {
|
||||
Some(_) => {}
|
||||
None => {
|
||||
added += 1;
|
||||
mark(wires, ADDED);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut removed = 0;
|
||||
for handle in unmatched.into_values().flatten() {
|
||||
if let Some(wires) = there.get(&handle) {
|
||||
removed += 1;
|
||||
mark(wires, REMOVED);
|
||||
}
|
||||
}
|
||||
// Reading order: top to bottom, then left to right.
|
||||
spots.sort_by(|a, b| {
|
||||
b.1.y
|
||||
.partial_cmp(&a.1.y)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
.then(a.0.x.partial_cmp(&b.0.x).unwrap_or(std::cmp::Ordering::Equal))
|
||||
});
|
||||
Comparison {
|
||||
added,
|
||||
removed,
|
||||
overlay,
|
||||
spots,
|
||||
}
|
||||
}
|
||||
@@ -1812,6 +1812,7 @@ pub fn tessellate(
|
||||
fill_is_3d: false,
|
||||
fill_is_2d_solid: false,
|
||||
render_instance: None,
|
||||
stub: None,
|
||||
pick_tris: Vec::new(),
|
||||
pick_tris_low: Vec::new(),
|
||||
dash_from_start: false,
|
||||
@@ -1977,6 +1978,7 @@ pub fn tessellate(
|
||||
fill_is_3d: false,
|
||||
fill_is_2d_solid: false,
|
||||
render_instance: None,
|
||||
stub: None,
|
||||
pick_tris: Vec::new(),
|
||||
pick_tris_low: Vec::new(),
|
||||
dash_from_start: false,
|
||||
|
||||
@@ -1200,8 +1200,18 @@ impl Allocator<'_> {
|
||||
},
|
||||
class_version: 0,
|
||||
enabled: true,
|
||||
// Point and curve addressing lives on constraint-node data.
|
||||
persistent_subent: AssocPersistentSubentId::default(),
|
||||
// Point and curve addressing lives on constraint-node data;
|
||||
// the dependency names the whole curve, as single-edge
|
||||
// subentities do in authored constraint groups. An empty id
|
||||
// (no class) crashes strict readers loading the drawing.
|
||||
// ponytail: polyline segments get the single-edge id too;
|
||||
// authored files address them with an index subentity (class
|
||||
// code 3) whose fields are not decoded yet.
|
||||
persistent_subent: AssocPersistentSubentId {
|
||||
class_name: "AcDbAssocSingleEdgePersSubentId".to_string(),
|
||||
class_code: 1,
|
||||
..Default::default()
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -2479,9 +2489,15 @@ fn detach_preserved_scope(
|
||||
Some(ObjectType::Associative(object)) => match &object.data {
|
||||
AssociativeData::ConstraintGroup(_) | AssociativeData::Network(_) => false,
|
||||
AssociativeData::Variable(variable) => variable.name.trim().is_empty(),
|
||||
// A surface action whose surface was erased goes with it.
|
||||
AssociativeData::Action(_) => action_surface(document, *handle)
|
||||
.is_none_or(|surface| document.get_entity(surface).is_some()),
|
||||
// A surface action whose surface was erased goes with it,
|
||||
// and an array action whose array reference was.
|
||||
AssociativeData::Action(_) => {
|
||||
action_surface(document, *handle)
|
||||
.is_none_or(|surface| document.get_entity(surface).is_some())
|
||||
&& !crate::scene::assoc_array::orphaned_array_action(
|
||||
document, *handle,
|
||||
)
|
||||
}
|
||||
_ => true,
|
||||
},
|
||||
_ => false,
|
||||
|
||||
+32
-3
@@ -1263,19 +1263,35 @@ impl Scene {
|
||||
|
||||
/// Replace the contents and base point of block `name`, defining it when
|
||||
/// absent. References keep pointing at it and show the new contents.
|
||||
/// Refuses and returns Err if the new contents lead back to `name` directly or transitively.
|
||||
pub fn redefine_block_raw(
|
||||
&mut self,
|
||||
name: &str,
|
||||
base_point: codec::types::Vector3,
|
||||
entities: Vec<EntityType>,
|
||||
) {
|
||||
) -> Result<(), String> {
|
||||
for entity in &entities {
|
||||
if let EntityType::Insert(ins) = entity {
|
||||
if crate::scene::block_service::would_create_cycle(
|
||||
&self.document,
|
||||
name,
|
||||
&ins.block_name,
|
||||
) {
|
||||
return Err(crate::scene::block_service::circular_dependency_error(
|
||||
name,
|
||||
&ins.block_name,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
let Some((br_handle, block_handle)) = self
|
||||
.document
|
||||
.block_records
|
||||
.get(name)
|
||||
.map(|br| (br.handle, br.block_entity_handle))
|
||||
else {
|
||||
return self.define_block_raw(name, base_point, entities);
|
||||
self.define_block_raw(name, base_point, entities);
|
||||
return Ok(());
|
||||
};
|
||||
let owned: Vec<Handle> = self
|
||||
.document
|
||||
@@ -1299,6 +1315,7 @@ impl Scene {
|
||||
let _ = self.document.add_entity(entity);
|
||||
}
|
||||
self.bump_geometry();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn synced_hatch_models(
|
||||
@@ -2228,7 +2245,14 @@ impl Scene {
|
||||
.colors
|
||||
.get(i)
|
||||
.and_then(|e| e.color.rgb())
|
||||
.map(|(r, g, b)| [r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, color[3]])
|
||||
.map(|(r, g, b)| {
|
||||
[
|
||||
r as f32 / 255.0,
|
||||
g as f32 / 255.0,
|
||||
b as f32 / 255.0,
|
||||
color[3],
|
||||
]
|
||||
})
|
||||
};
|
||||
gradient_color1 = stop(0);
|
||||
let color1 = gradient_color1.unwrap_or(color);
|
||||
@@ -3090,10 +3114,15 @@ impl Scene {
|
||||
// The name alone leaves the header pointing at the previous style, and
|
||||
// a DWG writes the current style by handle only.
|
||||
self.document.header.current_dimstyle_handle = iso;
|
||||
// The annotation multiplier depends on those units; Scene::new()
|
||||
// left it at the unitless 1, which drew annotative objects 1000x too
|
||||
// large until the first CANNOSCALE change shrank them. (#1674)
|
||||
self.sync_annotation_scale_from_header();
|
||||
for obj in self.document.objects.values_mut() {
|
||||
if let codec::objects::ObjectType::Layout(l) = obj {
|
||||
if l.name != "Model" {
|
||||
crate::scene::apply_default_page_setup(l, "");
|
||||
self.unopened_layouts.insert(l.name.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,10 @@ impl Scene {
|
||||
/// cached layout-name list is invalidated on insert.
|
||||
pub fn add_layout(&mut self, name: &str) -> codec::Result<Handle> {
|
||||
let handle = self.document.add_layout(name)?;
|
||||
// Opening it fits the sheet the document made for it (placed at the
|
||||
// origin) to its page and, with LAYOUTCREATEVIEWPORT, gives it its
|
||||
// first viewport; saved before that, it is written with none.
|
||||
self.unopened_layouts.insert(name.to_string());
|
||||
self.bump_layout_epoch();
|
||||
Ok(handle)
|
||||
}
|
||||
@@ -34,6 +38,9 @@ impl Scene {
|
||||
if let ObjectType::Layout(l) = obj {
|
||||
if l.name == old_name {
|
||||
l.name = new_name.to_string();
|
||||
if self.unopened_layouts.remove(old_name) {
|
||||
self.unopened_layouts.insert(new_name.to_string());
|
||||
}
|
||||
self.bump_layout_epoch();
|
||||
return;
|
||||
}
|
||||
|
||||
+732
-171
File diff suppressed because it is too large.
Load diff
@@ -252,7 +252,12 @@ impl ImageModel {
|
||||
Some(Self {
|
||||
render_instance: None,
|
||||
file_path: img.file_path.clone(),
|
||||
pixels: decoded.pixels,
|
||||
pixels: adjusted_image_pixels(
|
||||
&img.file_path,
|
||||
&decoded.pixels,
|
||||
img.brightness,
|
||||
img.contrast,
|
||||
),
|
||||
width: decoded.width,
|
||||
height: decoded.height,
|
||||
opacity,
|
||||
@@ -650,6 +655,78 @@ pub fn clear_image_cache() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Brightness and contrast (0–100, 50 = unchanged) as one lookup table per
|
||||
/// colour channel. Measured against the reference's display of a gradient:
|
||||
/// brightness is a power curve x^g with g = ((100 − B) / B)^0.72 (0 is black,
|
||||
/// 100 white); contrast bends lightness around one half the same way,
|
||||
/// 0.5 ± 0.5·|2x − 1|^h with h = ((100 − C) / C)^0.72 (0 is flat grey, 100 a
|
||||
/// hard step). Brightness is applied first. Fade is the model's opacity.
|
||||
pub(crate) fn brightness_contrast_lut(brightness: u8, contrast: u8) -> [u8; 256] {
|
||||
// ponytail: the 0.72 exponent is a least-squares fit of seven measured
|
||||
// levels (within about 10/255); refine with more reference samples.
|
||||
let curve = |v: u8| -> f64 {
|
||||
let v = v.min(100);
|
||||
(f64::from(100 - v) / f64::from(v.max(1))).powf(0.72)
|
||||
};
|
||||
let mut lut = [0u8; 256];
|
||||
for (i, out) in lut.iter_mut().enumerate() {
|
||||
let mut x = i as f64 / 255.0;
|
||||
x = match brightness.min(100) {
|
||||
0 => 0.0,
|
||||
100 => if i == 0 { 0.0 } else { 1.0 },
|
||||
b => x.powf(curve(b)),
|
||||
};
|
||||
x = match contrast.min(100) {
|
||||
0 => 0.5,
|
||||
100 => if x >= 0.5 { 1.0 } else { 0.0 },
|
||||
c => {
|
||||
let d = 2.0 * x - 1.0;
|
||||
0.5 + 0.5 * d.signum() * d.abs().powf(curve(c))
|
||||
}
|
||||
};
|
||||
*out = (x.clamp(0.0, 1.0) * 255.0).round() as u8;
|
||||
}
|
||||
lut
|
||||
}
|
||||
|
||||
/// The decoded raster with the entity's brightness and contrast applied,
|
||||
/// memoised per source and setting (the GPU keeps textures per pixel buffer).
|
||||
/// An entry holds its source buffer, so the address in its key cannot be
|
||||
/// handed to another decode (a reloaded file) while the entry lives.
|
||||
fn adjusted_image_pixels(
|
||||
path: &str,
|
||||
pixels: &Arc<Vec<u8>>,
|
||||
brightness: u8,
|
||||
contrast: u8,
|
||||
) -> Arc<Vec<u8>> {
|
||||
if brightness == 50 && contrast == 50 {
|
||||
return Arc::clone(pixels);
|
||||
}
|
||||
type Key = (String, usize, u8, u8);
|
||||
type Entry = (Arc<Vec<u8>>, Arc<Vec<u8>>);
|
||||
static CACHE: OnceLock<Mutex<HashMap<Key, Entry>>> = OnceLock::new();
|
||||
let key = (path.to_string(), Arc::as_ptr(pixels) as usize, brightness, contrast);
|
||||
let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
|
||||
if let Some((_, hit)) = cache.lock().unwrap_or_else(|p| p.into_inner()).get(&key) {
|
||||
return Arc::clone(hit);
|
||||
}
|
||||
let lut = brightness_contrast_lut(brightness, contrast);
|
||||
let mut out = pixels.as_ref().clone();
|
||||
for px in out.chunks_exact_mut(4) {
|
||||
for c in &mut px[..3] {
|
||||
*c = lut[*c as usize];
|
||||
}
|
||||
}
|
||||
let out = Arc::new(out);
|
||||
let mut cache = cache.lock().unwrap_or_else(|p| p.into_inner());
|
||||
// ponytail: cleared wholesale past 64 entries; an LRU if many images are adjusted.
|
||||
if cache.len() > 64 {
|
||||
cache.clear();
|
||||
}
|
||||
cache.insert(key, (Arc::clone(pixels), Arc::clone(&out)));
|
||||
out
|
||||
}
|
||||
|
||||
/// Resolve an image reference to decoded pixels, memoised per path. Handles a
|
||||
/// local file and an `http`/`https` URL (fetched synchronously natively; on
|
||||
/// the web the first resolve kicks a background fetch and returns `None` —
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::wire_model::WireModel;
|
||||
|
||||
static NEXT_SOURCE_ID: AtomicU64 = AtomicU64::new(1);
|
||||
|
||||
@@ -11,3 +14,127 @@ pub struct RenderInstance {
|
||||
pub fn next_source_id() -> u64 {
|
||||
NEXT_SOURCE_ID.fetch_add(1, Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Translated copies of one nested block expansion, kept as a single wire
|
||||
/// instead of one wire per copied primitive (#1698): the copy is `prototype`
|
||||
/// moved by `delta`, its draw depths shifted by `depth_delta`. Prototype
|
||||
/// wires are block-instanced (or stubs themselves), so the GPU draws them
|
||||
/// from the shared source geometry and CPU users expand them on demand.
|
||||
#[derive(Debug)]
|
||||
pub struct InstanceStub {
|
||||
pub prototype: Arc<Vec<WireModel>>,
|
||||
pub delta: [f64; 3],
|
||||
pub depth_delta: f32,
|
||||
}
|
||||
|
||||
/// Whether a prototype wire may be represented by a stub: one the block
|
||||
/// renderer draws from its source geometry alone (no glyphs, fills or
|
||||
/// markers of its own), or a stub already.
|
||||
pub fn stubbable(wire: &WireModel) -> bool {
|
||||
// Kill-switch: `OCS_NO_BLOCK_STUBS` keeps every copy as its own wires.
|
||||
static OFF: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||
if *OFF.get_or_init(|| std::env::var("OCS_NO_BLOCK_STUBS").is_ok()) {
|
||||
return false;
|
||||
}
|
||||
wire.stub.is_some()
|
||||
|| (wire.render_instance.is_some()
|
||||
&& wire.text_verts.is_empty()
|
||||
&& wire.fill_tris.is_empty()
|
||||
&& wire.pick_tris.is_empty()
|
||||
&& !wire.fill_is_3d
|
||||
&& wire.point_marker.is_none()
|
||||
&& wire.searchable_text.is_empty())
|
||||
}
|
||||
|
||||
/// The XY box a prototype's wires cover, before any copy's offset.
|
||||
pub fn prototype_aabb(prototype: &[WireModel]) -> [f32; 4] {
|
||||
prototype.iter().fold(
|
||||
[f32::INFINITY, f32::INFINITY, f32::NEG_INFINITY, f32::NEG_INFINITY],
|
||||
|acc, wire| {
|
||||
[
|
||||
acc[0].min(wire.aabb[0]),
|
||||
acc[1].min(wire.aabb[1]),
|
||||
acc[2].max(wire.aabb[2]),
|
||||
acc[3].max(wire.aabb[3]),
|
||||
]
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// A stub wire standing for `prototype` moved by `delta`.
|
||||
pub fn stub_wire(
|
||||
prototype: Arc<Vec<WireModel>>,
|
||||
prototype_aabb: [f32; 4],
|
||||
delta: [f64; 3],
|
||||
depth_delta: f32,
|
||||
) -> WireModel {
|
||||
let aabb = [
|
||||
(prototype_aabb[0] as f64 + delta[0]) as f32,
|
||||
(prototype_aabb[1] as f64 + delta[1]) as f32,
|
||||
(prototype_aabb[2] as f64 + delta[0]) as f32,
|
||||
(prototype_aabb[3] as f64 + delta[1]) as f32,
|
||||
];
|
||||
WireModel {
|
||||
aabb,
|
||||
stub: Some(Arc::new(InstanceStub {
|
||||
prototype,
|
||||
delta,
|
||||
depth_delta,
|
||||
})),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Every wire `wires` stands for, stubs expanded into translated copies
|
||||
/// named and selected like the stub that holds them.
|
||||
pub fn expand_stubs(wires: &[WireModel]) -> std::borrow::Cow<'_, [WireModel]> {
|
||||
if !wires.iter().any(|wire| wire.stub.is_some()) {
|
||||
return std::borrow::Cow::Borrowed(wires);
|
||||
}
|
||||
let mut out = Vec::with_capacity(wires.len());
|
||||
for wire in wires {
|
||||
if wire.stub.is_some() {
|
||||
expand_stub_into(wire, &mut out);
|
||||
} else {
|
||||
out.push(wire.clone());
|
||||
}
|
||||
}
|
||||
std::borrow::Cow::Owned(out)
|
||||
}
|
||||
|
||||
/// Append the translated copies a stub wire stands for.
|
||||
pub fn expand_stub_into(stub_wire: &WireModel, out: &mut Vec<WireModel>) {
|
||||
let Some(stub) = stub_wire.stub.as_ref() else {
|
||||
out.push(stub_wire.clone());
|
||||
return;
|
||||
};
|
||||
for wire in stub.prototype.iter() {
|
||||
let mut copy = crate::scene::cache::block_cache::translated_prototype_wire(
|
||||
wire,
|
||||
&stub_wire.name,
|
||||
stub.delta,
|
||||
);
|
||||
shift_depth(&mut copy, stub.depth_delta);
|
||||
copy.selected = stub_wire.selected;
|
||||
copy.display_visible &= stub_wire.display_visible;
|
||||
if copy.stub.is_some() {
|
||||
expand_stub_into(©, out);
|
||||
} else {
|
||||
out.push(copy);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Shift a block-local draw depth by `delta`, through a stub as well.
|
||||
pub fn shift_depth(wire: &mut WireModel, delta: f32) {
|
||||
if let Some(depth) = wire.depth_override.as_mut() {
|
||||
*depth += delta;
|
||||
}
|
||||
if let Some(stub) = wire.stub.as_mut() {
|
||||
*stub = Arc::new(InstanceStub {
|
||||
prototype: Arc::clone(&stub.prototype),
|
||||
delta: stub.delta,
|
||||
depth_delta: stub.depth_delta + delta,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -636,6 +636,13 @@ mod w2d {
|
||||
let size = r.i32()?;
|
||||
r.i = r.i.checked_add(size.max(0) as usize)?;
|
||||
}
|
||||
b'O' => {
|
||||
// Set origin: the absolute point later relative
|
||||
// coordinates continue from (plot-optimized streams
|
||||
// start each drawing with it).
|
||||
r.i += 1;
|
||||
at = [r.i32()? as f64, r.i32()? as f64];
|
||||
}
|
||||
b'V' => {
|
||||
visible = true;
|
||||
r.i += 1;
|
||||
|
||||
@@ -836,6 +836,9 @@ pub struct WireModel {
|
||||
pub fill_is_2d_solid: bool,
|
||||
/// Shared block-render source and this placement's translation.
|
||||
pub render_instance: Option<super::instance_model::RenderInstance>,
|
||||
/// Set on a wire that stands for a translated copy of a nested block
|
||||
/// expansion; its own geometry is empty (see `InstanceStub`).
|
||||
pub stub: Option<std::sync::Arc<super::instance_model::InstanceStub>>,
|
||||
}
|
||||
|
||||
impl WireModel {
|
||||
@@ -911,6 +914,7 @@ impl WireModel {
|
||||
fill_is_3d: false,
|
||||
fill_is_2d_solid: false,
|
||||
render_instance: None,
|
||||
stub: None,
|
||||
pick_tris: Vec::new(),
|
||||
pick_tris_low: Vec::new(),
|
||||
text_verts: Vec::new(),
|
||||
@@ -1448,6 +1452,7 @@ impl Default for WireModel {
|
||||
fill_is_3d: false,
|
||||
fill_is_2d_solid: false,
|
||||
render_instance: None,
|
||||
stub: None,
|
||||
pick_tris: Vec::new(),
|
||||
pick_tris_low: Vec::new(),
|
||||
}
|
||||
|
||||
+627
-4
@@ -313,6 +313,9 @@ impl Scene {
|
||||
| EntityType::Region(_)
|
||||
| EntityType::Body(_)
|
||||
| EntityType::Surface(_)
|
||||
| EntityType::Mesh(_)
|
||||
| EntityType::PolygonMesh(_)
|
||||
| EntityType::PolyfaceMesh(_)
|
||||
)
|
||||
})
|
||||
{
|
||||
@@ -578,6 +581,122 @@ impl Scene {
|
||||
}
|
||||
}
|
||||
|
||||
/// Deep-clone the extension-dictionary tree rooted at `src_root` (owned by
|
||||
/// the entity `src_owner`) and return the clone's root, wired for
|
||||
/// `new_owner`. In-drawing COPY parity: the copy gets its own tree, so
|
||||
/// edits on it never touch the source. Dictionary entries, owners,
|
||||
/// reactors, nested xdictionary links, and XRecord handle references that
|
||||
/// land inside the tree are remapped onto fresh handles; references
|
||||
/// leaving the tree are preserved verbatim. A cyclic tree terminates via
|
||||
/// the visited set and remaps consistently. Atomic: nothing enters
|
||||
/// `document.objects` unless the whole tree clones; a missing or dangling
|
||||
/// root returns `None` so the caller leaves the copy without an extension
|
||||
/// dictionary — never shared.
|
||||
pub(super) fn clone_xdict_tree(
|
||||
doc: &mut codec::CadDocument,
|
||||
src_owner: Handle,
|
||||
src_root: Handle,
|
||||
new_owner: Handle,
|
||||
) -> Option<Handle> {
|
||||
if src_root.is_null() {
|
||||
return None;
|
||||
}
|
||||
// Walk every object reachable through dictionary entries and
|
||||
// xdictionary links, in discovery order (root first).
|
||||
let mut order: Vec<Handle> = Vec::new();
|
||||
let mut index = rustc_hash::FxHashMap::default();
|
||||
let mut stack = vec![src_root];
|
||||
while let Some(h) = stack.pop() {
|
||||
if index.contains_key(&h) {
|
||||
continue;
|
||||
}
|
||||
let Some(obj) = doc.objects.get(&h) else {
|
||||
if h == src_root {
|
||||
return None;
|
||||
}
|
||||
continue;
|
||||
};
|
||||
index.insert(h, order.len());
|
||||
order.push(h);
|
||||
match obj {
|
||||
codec::objects::ObjectType::Dictionary(d) => {
|
||||
for (_, child) in &d.entries {
|
||||
stack.push(*child);
|
||||
}
|
||||
if let Some(xd) = d.xdictionary_handle {
|
||||
stack.push(xd);
|
||||
}
|
||||
}
|
||||
codec::objects::ObjectType::XRecord(x) => {
|
||||
if let Some(xd) = x.xdictionary_handle {
|
||||
stack.push(xd);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// Clone and remap onto fresh handles; insert only once the whole tree
|
||||
// is built so a mid-walk failure leaves the document untouched.
|
||||
let mut remap = rustc_hash::FxHashMap::default();
|
||||
for &h in &order {
|
||||
remap.insert(h, doc.allocate_handle());
|
||||
}
|
||||
let rewrite = |handle: Handle| {
|
||||
if handle == src_owner {
|
||||
new_owner
|
||||
} else {
|
||||
*remap.get(&handle).unwrap_or(&handle)
|
||||
}
|
||||
};
|
||||
let mut clones = Vec::with_capacity(order.len());
|
||||
for &h in &order {
|
||||
let new_h = remap[&h];
|
||||
let obj = doc.objects.get(&h)?;
|
||||
let mut obj = obj.clone();
|
||||
match &mut obj {
|
||||
codec::objects::ObjectType::Dictionary(d) => {
|
||||
d.handle = new_h;
|
||||
d.owner = rewrite(d.owner);
|
||||
for (_, child) in &mut d.entries {
|
||||
*child = rewrite(*child);
|
||||
}
|
||||
d.xdictionary_handle = d.xdictionary_handle.map(rewrite);
|
||||
for r in &mut d.reactors {
|
||||
*r = rewrite(*r);
|
||||
}
|
||||
}
|
||||
codec::objects::ObjectType::XRecord(x) => {
|
||||
x.handle = new_h;
|
||||
x.owner = rewrite(x.owner);
|
||||
x.xdictionary_handle = x.xdictionary_handle.map(rewrite);
|
||||
for r in &mut x.reactors {
|
||||
*r = rewrite(*r);
|
||||
}
|
||||
for entry in &mut x.entries {
|
||||
if let codec::objects::XRecordValue::Handle(h) = &mut entry.value {
|
||||
*h = rewrite(*h);
|
||||
}
|
||||
}
|
||||
for entry in &mut x.object_references {
|
||||
entry.handle = rewrite(entry.handle);
|
||||
}
|
||||
// The raw DWG stream describes the source's object bytes;
|
||||
// the clone regenerates its own from the remapped entries.
|
||||
x.raw_dwg_data = None;
|
||||
x.raw_dwg_handle_bits = 0;
|
||||
x.raw_dwg_version = None;
|
||||
x.preserve_object_reference_stream = false;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
clones.push((new_h, obj));
|
||||
}
|
||||
for (new_h, obj) in clones {
|
||||
doc.objects.insert(new_h, obj);
|
||||
}
|
||||
Some(remap[&src_root])
|
||||
}
|
||||
|
||||
/// Add a clone to the active space with fresh top-level and inline handles.
|
||||
/// Source owner handles are document-local and must not cross drawings.
|
||||
/// (#129, #934)
|
||||
@@ -586,6 +705,10 @@ impl Scene {
|
||||
let common = entity.common_mut();
|
||||
common.handle = Handle::NULL;
|
||||
common.owner_handle = Handle::NULL;
|
||||
// An extension dictionary is exclusively owned; the clone must not
|
||||
// claim the source's, or the copy dangles into a document that does
|
||||
// not own that object.
|
||||
common.xdictionary_handle = None;
|
||||
common.entity_mode = Some(
|
||||
if self.current_layout != "Model" && self.active_viewport.is_none() {
|
||||
1
|
||||
@@ -752,15 +875,35 @@ impl Scene {
|
||||
}
|
||||
Self::reset_clone_subhandles(&mut self.document, &mut entity);
|
||||
|
||||
// An annotative LEADER/MTEXT pair must receive a fresh extension dictionary.
|
||||
// Keeping this handle would make the copy share the source's context tree.
|
||||
if !annotation_scales.is_empty() {
|
||||
// An annotative entity's extension dictionary is a context tree
|
||||
// recreated fresh below; any other copy's tree is deep-cloned
|
||||
// after insertion, so the clone must never carry the source's
|
||||
// handle through add_entity.
|
||||
let src_xdict = if annotation_scales.is_empty() {
|
||||
entity.common_mut().xdictionary_handle.take()
|
||||
} else {
|
||||
entity.common_mut().xdictionary_handle = None;
|
||||
}
|
||||
None
|
||||
};
|
||||
|
||||
entity.common_mut().handle = Handle::NULL;
|
||||
let h = self.document.add_entity(entity).unwrap_or(Handle::NULL);
|
||||
if !h.is_null() {
|
||||
// Deep-clone the extension-dictionary tree so edits on the
|
||||
// copy never touch the source. A dangling or missing root
|
||||
// leaves the copy without one — never shared.
|
||||
if let Some(src_root) = src_xdict {
|
||||
if let Some(new_root) =
|
||||
Self::clone_xdict_tree(&mut self.document, src_handle, src_root, h)
|
||||
{
|
||||
if let Some(clone) = self.document.get_entity_mut(h) {
|
||||
clone.common_mut().xdictionary_handle = Some(new_root);
|
||||
}
|
||||
// The cloned tree lives in document.objects — outside
|
||||
// a pure-entity delta; keep undo on the full snapshot.
|
||||
self.poison_undo_recording();
|
||||
}
|
||||
}
|
||||
if !annotation_scales.is_empty() {
|
||||
for scale_handle in annotation_scales {
|
||||
crate::scene::annotative::create_annotation_context(
|
||||
@@ -2319,3 +2462,483 @@ mod define_transformed_block_xdict_tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod add_entity_clone_xdict_tests {
|
||||
use super::*;
|
||||
|
||||
/// A cross-document copy must not carry the source's extension dictionary:
|
||||
/// the target document does not own that object, so sharing it leaves a
|
||||
/// dangling reference. An extension dictionary is exclusively owned, and a
|
||||
/// second claimant fails file validation with
|
||||
/// "Extension dictionary <H> Cannot access".
|
||||
#[test]
|
||||
fn exported_clone_does_not_share_source_xdict() {
|
||||
let mut source = Scene::new();
|
||||
|
||||
let dict_handle = source.document.allocate_handle();
|
||||
let mut dict = codec::objects::Dictionary::new();
|
||||
dict.handle = dict_handle;
|
||||
source
|
||||
.document
|
||||
.objects
|
||||
.insert(dict_handle, codec::objects::ObjectType::Dictionary(dict));
|
||||
|
||||
let mut line = codec::entities::Line::new();
|
||||
line.common.xdictionary_handle = Some(dict_handle);
|
||||
let src_handle = source
|
||||
.document
|
||||
.add_entity(codec::EntityType::Line(line))
|
||||
.unwrap();
|
||||
|
||||
let cloned = source.document.get_entity(src_handle).unwrap().clone();
|
||||
let mut target = Scene::new();
|
||||
let new_handle = target.add_entity_clone(cloned);
|
||||
assert!(!new_handle.is_null(), "clone must be added");
|
||||
|
||||
// Source keeps its dictionary.
|
||||
match source.document.get_entity(src_handle) {
|
||||
Some(e) => assert_eq!(
|
||||
e.common().xdictionary_handle,
|
||||
Some(dict_handle),
|
||||
"the source must keep its own dictionary"
|
||||
),
|
||||
None => panic!("source entity missing"),
|
||||
}
|
||||
// The copy carries none.
|
||||
match target.document.get_entity(new_handle) {
|
||||
Some(e) => assert!(
|
||||
e.common().xdictionary_handle.is_none(),
|
||||
"exported clone must not carry an extension dictionary"
|
||||
),
|
||||
None => panic!("exported clone missing"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod copy_entities_xdict_tests {
|
||||
use super::*;
|
||||
use crate::command::EntityTransform;
|
||||
use codec::objects::{
|
||||
Dictionary, ObjectType, ProxyObjectReference, ProxyReferenceKind, XRecord, XRecordValue,
|
||||
};
|
||||
use glam::DVec3;
|
||||
|
||||
/// Line with an extension-dictionary tree:
|
||||
/// root { SUB -> sub { DEEP -> deep }, REC -> rec("CLONE_ME") };
|
||||
/// root owner and reactors both name the source entity.
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn scene_with_xdict_tree() -> (
|
||||
Scene,
|
||||
codec::Handle,
|
||||
codec::Handle,
|
||||
codec::Handle,
|
||||
codec::Handle,
|
||||
codec::Handle,
|
||||
) {
|
||||
let mut scene = Scene::new();
|
||||
let root_h = scene.document.allocate_handle();
|
||||
let sub_h = scene.document.allocate_handle();
|
||||
let deep_h = scene.document.allocate_handle();
|
||||
let rec_h = scene.document.allocate_handle();
|
||||
|
||||
let mut deep = XRecord::named("DEEP");
|
||||
deep.handle = deep_h;
|
||||
deep.owner = sub_h;
|
||||
deep.add_string(1, "DEEP_VALUE");
|
||||
let mut sub = Dictionary::new();
|
||||
sub.handle = sub_h;
|
||||
sub.owner = root_h;
|
||||
sub.add_entry("DEEP", deep_h);
|
||||
let mut rec = XRecord::named("REC");
|
||||
rec.handle = rec_h;
|
||||
rec.owner = root_h;
|
||||
rec.add_string(1, "CLONE_ME");
|
||||
let mut root = Dictionary::new();
|
||||
root.handle = root_h;
|
||||
root.add_entry("SUB", sub_h);
|
||||
root.add_entry("REC", rec_h);
|
||||
|
||||
scene
|
||||
.document
|
||||
.objects
|
||||
.insert(root_h, ObjectType::Dictionary(root));
|
||||
scene
|
||||
.document
|
||||
.objects
|
||||
.insert(sub_h, ObjectType::Dictionary(sub));
|
||||
scene
|
||||
.document
|
||||
.objects
|
||||
.insert(deep_h, ObjectType::XRecord(deep));
|
||||
scene
|
||||
.document
|
||||
.objects
|
||||
.insert(rec_h, ObjectType::XRecord(rec));
|
||||
|
||||
let mut line = codec::entities::Line::new();
|
||||
line.common.xdictionary_handle = Some(root_h);
|
||||
let src = scene
|
||||
.document
|
||||
.add_entity(codec::EntityType::Line(line))
|
||||
.expect("add source entity");
|
||||
if let Some(ObjectType::Dictionary(d)) = scene.document.objects.get_mut(&root_h) {
|
||||
d.owner = src;
|
||||
d.reactors.push(src);
|
||||
}
|
||||
(scene, src, root_h, sub_h, deep_h, rec_h)
|
||||
}
|
||||
|
||||
/// Every object reachable from `root` through dictionary entries and
|
||||
/// xdictionary links (cycle-safe).
|
||||
fn tree_handles(doc: &codec::CadDocument, root: codec::Handle) -> Vec<codec::Handle> {
|
||||
let mut out = Vec::new();
|
||||
let mut stack = vec![root];
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
while let Some(h) = stack.pop() {
|
||||
if !seen.insert(h) {
|
||||
continue;
|
||||
}
|
||||
out.push(h);
|
||||
match doc.objects.get(&h) {
|
||||
Some(ObjectType::Dictionary(d)) => {
|
||||
for (_, c) in &d.entries {
|
||||
stack.push(*c);
|
||||
}
|
||||
if let Some(xd) = d.xdictionary_handle {
|
||||
stack.push(xd);
|
||||
}
|
||||
}
|
||||
Some(ObjectType::XRecord(x)) => {
|
||||
if let Some(xd) = x.xdictionary_handle {
|
||||
stack.push(xd);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out.sort_unstable();
|
||||
out
|
||||
}
|
||||
|
||||
/// Byte-for-byte image of a tree for source-unchanged assertions.
|
||||
fn tree_image(
|
||||
doc: &codec::CadDocument,
|
||||
root: codec::Handle,
|
||||
) -> Vec<(codec::Handle, ObjectType)> {
|
||||
tree_handles(doc, root)
|
||||
.into_iter()
|
||||
.map(|h| (h, doc.objects.get(&h).expect("tree object").clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_deep_clones_extension_dictionary_tree() {
|
||||
let (mut scene, src, root, _sub, _deep, rec) = scene_with_xdict_tree();
|
||||
let before = tree_image(&scene.document, root);
|
||||
|
||||
let copies = scene.copy_entities(&[src], &EntityTransform::Translate(DVec3::ZERO));
|
||||
assert_eq!(copies.len(), 1);
|
||||
let copy = copies[0];
|
||||
assert!(!copy.is_null() && copy != src);
|
||||
|
||||
let copy_root = scene
|
||||
.document
|
||||
.get_entity(copy)
|
||||
.expect("copy entity")
|
||||
.common()
|
||||
.xdictionary_handle
|
||||
.expect("copy must own a deep-cloned extension dictionary");
|
||||
assert_ne!(copy_root, root, "copy must not claim the source's root");
|
||||
|
||||
// Structure mirrors the source shape on fresh handles.
|
||||
let copy_tree = tree_handles(&scene.document, copy_root);
|
||||
assert_eq!(
|
||||
copy_tree.len(),
|
||||
4,
|
||||
"cloned tree must mirror the source: {copy_tree:?}"
|
||||
);
|
||||
for h in ©_tree {
|
||||
assert!(
|
||||
!tree_handles(&scene.document, root).contains(h),
|
||||
"clone must not reuse source handles"
|
||||
);
|
||||
}
|
||||
|
||||
// Root wiring: owner and reactor rewritten to the copy.
|
||||
let ObjectType::Dictionary(copy_root_obj) = scene
|
||||
.document
|
||||
.objects
|
||||
.get(©_root)
|
||||
.expect("copy root object")
|
||||
else {
|
||||
panic!("copy root must be a dictionary");
|
||||
};
|
||||
assert_eq!(
|
||||
copy_root_obj.owner, copy,
|
||||
"cloned root must be owned by the copy"
|
||||
);
|
||||
assert_eq!(
|
||||
copy_root_obj.reactors,
|
||||
vec![copy],
|
||||
"reactor naming the source must be rewritten to the copy"
|
||||
);
|
||||
|
||||
// Children resolve by key with preserved content.
|
||||
let sub_h = copy_root_obj.get("SUB").expect("SUB entry on copy");
|
||||
let copy_rec = copy_root_obj.get("REC").expect("REC entry on copy");
|
||||
let ObjectType::Dictionary(sub_obj) =
|
||||
scene.document.objects.get(&sub_h).expect("copy sub object")
|
||||
else {
|
||||
panic!("copy SUB must be a dictionary");
|
||||
};
|
||||
assert_eq!(
|
||||
sub_obj.owner, copy_root,
|
||||
"nested owner must point at the cloned parent"
|
||||
);
|
||||
let deep_h = sub_obj.get("DEEP").expect("DEEP entry on copy");
|
||||
let ObjectType::XRecord(deep_obj) = scene
|
||||
.document
|
||||
.objects
|
||||
.get(&deep_h)
|
||||
.expect("copy deep object")
|
||||
else {
|
||||
panic!("copy DEEP must be an XRecord");
|
||||
};
|
||||
assert_eq!(deep_obj.name, "DEEP");
|
||||
let ObjectType::XRecord(copy_rec_obj) = scene
|
||||
.document
|
||||
.objects
|
||||
.get(©_rec)
|
||||
.expect("copy REC object")
|
||||
else {
|
||||
panic!("copy REC must be an XRecord");
|
||||
};
|
||||
assert_eq!(copy_rec_obj.name, "REC");
|
||||
assert!(
|
||||
copy_rec_obj
|
||||
.entries
|
||||
.iter()
|
||||
.any(|e| matches!(&e.value, XRecordValue::String(s) if s == "CLONE_ME")),
|
||||
"cloned XRecord must carry the string content"
|
||||
);
|
||||
|
||||
// Mutation independence: edit the copy, source stays untouched.
|
||||
if let Some(ObjectType::XRecord(x)) = scene.document.objects.get_mut(©_rec) {
|
||||
if let Some(e) = x
|
||||
.entries
|
||||
.iter_mut()
|
||||
.find(|e| matches!(&e.value, XRecordValue::String(s) if s == "CLONE_ME"))
|
||||
{
|
||||
e.value = XRecordValue::String("MUTATED".into());
|
||||
}
|
||||
}
|
||||
let ObjectType::XRecord(src_rec_obj) =
|
||||
scene.document.objects.get(&rec).expect("source REC object")
|
||||
else {
|
||||
panic!("source REC must be an XRecord");
|
||||
};
|
||||
assert!(
|
||||
src_rec_obj
|
||||
.entries
|
||||
.iter()
|
||||
.any(|e| matches!(&e.value, XRecordValue::String(s) if s == "CLONE_ME")),
|
||||
"source XRecord must be unaffected by edits on the copy"
|
||||
);
|
||||
|
||||
// Source tree byte-for-byte unchanged.
|
||||
let after = tree_image(&scene.document, root);
|
||||
assert_eq!(
|
||||
before, after,
|
||||
"source extension-dictionary tree must be byte-for-byte unchanged"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_without_xdict_adds_no_objects_and_keeps_delta_undo() {
|
||||
let mut scene = Scene::new();
|
||||
let line = codec::entities::Line::new();
|
||||
let src = scene
|
||||
.document
|
||||
.add_entity(codec::EntityType::Line(line))
|
||||
.expect("add source entity");
|
||||
let objects_before = scene.document.objects.len();
|
||||
|
||||
scene.begin_undo_recording();
|
||||
let copies = scene.copy_entities(&[src], &EntityTransform::Translate(DVec3::ZERO));
|
||||
let objects_after = scene.document.objects.len();
|
||||
|
||||
assert_eq!(copies.len(), 1);
|
||||
assert_ne!(copies[0], src);
|
||||
assert_eq!(
|
||||
objects_before, objects_after,
|
||||
"xdict-free copy must add no document.objects entries"
|
||||
);
|
||||
let recording = scene.take_undo_recording().expect("recording open");
|
||||
assert!(
|
||||
!recording.poisoned,
|
||||
"xdict-free copy must stay on the delta-undo path"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_with_dangling_xdict_root_leaves_copy_without_one() {
|
||||
let mut scene = Scene::new();
|
||||
let missing = scene.document.allocate_handle();
|
||||
let mut line = codec::entities::Line::new();
|
||||
line.common.xdictionary_handle = Some(missing);
|
||||
let src = scene
|
||||
.document
|
||||
.add_entity(codec::EntityType::Line(line))
|
||||
.expect("add source entity");
|
||||
let objects_before = scene.document.objects.len();
|
||||
|
||||
let copies = scene.copy_entities(&[src], &EntityTransform::Translate(DVec3::ZERO));
|
||||
|
||||
let copy_root = scene
|
||||
.document
|
||||
.get_entity(copies[0])
|
||||
.expect("copy entity")
|
||||
.common()
|
||||
.xdictionary_handle;
|
||||
assert!(
|
||||
copy_root.is_none(),
|
||||
"dangling root must leave the copy without an extension dictionary, never shared"
|
||||
);
|
||||
assert_eq!(
|
||||
objects_before,
|
||||
scene.document.objects.len(),
|
||||
"dangling root must not invent objects"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_cloned_xdict_cycle_terminates_and_remaps() {
|
||||
let (mut scene, src, root, ..) = scene_with_xdict_tree();
|
||||
if let Some(ObjectType::Dictionary(d)) = scene.document.objects.get_mut(&root) {
|
||||
d.add_entry("BACK", root);
|
||||
}
|
||||
|
||||
let copies = scene.copy_entities(&[src], &EntityTransform::Translate(DVec3::ZERO));
|
||||
let copy_root = scene
|
||||
.document
|
||||
.get_entity(copies[0])
|
||||
.expect("copy entity")
|
||||
.common()
|
||||
.xdictionary_handle
|
||||
.expect("copy dictionary");
|
||||
let ObjectType::Dictionary(d) = scene.document.objects.get(©_root).expect("dict")
|
||||
else {
|
||||
panic!("dictionary");
|
||||
};
|
||||
assert_eq!(
|
||||
d.get("BACK"),
|
||||
Some(copy_root),
|
||||
"cyclic entry must be remapped to the cloned root, consistently"
|
||||
);
|
||||
assert_eq!(
|
||||
tree_handles(&scene.document, copy_root).len(),
|
||||
4,
|
||||
"cycle must not duplicate the tree"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_keeps_references_outside_the_cloned_tree() {
|
||||
let (mut scene, src, root, ..) = scene_with_xdict_tree();
|
||||
let (_, rec) = {
|
||||
let ObjectType::Dictionary(d) = scene.document.objects.get(&root).expect("root") else {
|
||||
panic!("root must be a dictionary");
|
||||
};
|
||||
((), d.get("REC").expect("REC entry"))
|
||||
};
|
||||
// An entity recorded inside the tree (e.g. a field pointing back at a
|
||||
// drawing entity) stays pointed at the original; entities are never
|
||||
// part of the object-tree clone.
|
||||
let mut other_line = codec::entities::Line::new();
|
||||
other_line.common.handle = Handle::NULL;
|
||||
let other = scene
|
||||
.document
|
||||
.add_entity(codec::EntityType::Line(other_line))
|
||||
.expect("other entity");
|
||||
// An object outside the reachable closure, referenced only through
|
||||
// the XRecord handle stream, likewise stays pointed at the original.
|
||||
let outside_h = scene.document.allocate_handle();
|
||||
let mut outside = Dictionary::new();
|
||||
outside.handle = outside_h;
|
||||
scene
|
||||
.document
|
||||
.objects
|
||||
.insert(outside_h, ObjectType::Dictionary(outside));
|
||||
if let Some(ObjectType::XRecord(x)) = scene.document.objects.get_mut(&rec) {
|
||||
x.add_handle(340, other);
|
||||
x.object_references.push(ProxyObjectReference {
|
||||
handle: outside_h,
|
||||
kind: ProxyReferenceKind::SoftPointer,
|
||||
});
|
||||
}
|
||||
|
||||
let copies = scene.copy_entities(&[src], &EntityTransform::Translate(DVec3::ZERO));
|
||||
let copy_root = scene
|
||||
.document
|
||||
.get_entity(copies[0])
|
||||
.expect("copy entity")
|
||||
.common()
|
||||
.xdictionary_handle
|
||||
.expect("copy dictionary");
|
||||
let ObjectType::Dictionary(d) = scene.document.objects.get(©_root).expect("dict")
|
||||
else {
|
||||
panic!("dictionary");
|
||||
};
|
||||
let copy_rec = d.get("REC").expect("REC entry on copy");
|
||||
let ObjectType::XRecord(copy_rec_obj) =
|
||||
scene.document.objects.get(©_rec).expect("copy REC")
|
||||
else {
|
||||
panic!("copy REC must be an XRecord");
|
||||
};
|
||||
assert!(
|
||||
copy_rec_obj
|
||||
.entries
|
||||
.iter()
|
||||
.any(|e| matches!(&e.value, XRecordValue::Handle(h) if *h == other)),
|
||||
"entity references leaving the object tree must stay pointed at the original"
|
||||
);
|
||||
assert!(
|
||||
copy_rec_obj
|
||||
.object_references
|
||||
.iter()
|
||||
.any(|r| r.handle == outside_h),
|
||||
"handle-stream references leaving the source tree must stay pointed at the original"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_copy_gets_its_own_disjoint_tree() {
|
||||
let (mut scene, src, root, ..) = scene_with_xdict_tree();
|
||||
let source_tree = tree_handles(&scene.document, root);
|
||||
let mut all_trees = vec![source_tree];
|
||||
|
||||
for _ in 0..3 {
|
||||
let copies = scene.copy_entities(&[src], &EntityTransform::Translate(DVec3::ZERO));
|
||||
let copy_root = scene
|
||||
.document
|
||||
.get_entity(copies[0])
|
||||
.expect("copy entity")
|
||||
.common()
|
||||
.xdictionary_handle
|
||||
.expect("each copy must own a dictionary");
|
||||
all_trees.push(tree_handles(&scene.document, copy_root));
|
||||
}
|
||||
|
||||
for (i, a) in all_trees.iter().enumerate() {
|
||||
for (j, b) in all_trees.iter().enumerate() {
|
||||
if i == j {
|
||||
continue;
|
||||
}
|
||||
let overlap = a.iter().any(|h| b.contains(h));
|
||||
assert!(!overlap, "tree {i} and tree {j} must be disjoint");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2535,6 +2535,9 @@ impl super::Scene {
|
||||
}
|
||||
crate::io::xref::strip_resolved_xref_content(&mut document);
|
||||
crate::entities::field::stamp_save_dates(&mut document);
|
||||
for name in &self.unopened_layouts {
|
||||
crate::scene::write_layout_unopened(&mut document, name);
|
||||
}
|
||||
document
|
||||
}
|
||||
|
||||
|
||||
@@ -1646,6 +1646,78 @@ impl InteractionCandidates {
|
||||
.map(|(rect, (view, eye, bounds))| (rect, view, eye, bounds))
|
||||
}
|
||||
|
||||
/// The candidates with every block-copy stub among them expanded into
|
||||
/// the wires it stands for, so snapping and picking see each copy. Only
|
||||
/// the copies' wires near an XY query are kept.
|
||||
pub(crate) fn materialize_stubs(self) -> Self {
|
||||
if !self.iter().any(|wire| wire.stub.is_some()) {
|
||||
return self;
|
||||
}
|
||||
let near = |wire: &WireModel, query: [f64; 4]| {
|
||||
let [x0, y0, x1, y1] = wire.aabb.map(f64::from);
|
||||
!(x0.is_finite() && y0.is_finite() && x1.is_finite() && y1.is_finite())
|
||||
|| (x1 >= query[0] && x0 <= query[2] && y1 >= query[1] && y0 <= query[3])
|
||||
};
|
||||
let mut wires = Vec::new();
|
||||
for wire in self.iter() {
|
||||
if wire.stub.is_none() {
|
||||
wires.push(wire.clone());
|
||||
continue;
|
||||
}
|
||||
let start = wires.len();
|
||||
crate::scene::model::instance_model::expand_stub_into(wire, &mut wires);
|
||||
if let Some(query) = self.query_aabb {
|
||||
let mut keep = start;
|
||||
for index in start..wires.len() {
|
||||
if near(&wires[index], query) {
|
||||
wires.swap(keep, index);
|
||||
keep += 1;
|
||||
}
|
||||
}
|
||||
wires.truncate(keep);
|
||||
}
|
||||
}
|
||||
Self {
|
||||
query_aabb: self.query_aabb,
|
||||
screen_rect: self.screen_rect,
|
||||
screen_view: self.screen_view,
|
||||
..Self::all(Arc::new(wires))
|
||||
}
|
||||
}
|
||||
|
||||
/// Leave out every wire `drop` matches, with the segments, points and
|
||||
/// triangles that belong to it.
|
||||
pub(crate) fn exclude_wires(&mut self, drop: impl Fn(&WireModel) -> bool) {
|
||||
let wires = Arc::clone(&self.wires);
|
||||
let dropped = |wire: u32| wires.get(wire as usize).is_some_and(&drop);
|
||||
match self.wire_indices.as_mut() {
|
||||
Some(indices) => indices.retain(|&wire| !dropped(wire)),
|
||||
None => {
|
||||
self.wire_indices = Some(
|
||||
(0..wires.len() as u32)
|
||||
.filter(|&wire| !dropped(wire))
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
}
|
||||
fn keep<T>(
|
||||
refs: &mut Option<Vec<T>>,
|
||||
wire: impl Fn(&T) -> u32,
|
||||
dropped: impl Fn(u32) -> bool,
|
||||
) {
|
||||
if let Some(refs) = refs.as_mut() {
|
||||
refs.retain(|item| !dropped(wire(item)));
|
||||
}
|
||||
}
|
||||
keep(&mut self.segments, |r| r.wire, dropped);
|
||||
keep(&mut self.snap_points, |r| r.wire, dropped);
|
||||
keep(&mut self.key_vertices, |r| r.wire, dropped);
|
||||
keep(&mut self.key_segments, |r| r.wire, dropped);
|
||||
keep(&mut self.fill_triangles, |r| r.wire, dropped);
|
||||
keep(&mut self.pick_triangles, |r| r.wire, dropped);
|
||||
keep(&mut self.glyphs, |r| r.wire, dropped);
|
||||
}
|
||||
|
||||
pub(crate) fn extend_indexed_area(&mut self, other: Self) {
|
||||
debug_assert!(Arc::ptr_eq(&self.wires, &other.wires));
|
||||
|
||||
@@ -1879,6 +1951,14 @@ fn finite_wire_aabb3(wire: &WireModel) -> Option<[f64; 6]> {
|
||||
vertex.pos[2] as f64 + vertex.pos_low[2] as f64,
|
||||
]);
|
||||
}
|
||||
if let Some(stub) = &wire.stub {
|
||||
for inner in stub.prototype.iter() {
|
||||
if let Some(inner) = finite_wire_aabb3(inner) {
|
||||
aabb[2] = aabb[2].min(inner[2] + stub.delta[2]);
|
||||
aabb[5] = aabb[5].max(inner[5] + stub.delta[2]);
|
||||
}
|
||||
}
|
||||
}
|
||||
if !aabb[2].is_finite() {
|
||||
aabb[2] = 0.0;
|
||||
aabb[5] = 0.0;
|
||||
|
||||
@@ -264,7 +264,7 @@ pub fn extract_circle_instance_from_geom_indexed(
|
||||
}
|
||||
|
||||
let (ch, cl) = split_ds_xyz(center[0], center[1], center[2]);
|
||||
let hw = wire.line_weight_px * 0.5;
|
||||
let hw = wire.line_weight_px.max(1.0) * 0.5;
|
||||
let pat0 = [
|
||||
wire.pattern[0],
|
||||
wire.pattern[1],
|
||||
|
||||
@@ -235,7 +235,7 @@ pub fn extract_ellipse_instance_from_geom(
|
||||
}
|
||||
|
||||
let (ch, cl) = split_ds_xyz(center[0], center[1], center[2]);
|
||||
let hw = wire.line_weight_px * 0.5;
|
||||
let hw = wire.line_weight_px.max(1.0) * 0.5;
|
||||
let pat0 = [
|
||||
wire.pattern[0],
|
||||
wire.pattern[1],
|
||||
|
||||
@@ -71,35 +71,45 @@ fn block_edits_preserve_cache_coordinates_and_arena_partition() {
|
||||
let original = pipeline.upload_block_wires(
|
||||
&device,
|
||||
&queue,
|
||||
&[&first, &second],
|
||||
&[(&first).into(), (&second).into()],
|
||||
&depth,
|
||||
&mut block_geometry,
|
||||
);
|
||||
let unchanged = pipeline.upload_block_wires(
|
||||
&device,
|
||||
&queue,
|
||||
&[&first, &second],
|
||||
&[(&first).into(), (&second).into()],
|
||||
&depth,
|
||||
&mut block_geometry,
|
||||
);
|
||||
assert_eq!(original[0].geometry_id(), unchanged[0].geometry_id());
|
||||
assert_eq!(original[0].instance_count, 2);
|
||||
|
||||
let removed =
|
||||
pipeline.upload_block_wires(&device, &queue, &[&second], &depth, &mut block_geometry);
|
||||
let removed = pipeline.upload_block_wires(
|
||||
&device,
|
||||
&queue,
|
||||
&[(&second).into()],
|
||||
&depth,
|
||||
&mut block_geometry,
|
||||
);
|
||||
assert_ne!(
|
||||
original[0].geometry_id(),
|
||||
removed[0].geometry_id(),
|
||||
"removing the base instance must replace vertices baked at its old position"
|
||||
);
|
||||
let moved = block(2, 30.0);
|
||||
let moved_gpu =
|
||||
pipeline.upload_block_wires(&device, &queue, &[&moved], &depth, &mut block_geometry);
|
||||
let moved_gpu = pipeline.upload_block_wires(
|
||||
&device,
|
||||
&queue,
|
||||
&[(&moved).into()],
|
||||
&depth,
|
||||
&mut block_geometry,
|
||||
);
|
||||
assert_ne!(removed[0].geometry_id(), moved_gpu[0].geometry_id());
|
||||
let restored = pipeline.upload_block_wires(
|
||||
&device,
|
||||
&queue,
|
||||
&[&first, &second],
|
||||
&[(&first).into(), (&second).into()],
|
||||
&depth,
|
||||
&mut block_geometry,
|
||||
);
|
||||
|
||||
+52
-14
@@ -136,6 +136,9 @@ pub struct Pipeline {
|
||||
/// Capability-selected hatch renderer. Storage and texture transports are
|
||||
/// private backends behind one upload/LOD/draw lifecycle.
|
||||
hatch_gpu: hatch_gpu::HatchGpu,
|
||||
/// Hatches with transparency, drawn after the wires so the lines behind
|
||||
/// them show through the blend instead of failing the depth test.
|
||||
hatch_translucent_gpu: hatch_gpu::HatchGpu,
|
||||
image_pipeline: wgpu::RenderPipeline,
|
||||
point_cloud_pipeline: wgpu::RenderPipeline,
|
||||
/// SDF text-quad pipeline (Phase 2b): draws per-glyph quads sampling the
|
||||
@@ -1100,6 +1103,14 @@ impl Pipeline {
|
||||
device_caps,
|
||||
force_compat_renderer,
|
||||
);
|
||||
let hatch_translucent_gpu = hatch_gpu::HatchGpu::new(
|
||||
device,
|
||||
format,
|
||||
&frame_bgl,
|
||||
&content_stencil,
|
||||
device_caps,
|
||||
force_compat_renderer,
|
||||
);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if std::env::var_os("RUST_LOG").is_some() {
|
||||
eprintln!(
|
||||
@@ -2339,6 +2350,7 @@ impl Pipeline {
|
||||
gpu_error_epoch: gpu_errors_seen(),
|
||||
wipeout_pipeline,
|
||||
hatch_gpu,
|
||||
hatch_translucent_gpu,
|
||||
image_pipeline,
|
||||
point_cloud_pipeline,
|
||||
text_pipeline,
|
||||
@@ -2474,7 +2486,7 @@ impl Pipeline {
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
wires: &[&WireModel],
|
||||
wires: &[wire_gpu::BlockRef<'_>],
|
||||
depth_map: &rustc_hash::FxHashMap<u64, [f32; 2]>,
|
||||
block_geometry: &mut wire_gpu::BlockGeometryCache,
|
||||
) -> Vec<BlockWireGpu> {
|
||||
@@ -2608,7 +2620,7 @@ impl Pipeline {
|
||||
// and alpha blending both depend on it. Scissor and mesh-edge stay
|
||||
// grouping keys because the draw loop sets one scissor per batch and
|
||||
let mut batches: Vec<WireGpu> = Vec::new();
|
||||
let mut block_wires: Vec<&WireModel> = Vec::new();
|
||||
let mut block_wires: Vec<wire_gpu::BlockRef<'_>> = Vec::new();
|
||||
let mut i = 0;
|
||||
while i < wires.len() {
|
||||
let kind = wire_arena::classify_wire(&wires[i]);
|
||||
@@ -2621,7 +2633,7 @@ impl Pipeline {
|
||||
continue;
|
||||
}
|
||||
wire_arena::WireKind::Block => {
|
||||
block_wires.push(&wires[i]);
|
||||
wire_gpu::push_block_refs(&wires[i], &mut block_wires);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
@@ -2709,7 +2721,7 @@ impl Pipeline {
|
||||
Vec<CircleInstance>,
|
||||
Vec<EllipseInstance>,
|
||||
Vec<&'a WireModel>,
|
||||
Vec<&'a WireModel>,
|
||||
Vec<wire_gpu::BlockRef<'a>>,
|
||||
) {
|
||||
use crate::par::prelude::*;
|
||||
// Pure per-wire work, so it fans out; `collect` on an indexed
|
||||
@@ -2722,7 +2734,7 @@ impl Pipeline {
|
||||
)> = wires
|
||||
.par_iter()
|
||||
.map(|&wire| {
|
||||
if wire.render_instance.is_some() {
|
||||
if wire.render_instance.is_some() || wire.stub.is_some() {
|
||||
return (None, None, true);
|
||||
}
|
||||
let depth = wire_gpu::wire_draw_depth(wire, depth_map);
|
||||
@@ -2751,10 +2763,10 @@ impl Pipeline {
|
||||
let mut circles: Vec<CircleInstance> = Vec::new();
|
||||
let mut ellipses: Vec<EllipseInstance> = Vec::new();
|
||||
let mut regular: Vec<&'a WireModel> = Vec::new();
|
||||
let mut blocks: Vec<&'a WireModel> = Vec::new();
|
||||
let mut blocks: Vec<wire_gpu::BlockRef<'a>> = Vec::new();
|
||||
for (&wire, (wire_circles, wire_ellipses, is_block)) in wires.iter().zip(per) {
|
||||
if is_block {
|
||||
blocks.push(wire);
|
||||
wire_gpu::push_block_refs(wire, &mut blocks);
|
||||
continue;
|
||||
}
|
||||
let shaped = wire_circles.is_some() || wire_ellipses.is_some();
|
||||
@@ -3429,10 +3441,12 @@ analytic={:.1} regular={:.1} blocks={:.1}",
|
||||
clip_h: u32,
|
||||
) {
|
||||
let perf_started = crate::perf::enabled().then(iced::time::Instant::now);
|
||||
let instance_count = self.hatch_gpu.update_visibility(queue, |aabb| {
|
||||
let visible = |aabb: [f32; 4]| {
|
||||
!aabb_below_pixel(aabb, view_rot, eye, clip_w, clip_h, 2.0)
|
||||
&& !aabb_offscreen(aabb, view_rot, eye, clip_w, clip_h)
|
||||
});
|
||||
};
|
||||
let instance_count = self.hatch_gpu.update_visibility(queue, visible)
|
||||
+ self.hatch_translucent_gpu.update_visibility(queue, visible);
|
||||
if instance_count == 0 {
|
||||
return;
|
||||
}
|
||||
@@ -3786,7 +3800,10 @@ analytic={:.1} regular={:.1} blocks={:.1}",
|
||||
.iter()
|
||||
.filter(|hatch| hatch.boundary.len() >= 3)
|
||||
.count();
|
||||
self.hatch_gpu.upload(device, queue, hatches);
|
||||
let (translucent, opaque): (Vec<HatchModel>, Vec<HatchModel>) =
|
||||
hatches.iter().cloned().partition(|hatch| hatch.color[3] < 0.999);
|
||||
self.hatch_gpu.upload(device, queue, &opaque);
|
||||
self.hatch_translucent_gpu.upload(device, queue, &translucent);
|
||||
if let Some(started) = perf_started {
|
||||
let elapsed_ms = started.elapsed().as_secs_f64() * 1000.0;
|
||||
if elapsed_ms >= 1.0 {
|
||||
@@ -3996,7 +4013,8 @@ analytic={:.1} regular={:.1} blocks={:.1}",
|
||||
}
|
||||
let upload = |label: &'static str,
|
||||
image: Option<&crate::scene::model::image_model::DecodedImage>,
|
||||
fallback: [u8; 4]| {
|
||||
fallback: [u8; 4],
|
||||
format: wgpu::TextureFormat| {
|
||||
let (width, height, pixels) = image
|
||||
.map(|image| (image.width, image.height, image.pixels.as_slice()))
|
||||
.unwrap_or((1, 1, fallback.as_slice()));
|
||||
@@ -4010,7 +4028,7 @@ analytic={:.1} regular={:.1} blocks={:.1}",
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8UnormSrgb,
|
||||
format,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
|
||||
view_formats: &[],
|
||||
});
|
||||
@@ -4030,8 +4048,21 @@ analytic={:.1} regular={:.1} blocks={:.1}",
|
||||
);
|
||||
texture
|
||||
};
|
||||
self.background_texture = upload("background.texture", background, [0, 0, 0, 255]);
|
||||
self.environment_texture = upload("environment.texture", environment, [128, 128, 128, 255]);
|
||||
// The background image is drawn as it is, like the solid and gradient
|
||||
// colours (an sRGB view would darken it a second time); the
|
||||
// environment map stays sRGB for lighting.
|
||||
self.background_texture = upload(
|
||||
"background.texture",
|
||||
background,
|
||||
[0, 0, 0, 255],
|
||||
wgpu::TextureFormat::Rgba8Unorm,
|
||||
);
|
||||
self.environment_texture = upload(
|
||||
"environment.texture",
|
||||
environment,
|
||||
[128, 128, 128, 255],
|
||||
wgpu::TextureFormat::Rgba8UnormSrgb,
|
||||
);
|
||||
self.rebuild_frame_bind_group(device);
|
||||
self.background_source_id = background_id;
|
||||
self.environment_source_id = environment_id;
|
||||
@@ -4819,6 +4850,12 @@ analytic={:.1} regular={:.1} blocks={:.1}",
|
||||
}
|
||||
}
|
||||
}
|
||||
// Transparent hatches blend over the lines already drawn; the
|
||||
// lines in front of them still win the depth test.
|
||||
if !self.skip_hatch_frame {
|
||||
self.hatch_translucent_gpu
|
||||
.draw(&mut pass, &self.uniform_bind_group, stencil_ref);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pass 5c: SDF text quads (drawn over wires) ────────────────────
|
||||
@@ -5311,6 +5348,7 @@ analytic={:.1} regular={:.1} blocks={:.1}",
|
||||
self.gpu_preview_circles.clear();
|
||||
self.gpu_preview_ellipses.clear();
|
||||
self.hatch_gpu.clear();
|
||||
self.hatch_translucent_gpu.clear();
|
||||
self.gpu_wipeouts.clear();
|
||||
self.wipeout_skip_flags.clear();
|
||||
self.gpu_images.clear();
|
||||
|
||||
@@ -172,23 +172,16 @@ impl WipeoutGpu {
|
||||
models: &[HatchModel],
|
||||
bgl1: &wgpu::BindGroupLayout,
|
||||
) -> Vec<Self> {
|
||||
let mut slots = rustc_hash::FxHashMap::default();
|
||||
let mut groups: Vec<Vec<&HatchModel>> = Vec::new();
|
||||
for (index, model) in models.iter().enumerate() {
|
||||
let key = model
|
||||
.render_instance
|
||||
.map(|instance| (true, instance.source_id))
|
||||
.unwrap_or((false, index as u64));
|
||||
let slot = *slots.entry(key).or_insert_with(|| {
|
||||
let slot = groups.len();
|
||||
groups.push(Vec::new());
|
||||
slot
|
||||
});
|
||||
groups[slot].push(model);
|
||||
}
|
||||
groups
|
||||
.into_iter()
|
||||
.map(|group| Self::new(device, queue, &group, bgl1))
|
||||
// A source_id only identifies reusable render geometry, not an
|
||||
// interchangeable hatch boundary in world space. Grouping masks by
|
||||
// source_id can incorrectly reuse the first model's boundary, origin,
|
||||
// plane and uniform for later INSERT copies. Unlike wires, a mask's
|
||||
// GPU shader clips against its own boundary, so build each mask from
|
||||
// its actual transformed model until we have a geometry-local,
|
||||
// translation-safe instancing path.
|
||||
models
|
||||
.iter()
|
||||
.map(|model| Self::new(device, queue, &[model], bgl1))
|
||||
.collect()
|
||||
}
|
||||
|
||||
|
||||
@@ -146,7 +146,7 @@ pub fn classify_wire(wire: &WireModel) -> WireKind {
|
||||
if !wire.display_visible {
|
||||
return WireKind::Invisible;
|
||||
}
|
||||
if wire.render_instance.is_some() {
|
||||
if wire.render_instance.is_some() || wire.stub.is_some() {
|
||||
return WireKind::Block;
|
||||
}
|
||||
if !wire.tangent_geoms.is_empty()
|
||||
@@ -176,7 +176,7 @@ pub fn classify_wire(wire: &WireModel) -> WireKind {
|
||||
pub struct PartitionedWires<'a> {
|
||||
pub regular: Vec<&'a WireModel>,
|
||||
pub mesh: Vec<&'a WireModel>,
|
||||
pub instanced: Vec<&'a WireModel>,
|
||||
pub instanced: Vec<super::wire_gpu::BlockRef<'a>>,
|
||||
pub circle_instances: Vec<super::circle_gpu::CircleInstance>,
|
||||
pub ellipse_instances: Vec<super::ellipse_gpu::EllipseInstance>,
|
||||
/// Handles that contributed to the retained analytical uploads.
|
||||
@@ -213,11 +213,11 @@ pub fn partition_wires<'a>(
|
||||
if !wire.display_visible {
|
||||
continue;
|
||||
}
|
||||
if wire.render_instance.is_some() {
|
||||
if wire.render_instance.is_some() || wire.stub.is_some() {
|
||||
if let Some(handle) = handle_of(wire) {
|
||||
contributors.insert(handle);
|
||||
}
|
||||
instanced.push(wire);
|
||||
super::wire_gpu::push_block_refs(wire, &mut instanced);
|
||||
continue;
|
||||
}
|
||||
// Resolve draw depth only for wires eligible for analytical extraction.
|
||||
|
||||
@@ -863,7 +863,7 @@ pub(crate) fn emit_wire_packed(
|
||||
wire.pattern[6],
|
||||
wire.pattern[7],
|
||||
];
|
||||
let half_width = wire.line_weight_px * 0.5;
|
||||
let half_width = wire.line_weight_px.max(1.0) * 0.5;
|
||||
let n = wire.points.len();
|
||||
let seg_count = n.saturating_sub(1);
|
||||
if seg_count == 0 {
|
||||
@@ -965,7 +965,7 @@ pub(crate) fn emit_wire_native(
|
||||
wire.pattern[6],
|
||||
wire.pattern[7],
|
||||
],
|
||||
half_width: wire.line_weight_px * 0.5,
|
||||
half_width: wire.line_weight_px.max(1.0) * 0.5,
|
||||
pattern_length: wire.pattern_length,
|
||||
draw_depth,
|
||||
align_end,
|
||||
@@ -1043,6 +1043,83 @@ pub fn fast_parse_u64(s: &str) -> Option<u64> {
|
||||
/// wires that carry no document handle). A wire carrying a block-local
|
||||
/// `depth_override` (a wide-polyline band inside a block) composes it into
|
||||
/// the insert's own sub-range so the band orders against its block siblings.
|
||||
/// One block-instanced wire to draw: a wire of the set, or a prototype wire
|
||||
/// reached through a stub, moved by `delta` with its depth shifted by
|
||||
/// `depth_delta` and owned (named, depth-ordered) by the stub `owner`.
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct BlockRef<'a> {
|
||||
pub wire: &'a WireModel,
|
||||
pub delta: [f64; 3],
|
||||
pub depth_delta: f32,
|
||||
pub owner: Option<&'a WireModel>,
|
||||
}
|
||||
|
||||
impl<'a> From<&'a WireModel> for BlockRef<'a> {
|
||||
fn from(wire: &'a WireModel) -> Self {
|
||||
Self {
|
||||
wire,
|
||||
delta: [0.0; 3],
|
||||
depth_delta: 0.0,
|
||||
owner: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> BlockRef<'a> {
|
||||
/// The wire whose name and visibility stand for this copy.
|
||||
pub fn named(&self) -> &'a WireModel {
|
||||
self.owner.unwrap_or(self.wire)
|
||||
}
|
||||
|
||||
/// The copy's draw depth: its owner's slot, the prototype wire's
|
||||
/// block-local depth shifted for this copy.
|
||||
pub fn draw_depth(&self, depth_map: &rustc_hash::FxHashMap<u64, [f32; 2]>) -> f32 {
|
||||
let base = fast_parse_u64(&self.named().name).and_then(|h| depth_map.get(&h).copied());
|
||||
match (base, self.wire.depth_override) {
|
||||
(Some([d, half]), Some(local)) => d + (local + self.depth_delta) * half,
|
||||
(Some([d, _]), None) => d,
|
||||
(None, _) => 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Push the block-instanced wires `wire` stands for: itself, or every copy a
|
||||
/// stub holds (stubs inside stubs included).
|
||||
pub fn push_block_refs<'a>(wire: &'a WireModel, out: &mut Vec<BlockRef<'a>>) {
|
||||
fn walk<'a>(
|
||||
wire: &'a WireModel,
|
||||
delta: [f64; 3],
|
||||
depth_delta: f32,
|
||||
owner: &'a WireModel,
|
||||
out: &mut Vec<BlockRef<'a>>,
|
||||
) {
|
||||
match &wire.stub {
|
||||
Some(stub) => {
|
||||
let delta = [
|
||||
delta[0] + stub.delta[0],
|
||||
delta[1] + stub.delta[1],
|
||||
delta[2] + stub.delta[2],
|
||||
];
|
||||
for inner in stub.prototype.iter() {
|
||||
walk(inner, delta, depth_delta + stub.depth_delta, owner, out);
|
||||
}
|
||||
}
|
||||
None if wire.render_instance.is_some() => out.push(BlockRef {
|
||||
wire,
|
||||
delta,
|
||||
depth_delta,
|
||||
owner: Some(owner),
|
||||
}),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
if wire.stub.is_some() {
|
||||
walk(wire, [0.0; 3], 0.0, wire, out);
|
||||
} else if wire.render_instance.is_some() {
|
||||
out.push(BlockRef::from(wire));
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn wire_draw_depth(
|
||||
wire: &WireModel,
|
||||
depth_map: &rustc_hash::FxHashMap<u64, [f32; 2]>,
|
||||
@@ -1121,7 +1198,7 @@ impl BlockWireGpu {
|
||||
pub fn from_wires(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
wires: &[&WireModel],
|
||||
wires: &[BlockRef<'_>],
|
||||
depth_map: &rustc_hash::FxHashMap<u64, [f32; 2]>,
|
||||
color_override: Option<[f32; 4]>,
|
||||
target: BlockWireTarget<'_>,
|
||||
@@ -1129,9 +1206,12 @@ impl BlockWireGpu {
|
||||
) -> Vec<Self> {
|
||||
let BlockWireTarget { const_bgl, mode } = target;
|
||||
let mut slots: rustc_hash::FxHashMap<(u64, bool), usize> = rustc_hash::FxHashMap::default();
|
||||
let mut groups: Vec<(bool, Vec<&WireModel>)> = Vec::new();
|
||||
for &wire in wires {
|
||||
if color_override.is_none() && !wire.display_visible {
|
||||
let mut groups: Vec<(bool, Vec<BlockRef<'_>>)> = Vec::new();
|
||||
for &block in wires {
|
||||
let wire = block.wire;
|
||||
if color_override.is_none()
|
||||
&& !(wire.display_visible && block.named().display_visible)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let Some(instance) = wire.render_instance else {
|
||||
@@ -1147,7 +1227,7 @@ impl BlockWireGpu {
|
||||
groups.push((mesh_edge, Vec::new()));
|
||||
slot
|
||||
});
|
||||
groups[slot].1.push(wire);
|
||||
groups[slot].1.push(block);
|
||||
}
|
||||
|
||||
let mut arena = BlockArenaBuilder::new(device, mode);
|
||||
@@ -1157,7 +1237,10 @@ impl BlockWireGpu {
|
||||
// visible rather than assumed.
|
||||
let mut emitted_segments = 0usize;
|
||||
for (mesh_edge, group) in groups {
|
||||
let Some(&source) = group.first() else {
|
||||
// A copy reached through a stub carries the prototype's
|
||||
// geometry at the prototype's placement, which is what `base`
|
||||
// means; its own `delta` only moves the instance.
|
||||
let Some(source) = group.first().map(|block| block.wire) else {
|
||||
continue;
|
||||
};
|
||||
let Some(base) = source.render_instance else {
|
||||
@@ -1460,19 +1543,19 @@ impl BlockArenaBuilder {
|
||||
|
||||
/// Placements and draw depths relative to the group's first instance.
|
||||
fn block_instances(
|
||||
group: &[&WireModel],
|
||||
group: &[BlockRef<'_>],
|
||||
base: crate::scene::model::instance_model::RenderInstance,
|
||||
mesh_edge: bool,
|
||||
depth_map: &rustc_hash::FxHashMap<u64, [f32; 2]>,
|
||||
) -> Vec<BlockWireInstance> {
|
||||
group
|
||||
.iter()
|
||||
.filter_map(|wire| {
|
||||
let instance = wire.render_instance?;
|
||||
.filter_map(|block| {
|
||||
let instance = block.wire.render_instance?;
|
||||
let delta = [
|
||||
instance.translation[0] - base.translation[0],
|
||||
instance.translation[1] - base.translation[1],
|
||||
instance.translation[2] - base.translation[2],
|
||||
instance.translation[0] + block.delta[0] - base.translation[0],
|
||||
instance.translation[1] + block.delta[1] - base.translation[1],
|
||||
instance.translation[2] + block.delta[2] - base.translation[2],
|
||||
];
|
||||
let high = delta.map(|value| value as f32);
|
||||
Some(BlockWireInstance {
|
||||
@@ -1486,7 +1569,7 @@ fn block_instances(
|
||||
if mesh_edge {
|
||||
0.0
|
||||
} else {
|
||||
wire_draw_depth(wire, depth_map)
|
||||
block.draw_depth(depth_map)
|
||||
},
|
||||
0.0,
|
||||
],
|
||||
|
||||
@@ -84,6 +84,7 @@ impl Scene {
|
||||
// path wants the full-fidelity (no-LOD-stub) wire list,
|
||||
// regardless of paper zoom.
|
||||
let model_wires = self.model_wires_for_viewport_arc(vp_handle, 0.0);
|
||||
let model_wires = crate::scene::model::instance_model::expand_stubs(&model_wires);
|
||||
|
||||
// ── Project and clip wires into viewport ──────────────────────
|
||||
let vp_x0 = pcx - hw;
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
//! SECTIONCULL: keep only what lies inside a section object — the part a
|
||||
//! live section shows — and remove the rest for good. Solids, surfaces and
|
||||
//! closed meshes are cut to it, open meshes are clipped face by face, and
|
||||
//! every other object wholly outside it is erased. Objects across its
|
||||
//! boundary that cannot be cut stay whole.
|
||||
|
||||
use codec::{EntityType, Handle};
|
||||
use glam::DVec3;
|
||||
use rustc_hash::FxHashMap;
|
||||
|
||||
use super::Scene;
|
||||
|
||||
/// What culling to a section does to the space it lives in.
|
||||
#[derive(Default)]
|
||||
pub struct SectionCull {
|
||||
/// Bodies the section crosses, with what stays of each.
|
||||
pub cut: Vec<(Handle, kernel::brep::Body)>,
|
||||
/// Open meshes the section crosses, clipped to it.
|
||||
pub clipped: Vec<EntityType>,
|
||||
/// Objects wholly outside the section.
|
||||
pub outside: Vec<Handle>,
|
||||
}
|
||||
|
||||
enum Place {
|
||||
Inside,
|
||||
Outside,
|
||||
Across,
|
||||
}
|
||||
|
||||
const TOLERANCE: f64 = 1e-9;
|
||||
|
||||
fn place(planes: &[kernel::space::Plane], points: &[[f64; 3]]) -> Place {
|
||||
let mut inside = true;
|
||||
for plane in planes {
|
||||
let behind = points
|
||||
.iter()
|
||||
.filter(|point| plane.distance_to(**point).unwrap_or(0.0) < -TOLERANCE)
|
||||
.count();
|
||||
if behind == points.len() {
|
||||
return Place::Outside;
|
||||
}
|
||||
inside &= behind == 0;
|
||||
}
|
||||
if inside {
|
||||
Place::Inside
|
||||
} else {
|
||||
Place::Across
|
||||
}
|
||||
}
|
||||
|
||||
fn same_extent(a: &kernel::brep::Body, b: &kernel::brep::Body) -> bool {
|
||||
match (kernel::brep::body_bounds(a), kernel::brep::body_bounds(b)) {
|
||||
(Some(a), Some(b)) => (0..3).all(|axis| {
|
||||
(a.min[axis] - b.min[axis]).abs() <= 1e-7 && (a.max[axis] - b.max[axis]).abs() <= 1e-7
|
||||
}),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
impl Scene {
|
||||
/// The section SECTIONCULL works with: the selected section object, or
|
||||
/// else the live section of the current space.
|
||||
pub fn cull_section(&self) -> Option<Handle> {
|
||||
let is_section = |handle: &Handle| {
|
||||
matches!(
|
||||
self.document.get_entity(*handle),
|
||||
Some(EntityType::Extended(extended))
|
||||
if matches!(
|
||||
extended.data,
|
||||
codec::entities::ExtendedEntityData::SectionObject(_)
|
||||
)
|
||||
)
|
||||
};
|
||||
if let Some(handle) = self.selected_handles_in_order().into_iter().find(is_section) {
|
||||
return Some(handle);
|
||||
}
|
||||
let block = self.current_layout_block_handle();
|
||||
self.document
|
||||
.entities()
|
||||
.filter(|entity| match entity {
|
||||
EntityType::Extended(extended) => match &extended.data {
|
||||
codec::entities::ExtendedEntityData::SectionObject(data) => {
|
||||
let owner = extended.common.owner_handle;
|
||||
data.flags & 1 != 0
|
||||
&& !extended.common.invisible
|
||||
&& (owner.is_null() || owner == block)
|
||||
}
|
||||
_ => false,
|
||||
},
|
||||
_ => false,
|
||||
})
|
||||
.map(|entity| entity.common().handle)
|
||||
.last()
|
||||
}
|
||||
|
||||
/// Plan culling the current space to section object `section`; `None`
|
||||
/// when it is not a section object.
|
||||
pub fn section_cull(&self, section: Handle) -> Option<SectionCull> {
|
||||
let Some(EntityType::Extended(extended)) = self.document.get_entity(section) else {
|
||||
return None;
|
||||
};
|
||||
let codec::entities::ExtendedEntityData::SectionObject(data) = &extended.data else {
|
||||
return None;
|
||||
};
|
||||
let slice_depth = crate::entities::extended::section_is_slice(extended).then(|| {
|
||||
crate::entities::extended::section_slice_depth(extended).unwrap_or(0.0)
|
||||
});
|
||||
let planes = Self::section_planes(data, slice_depth, None)?;
|
||||
|
||||
// Every drawn point of every object in the space, from its wires and
|
||||
// its shaded mesh.
|
||||
let block = self.current_layout_block_handle();
|
||||
let mut points: FxHashMap<Handle, Vec<[f64; 3]>> = FxHashMap::default();
|
||||
let wires = self.wires_for_block_culled(
|
||||
block,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
true,
|
||||
None,
|
||||
);
|
||||
for wire in &wires {
|
||||
let Some(handle) = Self::handle_from_wire_name(&wire.name) else {
|
||||
continue;
|
||||
};
|
||||
let entry = points.entry(handle).or_default();
|
||||
for (index, &[x, y, z]) in wire.points.iter().enumerate() {
|
||||
let [lx, ly, lz] = wire.points_low.get(index).copied().unwrap_or([0.0; 3]);
|
||||
let point = DVec3::new(
|
||||
f64::from(x) + f64::from(lx),
|
||||
f64::from(y) + f64::from(ly),
|
||||
f64::from(z) + f64::from(lz),
|
||||
);
|
||||
if point.is_finite() {
|
||||
entry.push(point.to_array());
|
||||
}
|
||||
}
|
||||
}
|
||||
for set in self.interaction_meshes_arc().iter() {
|
||||
let handle = set.entity_handle();
|
||||
let (Some(handle), Some(aabb)) = (handle, super::mesh_interaction_aabb(set)) else {
|
||||
continue;
|
||||
};
|
||||
let entry = points.entry(handle).or_default();
|
||||
for corner in 0..8 {
|
||||
entry.push([
|
||||
aabb[if corner & 1 == 0 { 0 } else { 3 }],
|
||||
aabb[if corner & 2 == 0 { 1 } else { 4 }],
|
||||
aabb[if corner & 4 == 0 { 2 } else { 5 }],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
let mut cull = SectionCull::default();
|
||||
// A live section hides what lies wholly outside it, so the space's
|
||||
// own objects are walked too.
|
||||
let mut handles: Vec<Handle> = points
|
||||
.keys()
|
||||
.copied()
|
||||
.chain(
|
||||
self.document
|
||||
.entities()
|
||||
.filter(|entity| entity.common().owner_handle == block)
|
||||
.map(|entity| entity.common().handle),
|
||||
)
|
||||
.collect();
|
||||
handles.sort_unstable_by_key(|handle| handle.value());
|
||||
handles.dedup();
|
||||
for handle in handles {
|
||||
if handle == section || self.is_layer_locked(handle) {
|
||||
continue;
|
||||
}
|
||||
let Some(entity) = self.document.get_entity(handle) else {
|
||||
continue;
|
||||
};
|
||||
// Bodies are cut with the kernel: their drawn wires may already
|
||||
// show the live cut, so they say nothing about the source.
|
||||
if let Some(body) = self.section_source_body(handle) {
|
||||
match Self::section_body(&body, data, slice_depth, None) {
|
||||
Ok(None) => cull.outside.push(handle),
|
||||
Ok(Some(kept)) if !same_extent(&body, &kept) => cull.cut.push((handle, kept)),
|
||||
_ => {}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if let Some((vertices, _)) = crate::entities::mesh::mesh_polygons(entity) {
|
||||
match place(&planes, &vertices) {
|
||||
Place::Outside => cull.outside.push(handle),
|
||||
Place::Inside => {}
|
||||
Place::Across => {
|
||||
let mut mesh = entity.clone();
|
||||
for plane in &planes {
|
||||
let Some((vertices, faces)) =
|
||||
crate::entities::mesh::mesh_polygons(&mesh)
|
||||
else {
|
||||
break;
|
||||
};
|
||||
let side = |point: [f64; 3]| plane.distance_to(point).unwrap_or(0.0);
|
||||
if vertices.iter().all(|point| side(*point) >= -TOLERANCE) {
|
||||
continue;
|
||||
}
|
||||
let Some(mut clipped) = crate::entities::mesh::clipped_mesh(
|
||||
&vertices,
|
||||
&faces,
|
||||
side,
|
||||
true,
|
||||
mesh.common(),
|
||||
) else {
|
||||
break;
|
||||
};
|
||||
clipped.common.handle = handle;
|
||||
mesh = EntityType::Mesh(clipped);
|
||||
}
|
||||
cull.clipped.push(mesh);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if let Some(points) = points.get(&handle) {
|
||||
if matches!(place(&planes, points), Place::Outside) {
|
||||
cull.outside.push(handle);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(cull)
|
||||
}
|
||||
}
|
||||
+175
-30
@@ -165,6 +165,7 @@ impl Scene {
|
||||
|
||||
pub fn deselect_all(&mut self) {
|
||||
self.selected_constraint = None;
|
||||
self.sub_segments.clear();
|
||||
if self.selected.is_empty() {
|
||||
return;
|
||||
}
|
||||
@@ -426,6 +427,27 @@ impl Scene {
|
||||
value: &str,
|
||||
mode: crate::app::QSelectMode,
|
||||
append: bool,
|
||||
) -> usize {
|
||||
let condition = crate::app::QSelectCondition {
|
||||
type_filter: type_name.map(str::to_owned),
|
||||
property: property_field.map(|field| crate::app::QSelectPropertyChoice {
|
||||
field: field.to_owned(),
|
||||
label: field.to_owned(),
|
||||
editor: crate::app::QSelectValueEditor::Text,
|
||||
}),
|
||||
operator: op,
|
||||
value: value.to_owned(),
|
||||
};
|
||||
self.qselect_conditions(scope, &[condition], mode, append)
|
||||
}
|
||||
|
||||
/// Evaluate all conditions against the same candidate set, then apply result behavior once.
|
||||
pub fn qselect_conditions(
|
||||
&mut self,
|
||||
scope: crate::app::QSelectScope,
|
||||
conditions: &[crate::app::QSelectCondition],
|
||||
mode: crate::app::QSelectMode,
|
||||
append: bool,
|
||||
) -> usize {
|
||||
use crate::app::{QSelectMode, QSelectOp};
|
||||
use crate::entities::traits::entity_type_name;
|
||||
@@ -436,33 +458,44 @@ impl Scene {
|
||||
let Some(e) = self.document.get_entity(h) else {
|
||||
continue;
|
||||
};
|
||||
let type_ok = type_name.is_none_or(|t| entity_type_name(e) == t);
|
||||
let prop_ok = if !type_ok {
|
||||
true
|
||||
} else {
|
||||
match (property_field, op) {
|
||||
(None, _) | (_, QSelectOp::Any) => true,
|
||||
(Some(field), op) => match self.entity_property_value(e, field) {
|
||||
Some(actual) => match op {
|
||||
QSelectOp::Eq => actual.eq_ignore_ascii_case(value),
|
||||
QSelectOp::Neq => !actual.eq_ignore_ascii_case(value),
|
||||
QSelectOp::Gt | QSelectOp::Lt => {
|
||||
match (
|
||||
crate::entities::common::parse_f64(&actual),
|
||||
crate::entities::common::parse_f64(value),
|
||||
) {
|
||||
(Some(a), Some(b)) if matches!(op, QSelectOp::Gt) => a > b,
|
||||
(Some(a), Some(b)) => a < b,
|
||||
_ => false,
|
||||
let matches_filter = conditions.iter().all(|condition| {
|
||||
let type_ok = condition
|
||||
.type_filter
|
||||
.as_deref()
|
||||
.is_none_or(|t| entity_type_name(e) == t);
|
||||
let prop_ok = if !type_ok {
|
||||
true
|
||||
} else {
|
||||
match (
|
||||
condition
|
||||
.property
|
||||
.as_ref()
|
||||
.map(|property| property.field.as_str()),
|
||||
condition.operator,
|
||||
) {
|
||||
(None, _) | (_, QSelectOp::Any) => true,
|
||||
(Some(field), op) => match self.entity_property_value(e, field) {
|
||||
Some(actual) => match op {
|
||||
QSelectOp::Eq => actual.eq_ignore_ascii_case(&condition.value),
|
||||
QSelectOp::Neq => !actual.eq_ignore_ascii_case(&condition.value),
|
||||
QSelectOp::Gt | QSelectOp::Lt => {
|
||||
match (
|
||||
crate::entities::common::parse_f64(&actual),
|
||||
crate::entities::common::parse_f64(&condition.value),
|
||||
) {
|
||||
(Some(a), Some(b)) if matches!(op, QSelectOp::Gt) => a > b,
|
||||
(Some(a), Some(b)) => a < b,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
QSelectOp::Any => true,
|
||||
QSelectOp::Any => true,
|
||||
},
|
||||
None => false,
|
||||
},
|
||||
None => false,
|
||||
},
|
||||
}
|
||||
};
|
||||
let matches_filter = type_ok && prop_ok;
|
||||
}
|
||||
};
|
||||
type_ok && prop_ok
|
||||
});
|
||||
let keep = match mode {
|
||||
QSelectMode::Include => matches_filter,
|
||||
QSelectMode::Exclude => !matches_filter,
|
||||
@@ -582,6 +615,17 @@ impl Scene {
|
||||
&self,
|
||||
type_name: Option<&str>,
|
||||
scope: crate::app::QSelectScope,
|
||||
) -> Vec<crate::app::QSelectPropertyChoice> {
|
||||
self.qselect_properties_with_sample(type_name, scope, None)
|
||||
}
|
||||
|
||||
/// Use the picked entity's own property schema, including optional fields
|
||||
/// that may not be present on the first entity of this type in the drawing.
|
||||
fn qselect_properties_with_sample(
|
||||
&self,
|
||||
type_name: Option<&str>,
|
||||
scope: crate::app::QSelectScope,
|
||||
sample_handle: Option<Handle>,
|
||||
) -> Vec<crate::app::QSelectPropertyChoice> {
|
||||
use crate::app::{QSelectPropertyChoice, QSelectValueEditor};
|
||||
use crate::entities::traits::{entity_type_name, EntityTypeOps};
|
||||
@@ -680,11 +724,16 @@ impl Scene {
|
||||
.iter()
|
||||
.map(|s| s.name.clone())
|
||||
.collect();
|
||||
let sample = candidate_handles
|
||||
.iter()
|
||||
.copied()
|
||||
.filter_map(|h| self.document.get_entity(h))
|
||||
.find(|e| entity_type_name(e) == t);
|
||||
let sample = sample_handle
|
||||
.and_then(|handle| self.document.get_entity(handle))
|
||||
.filter(|entity| entity_type_name(entity) == t)
|
||||
.or_else(|| {
|
||||
candidate_handles
|
||||
.iter()
|
||||
.copied()
|
||||
.filter_map(|h| self.document.get_entity(h))
|
||||
.find(|e| entity_type_name(e) == t)
|
||||
});
|
||||
if let Some(sample) = sample {
|
||||
let mut sections = vec![crate::scene::cache::properties::general_section(sample)];
|
||||
if let Some(section) =
|
||||
@@ -836,6 +885,58 @@ impl Scene {
|
||||
out
|
||||
}
|
||||
|
||||
/// Build editable equality conditions without changing the current selection.
|
||||
/// The unique handle and empty values are deliberately not sampled.
|
||||
pub fn qselect_conditions_from_entity(
|
||||
&self,
|
||||
handle: Handle,
|
||||
) -> Vec<crate::app::QSelectCondition> {
|
||||
use crate::app::{QSelectCondition, QSelectOp, QSelectScope, QSelectValueEditor};
|
||||
let Some(entity) = self.document.get_entity(handle) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let type_name = crate::entities::traits::entity_type_name(entity).to_owned();
|
||||
let mut conditions = vec![QSelectCondition {
|
||||
type_filter: Some(type_name.clone()),
|
||||
property: None,
|
||||
operator: QSelectOp::Eq,
|
||||
value: String::new(),
|
||||
}];
|
||||
let properties = self.qselect_properties_with_sample(
|
||||
Some(&type_name),
|
||||
QSelectScope::CurrentSpace,
|
||||
Some(handle),
|
||||
);
|
||||
for mut property in properties {
|
||||
if property.field == "handle" {
|
||||
continue;
|
||||
}
|
||||
let Some(value) = self.entity_property_value(entity, &property.field) else {
|
||||
continue;
|
||||
};
|
||||
if value.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
// Ensure a sampled choice can also be edited, even outside the
|
||||
// filter's current-selection scope.
|
||||
if let QSelectValueEditor::Choice(options) = &mut property.editor {
|
||||
if !options.contains(&value) {
|
||||
options.push(value.clone());
|
||||
}
|
||||
}
|
||||
let condition = QSelectCondition {
|
||||
type_filter: Some(type_name.clone()),
|
||||
property: Some(property),
|
||||
operator: QSelectOp::Eq,
|
||||
value,
|
||||
};
|
||||
if condition.validation_error().is_none() {
|
||||
conditions.push(condition);
|
||||
}
|
||||
}
|
||||
conditions
|
||||
}
|
||||
|
||||
/// Reads a property value from an entity for QSELECT comparison.
|
||||
/// Returns the canonical string used as the left-hand side of the
|
||||
/// operator test. Common properties have hand-rolled formatting so
|
||||
@@ -1172,6 +1273,50 @@ impl Scene {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn qselect_sample_matches_identical_entities_without_unique_handles() {
|
||||
use crate::app::{QSelectMode, QSelectScope};
|
||||
use codec::entities::Line;
|
||||
use codec::types::Vector3;
|
||||
let mut scene = Scene::new();
|
||||
let line = || {
|
||||
EntityType::Line(Line::from_points(
|
||||
Vector3::ZERO,
|
||||
Vector3::new(10.0, 0.0, 0.0),
|
||||
))
|
||||
};
|
||||
let first = scene.add_entity(line());
|
||||
let second = scene.add_entity(line());
|
||||
scene.select_entity(second, false);
|
||||
let before = scene.selected.clone();
|
||||
let conditions = scene.qselect_conditions_from_entity(first);
|
||||
assert_eq!(
|
||||
scene.selected, before,
|
||||
"sampling must not change the selection"
|
||||
);
|
||||
assert!(conditions.iter().any(|condition| condition
|
||||
.property
|
||||
.as_ref()
|
||||
.is_some_and(|property| property.field == "layer")));
|
||||
assert!(conditions.iter().all(|condition| condition
|
||||
.property
|
||||
.as_ref()
|
||||
.is_none_or(|property| property.field != "handle")));
|
||||
assert!(conditions
|
||||
.iter()
|
||||
.all(|condition| condition.validation_error().is_none()));
|
||||
assert_eq!(
|
||||
scene.qselect_conditions(
|
||||
QSelectScope::CurrentSpace,
|
||||
&conditions,
|
||||
QSelectMode::Include,
|
||||
false,
|
||||
),
|
||||
2
|
||||
);
|
||||
assert!(scene.selected.contains(&first) && scene.selected.contains(&second));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pe_url_of_reads_standard_hyperlink_xdata() {
|
||||
use codec::entities::Point;
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
//! Picking part of an object: Ctrl+click on a polyline selects the segment
|
||||
//! under the cursor. The polyline joins the selection, only its picked
|
||||
//! segments are highlighted, and MOVE / ROTATE / SCALE / MIRROR then change
|
||||
//! just the vertices of those segments, stretching the neighbours.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use codec::entities::LwPolyline;
|
||||
use codec::{EntityType, Handle};
|
||||
use glam::DVec3;
|
||||
|
||||
use super::Scene;
|
||||
use crate::command::EntityTransform;
|
||||
|
||||
/// Segment `index` of `polyline` on its own: its two vertices, with the
|
||||
/// bulge of the first.
|
||||
fn segment(polyline: &LwPolyline, index: usize) -> Option<LwPolyline> {
|
||||
let count = polyline.vertices.len();
|
||||
let next = if index + 1 < count {
|
||||
index + 1
|
||||
} else if polyline.is_closed && count > 2 {
|
||||
0
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
let mut part = polyline.clone();
|
||||
part.vertices = vec![
|
||||
polyline.vertices[index].clone(),
|
||||
polyline.vertices[next].clone(),
|
||||
];
|
||||
part.vertices[1].bulge = 0.0;
|
||||
part.is_closed = false;
|
||||
Some(part)
|
||||
}
|
||||
|
||||
fn segment_points(polyline: &LwPolyline, index: usize) -> Vec<[f64; 3]> {
|
||||
segment(polyline, index)
|
||||
.and_then(|part| crate::entities::curve::lwpolyline_curve(&part))
|
||||
.map(|curve| crate::entities::curve::curve_points(&curve))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
impl Scene {
|
||||
/// Ctrl+click: toggle the segment of polyline `handle` nearest `point`
|
||||
/// and keep the polyline selected while any of its segments is. `false`
|
||||
/// when `handle` is not a polyline.
|
||||
pub fn toggle_sub_segment(&mut self, handle: Handle, point: DVec3) -> bool {
|
||||
let Some(EntityType::LwPolyline(polyline)) = self.document.get_entity(handle) else {
|
||||
return false;
|
||||
};
|
||||
let nearest = (0..polyline.vertices.len())
|
||||
.filter_map(|index| {
|
||||
let distance = segment_points(polyline, index)
|
||||
.windows(2)
|
||||
.map(|pair| {
|
||||
let a = DVec3::from_array(pair[0]).truncate();
|
||||
let b = DVec3::from_array(pair[1]).truncate();
|
||||
let p = point.truncate();
|
||||
let t = ((p - a).dot(b - a) / (b - a).length_squared().max(1e-300))
|
||||
.clamp(0.0, 1.0);
|
||||
p.distance(a + (b - a) * t)
|
||||
})
|
||||
.fold(f64::INFINITY, f64::min);
|
||||
distance.is_finite().then_some((index, distance))
|
||||
})
|
||||
.min_by(|a, b| a.1.total_cmp(&b.1));
|
||||
let Some((index, _)) = nearest else {
|
||||
return false;
|
||||
};
|
||||
if !self.selected.contains(&handle) {
|
||||
self.sub_segments.remove(&handle);
|
||||
}
|
||||
let segments = self.sub_segments.entry(handle).or_default();
|
||||
if !segments.remove(&index) {
|
||||
segments.insert(index);
|
||||
}
|
||||
if segments.is_empty() {
|
||||
self.sub_segments.remove(&handle);
|
||||
self.deselect_entity(handle);
|
||||
} else {
|
||||
self.select_entity(handle, false);
|
||||
}
|
||||
self.bump_selection_set();
|
||||
true
|
||||
}
|
||||
|
||||
/// The picked segments of a selected polyline, if only part of it is.
|
||||
pub(crate) fn picked_segments(&self, handle: Handle) -> Option<&BTreeSet<usize>> {
|
||||
self.selected
|
||||
.contains(&handle)
|
||||
.then(|| self.sub_segments.get(&handle))
|
||||
.flatten()
|
||||
}
|
||||
|
||||
/// World points of the picked segments of `handle`, NaN-separated, for
|
||||
/// the selection highlight.
|
||||
pub(crate) fn picked_segment_points(&self, handle: Handle) -> Option<Vec<[f64; 3]>> {
|
||||
let segments = self.picked_segments(handle)?;
|
||||
let Some(EntityType::LwPolyline(polyline)) = self.document.get_entity(handle) else {
|
||||
return None;
|
||||
};
|
||||
let mut points = Vec::new();
|
||||
for &index in segments {
|
||||
points.extend(segment_points(polyline, index));
|
||||
points.push([f64::NAN; 3]);
|
||||
}
|
||||
Some(points)
|
||||
}
|
||||
|
||||
/// Apply `transform` to just the vertices of the picked segments of each
|
||||
/// partly selected polyline in `handles`. Returns the handles left to
|
||||
/// transform whole.
|
||||
pub fn transform_picked_segments(
|
||||
&mut self,
|
||||
handles: &[Handle],
|
||||
transform: &EntityTransform,
|
||||
) -> Vec<Handle> {
|
||||
let mut whole = Vec::with_capacity(handles.len());
|
||||
for &handle in handles {
|
||||
let (Some(segments), Some(EntityType::LwPolyline(polyline))) = (
|
||||
self.picked_segments(handle),
|
||||
self.document.get_entity(handle),
|
||||
) else {
|
||||
whole.push(handle);
|
||||
continue;
|
||||
};
|
||||
let count = polyline.vertices.len();
|
||||
let vertices: BTreeSet<usize> = segments
|
||||
.iter()
|
||||
.flat_map(|&index| [index, (index + 1) % count])
|
||||
.collect();
|
||||
let Some(plane) = crate::entities::curve::lwpolyline_curve(polyline)
|
||||
.map(|curve| curve.plane)
|
||||
else {
|
||||
whole.push(handle);
|
||||
continue;
|
||||
};
|
||||
let mut moved = polyline.clone();
|
||||
for index in vertices {
|
||||
let vertex = &mut moved.vertices[index];
|
||||
let world = plane.point_at([vertex.location.x, vertex.location.y]);
|
||||
let mut marker = EntityType::Point(codec::entities::Point::at(
|
||||
codec::types::Vector3::new(world[0], world[1], world[2]),
|
||||
));
|
||||
super::view::dispatch::apply_transform(&mut marker, transform);
|
||||
let EntityType::Point(marker) = marker else {
|
||||
continue;
|
||||
};
|
||||
let location = marker.location;
|
||||
if let Some([x, y]) = plane.project([location.x, location.y, location.z]) {
|
||||
vertex.location.x = x;
|
||||
vertex.location.y = y;
|
||||
}
|
||||
}
|
||||
self.update_entity(EntityType::LwPolyline(moved));
|
||||
}
|
||||
whole
|
||||
}
|
||||
}
|
||||
@@ -386,6 +386,7 @@ pub fn apply_along(
|
||||
fill_is_3d: false,
|
||||
fill_is_2d_solid: false,
|
||||
render_instance: None,
|
||||
stub: None,
|
||||
pick_tris: Vec::new(),
|
||||
pick_tris_low: Vec::new(),
|
||||
dash_from_start: false,
|
||||
|
||||
+13
-4
@@ -518,10 +518,19 @@ fn fonts_map() -> &'static HashMap<String, Font> {
|
||||
pub fn is_builtin(name: &str) -> bool {
|
||||
let map = fonts_map();
|
||||
let key = name.trim().to_ascii_uppercase();
|
||||
map.contains_key(&key)
|
||||
|| key
|
||||
.rsplit_once('.')
|
||||
.is_some_and(|(stem, _)| map.contains_key(stem))
|
||||
let stem = key.rsplit_once('.').map_or(key.as_str(), |(stem, _)| stem);
|
||||
(map.contains_key(&key) || map.contains_key(stem)) && !installed_alias(stem, name)
|
||||
}
|
||||
|
||||
/// An alias names a real font (ISOCPEUR is a TrueType face): an installed
|
||||
/// copy of exactly that font draws it, Cyrillic included, where the LFF
|
||||
/// stand-in has no such glyphs (#1764).
|
||||
fn installed_alias(stem: &str, name: &str) -> bool {
|
||||
use crate::scene::text::sysfont;
|
||||
ALIASES.iter().any(|(alias, _)| *alias == stem)
|
||||
&& (sysfont::family_for_file(name).is_some()
|
||||
|| sysfont::canonical_family_name(stem)
|
||||
.is_some_and(|family| family.eq_ignore_ascii_case(stem)))
|
||||
}
|
||||
|
||||
pub fn get_font(name: &str) -> &'static Font {
|
||||
|
||||
+40
-6
@@ -617,13 +617,21 @@ fn interpret(
|
||||
if n_oct == 0.0 {
|
||||
n_oct = 8.0;
|
||||
}
|
||||
// The arc spans `n_oct` octants from `start_oct`, starting
|
||||
// `so`/256 of an octant into the first one and ending `eo`/256
|
||||
// into the last (0 = on its far boundary) (#1638).
|
||||
let q = std::f64::consts::FRAC_PI_4;
|
||||
let start = start_oct * q + so * q / 256.0 * if cw { -1.0 } else { 1.0 };
|
||||
let total = n_oct * q;
|
||||
let sweep_mag = total - so * q / 256.0 - (255.0 - eo) * q / 256.0
|
||||
+ if eo == 0.0 { 0.0 } else { 0.0 };
|
||||
let sweep = sweep_mag.max(0.0) * if cw { -1.0 } else { 1.0 };
|
||||
emit_arc(st, cur, out, radius, start, sweep);
|
||||
let dir = if cw { -1.0 } else { 1.0 };
|
||||
let start = (start_oct + dir * so / 256.0) * q;
|
||||
let end = if eo == 0.0 {
|
||||
(start_oct + dir * n_oct) * q
|
||||
} else {
|
||||
(start_oct + dir * (n_oct - 1.0 + eo / 256.0)) * q
|
||||
};
|
||||
let sweep = end - start;
|
||||
if sweep * dir > 0.0 {
|
||||
emit_arc(st, cur, out, radius, start, sweep);
|
||||
}
|
||||
}
|
||||
}
|
||||
0xC => {
|
||||
@@ -754,4 +762,30 @@ mod tests {
|
||||
);
|
||||
assert!(font_glyph(&path, 'B' as u16).is_none());
|
||||
}
|
||||
|
||||
/// The `9` program of the ISOCP fonts: a fractional arc into the stem,
|
||||
/// then octant arcs. Its ink spans 36 font units, the cap height; the
|
||||
/// fractional arc used to end short and drop the glyph to ~0.68 (#1638).
|
||||
#[test]
|
||||
fn fractional_arc_spans_its_octants() {
|
||||
const NINE: [u8; 25] = [
|
||||
0x02, 0x08, 0x0a, 0x02, 0x01, 0x0b, 0x6c, 0x00, 0x00, 0x12, 0x62, 0xb4, 0x0a, 0x08,
|
||||
0x04, 0x4c, 0x0a, 0x08, 0x42, 0x80, 0x02, 0x08, 0x06, 0xee, 0x00,
|
||||
];
|
||||
let mut b = b"AutoCAD-86 unifont 1.0\r\n\x1a".to_vec();
|
||||
b.extend_from_slice(&2u32.to_le_bytes());
|
||||
let head: Vec<u8> = [b"UNI\x00".as_slice(), &[36u8, 8, 2, 0, 0, 0]].concat();
|
||||
b.extend_from_slice(&(head.len() as u16).to_le_bytes());
|
||||
b.extend_from_slice(&head);
|
||||
let glyph: Vec<u8> = [b"\x00".as_slice(), &NINE].concat();
|
||||
b.extend_from_slice(&('9' as u16).to_le_bytes());
|
||||
b.extend_from_slice(&(glyph.len() as u16).to_le_bytes());
|
||||
b.extend_from_slice(&glyph);
|
||||
let path = tmp("nine.shx", &b);
|
||||
let g = font_glyph(&path, '9' as u16).expect("glyph for 9");
|
||||
let ys = g.strokes.iter().flatten().map(|p| p[1]);
|
||||
let (lo, hi) = ys.fold((f32::MAX, f32::MIN), |(lo, hi), y| (lo.min(y), hi.max(y)));
|
||||
// 36 font units at above = 36 → the 9-unit cap height.
|
||||
assert!(((hi - lo) - 9.0).abs() < 0.05, "ink height {}", hi - lo);
|
||||
}
|
||||
}
|
||||
@@ -22,8 +22,9 @@ struct SysFonts {
|
||||
families: Mutex<Vec<String>>,
|
||||
/// Installed face file name (lower-case) → the family that face holds.
|
||||
/// A drawing references a TrueType style by *file* name, so this is what
|
||||
/// makes such a style resolve — see [`family_for_reference`].
|
||||
by_file: HashMap<String, String>,
|
||||
/// makes such a style resolve — see [`family_for_reference`]. Files a host
|
||||
/// registers at run time join it ([`register_font_file`]).
|
||||
by_file: Mutex<HashMap<String, String>>,
|
||||
}
|
||||
|
||||
static FONTS: OnceLock<SysFonts> = OnceLock::new();
|
||||
@@ -60,7 +61,7 @@ fn fonts() -> &'static SysFonts {
|
||||
SysFonts {
|
||||
db: Mutex::new(db),
|
||||
families: Mutex::new(families),
|
||||
by_file,
|
||||
by_file: Mutex::new(by_file),
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -315,12 +316,17 @@ pub fn with_face_data_weighted<T>(
|
||||
|
||||
/// The family of the installed face whose file is named `name` — a bare file
|
||||
/// name or any path to one, matched case-insensitively.
|
||||
fn family_for_file(name: &str) -> Option<String> {
|
||||
pub(crate) fn family_for_file(name: &str) -> Option<String> {
|
||||
let file = name.rsplit(['/', '\\']).next()?.trim();
|
||||
if file.is_empty() {
|
||||
return None;
|
||||
}
|
||||
fonts().by_file.get(&file.to_ascii_lowercase()).cloned()
|
||||
fonts()
|
||||
.by_file
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(&file.to_ascii_lowercase())
|
||||
.cloned()
|
||||
}
|
||||
|
||||
/// The installed family a drawing's font reference names, or `None` when
|
||||
@@ -371,10 +377,70 @@ pub fn register_web_font(bytes: &[u8]) {
|
||||
canonical_cache().lock().unwrap().clear();
|
||||
}
|
||||
|
||||
/// Add a font file a host supplies at run time (the `font_register` control
|
||||
/// op): its faces join the database and `file_name` resolves to the family of
|
||||
/// the first face, so a text style naming that file finds it. Returns that
|
||||
/// family and the number of faces the file added, or `None` when the bytes
|
||||
/// hold no face fontdb can read. Like [`register_web_font`], the family list
|
||||
/// is rebuilt and the canonical-name memo cleared; callers invalidate the glyph
|
||||
/// caches and re-layout text.
|
||||
pub fn register_font_file(file_name: &str, bytes: &[u8]) -> Option<(String, usize)> {
|
||||
let file = file_name.rsplit(['/', '\\']).next()?.trim();
|
||||
if file.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let sys = fonts();
|
||||
let (family, faces, names) = {
|
||||
let mut db = sys.db.lock().unwrap();
|
||||
let before: std::collections::HashSet<fontdb::ID> = db.faces().map(|f| f.id).collect();
|
||||
db.load_font_data(bytes.to_vec());
|
||||
let added: Vec<String> = db
|
||||
.faces()
|
||||
.filter(|face| !before.contains(&face.id))
|
||||
.filter_map(|face| face.families.first().map(|(name, _)| name.clone()))
|
||||
.collect();
|
||||
let family = added.first()?.clone();
|
||||
(family, added.len(), family_names(&db))
|
||||
};
|
||||
sys.by_file
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(file.to_ascii_lowercase(), family.clone());
|
||||
*sys.families.lock().unwrap() = names;
|
||||
canonical_cache().lock().unwrap().clear();
|
||||
Some((family, faces))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A host-registered file resolves by its file name — any case, bare or
|
||||
/// with a path — to the family inside it; bytes without a face are refused
|
||||
/// and leave nothing behind.
|
||||
#[test]
|
||||
fn registered_font_file_resolves_by_file_name() {
|
||||
let bytes = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/web/fonts/greek.ttf"))
|
||||
.expect("bundled web font");
|
||||
let (family, faces) =
|
||||
register_font_file("C:\\Fonts\\HostRegistered-Test.ttf", &bytes).expect("a face");
|
||||
assert!(faces >= 1);
|
||||
assert!(!family.is_empty());
|
||||
assert_eq!(
|
||||
family_for_reference("hostregistered-test.TTF").as_deref(),
|
||||
Some(family.as_str())
|
||||
);
|
||||
assert_eq!(
|
||||
family_for_reference("fonts/HostRegistered-Test.ttf").as_deref(),
|
||||
Some(family.as_str())
|
||||
);
|
||||
assert!(has_family(&family));
|
||||
|
||||
assert_eq!(register_font_file("Broken-Test.ttf", b"not a font"), None);
|
||||
assert_eq!(family_for_reference("Broken-Test.ttf"), None);
|
||||
assert_eq!(register_font_file("", &bytes), None);
|
||||
}
|
||||
|
||||
/// A DWG stores a style's TrueType font as a file name, so every installed
|
||||
/// face has to be reachable by the name of its file — case-insensitively,
|
||||
/// and whether it comes as a bare name or as a path.
|
||||
|
||||
@@ -3,3 +3,4 @@ pub mod dispatch;
|
||||
pub mod render;
|
||||
pub mod transform;
|
||||
pub mod viewport_pane;
|
||||
pub use render::device_scale;
|
||||
+65
-15
@@ -385,6 +385,7 @@ impl shader::Primitive for Primitive {
|
||||
pipeline.gpu_error_epoch = errors_at_entry;
|
||||
}
|
||||
let scale = viewport.scale_factor() as f32;
|
||||
DEVICE_SCALE.store(scale.to_bits(), std::sync::atomic::Ordering::Relaxed);
|
||||
let instance_ids: Vec<u64> = self.viewports.iter().map(|vp| vp.instance_id).collect();
|
||||
let slots = pipeline.resolve_slots(device, queue, &instance_ids);
|
||||
for (i, vp) in self.viewports.iter().enumerate() {
|
||||
@@ -431,7 +432,14 @@ impl shader::Primitive for Primitive {
|
||||
vp.background_image.as_ref(),
|
||||
vp.environment_image.as_ref(),
|
||||
);
|
||||
inner.upload_uniforms(device, queue, &vp.uniforms);
|
||||
// The scene works in logical pixels; an image background keeps
|
||||
// its size in device pixels.
|
||||
let mut uniforms = vp.uniforms;
|
||||
// The image's size and offset are in device pixels.
|
||||
for value in &mut uniforms.background_image_transform {
|
||||
*value /= scale.max(1e-3);
|
||||
}
|
||||
inner.upload_uniforms(device, queue, &uniforms);
|
||||
|
||||
// ── Scene-render cache ────────────────────────────────────────
|
||||
// A pure cursor move — or any frame where the view, geometry,
|
||||
@@ -2034,6 +2042,11 @@ text_atlas={:.1} wire_arena={:.1} block_geometry={:.1}",
|
||||
}
|
||||
|
||||
impl Scene {
|
||||
/// The model-space viewport record of the active tile.
|
||||
pub(crate) fn active_tile_vport_handle(&self) -> Option<Handle> {
|
||||
self.model_tile_vport(self.active_model_tile.get()).map(|value| value.handle)
|
||||
}
|
||||
|
||||
fn model_tile_vport(&self, index: usize) -> Option<&codec::tables::VPort> {
|
||||
let rect = self.model_tiles.borrow().get(index)?.rect;
|
||||
let lower_left = [rect.x as f64, (1.0 - rect.y - rect.height) as f64];
|
||||
@@ -2547,30 +2560,32 @@ impl Scene {
|
||||
if viewport.paper_sheet {
|
||||
let handle = self.current_layout_sheet_viewport_handle();
|
||||
if let Some(EntityType::Viewport(value)) = self.document.get_entity(handle) {
|
||||
return build(
|
||||
value.visual_style_handle,
|
||||
value.brightness,
|
||||
value.contrast,
|
||||
value.background_handle,
|
||||
);
|
||||
// The sheet keeps its page and desk: a paper-space view's
|
||||
// background is kept with the layout but not drawn.
|
||||
return build(value.visual_style_handle, value.brightness, value.contrast, Handle::NULL);
|
||||
}
|
||||
} else if viewport.handle.is_valid() {
|
||||
if let Some(EntityType::Viewport(value)) = self.document.get_entity(viewport.handle) {
|
||||
let flat = value.render_mode == codec::entities::ViewportRenderMode::Wireframe2D;
|
||||
return build(
|
||||
value.visual_style_handle,
|
||||
value.brightness,
|
||||
value.contrast,
|
||||
value.background_handle,
|
||||
if flat { Handle::NULL } else { value.background_handle },
|
||||
);
|
||||
}
|
||||
} else if self.current_layout == "Model" {
|
||||
let index = viewport.tile_idx.unwrap_or(0);
|
||||
if let Some(value) = self.model_tile_vport(index) {
|
||||
// A 2D wireframe view shows no background, as in the reference.
|
||||
let flat = self.model_tiles.borrow().get(index).is_some_and(|tile| {
|
||||
tile.render_mode == codec::entities::ViewportRenderMode::Wireframe2D
|
||||
});
|
||||
return build(
|
||||
value.visual_style_handle,
|
||||
value.brightness,
|
||||
value.contrast,
|
||||
value.background_handle,
|
||||
if flat { Handle::NULL } else { value.background_handle },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2973,7 +2988,8 @@ impl Scene {
|
||||
2.0,
|
||||
background.horizon as f32,
|
||||
background.height as f32,
|
||||
background.rotation as f32,
|
||||
// Stored in degrees.
|
||||
(background.rotation as f32).to_radians(),
|
||||
];
|
||||
result
|
||||
}
|
||||
@@ -2999,11 +3015,13 @@ impl Scene {
|
||||
background.use_tiling as u8 as f32,
|
||||
image.width as f32 / image.height.max(1) as f32,
|
||||
];
|
||||
// Offset, then the image's size in pixels at its scale:
|
||||
// an image not fitted to the screen keeps its pixel size.
|
||||
result.image_transform = [
|
||||
background.offset.x as f32,
|
||||
background.offset.y as f32,
|
||||
background.scale.x as f32,
|
||||
background.scale.y as f32,
|
||||
background.scale.x as f32 * image.width as f32,
|
||||
background.scale.y as f32 * image.height as f32,
|
||||
];
|
||||
result.params[0] = 4.0;
|
||||
result.image = Some(image);
|
||||
@@ -3571,9 +3589,11 @@ pub struct InheritStyle {
|
||||
}
|
||||
|
||||
/// Convert a concrete (already layer-resolved) lineweight to display pixels.
|
||||
/// Kept below 1 px for thin weights so plots and SVG get the true width;
|
||||
/// the GPU paths draw at least 1 px (#1749).
|
||||
pub(crate) fn lineweight_to_px(lw: &LineWeight) -> f32 {
|
||||
lw.millimeters()
|
||||
.map(|mm| (mm as f32 * MM_TO_PX).max(1.0))
|
||||
.map(|mm| mm as f32 * MM_TO_PX)
|
||||
.unwrap_or(1.0)
|
||||
}
|
||||
|
||||
@@ -4038,6 +4058,18 @@ impl Scene {
|
||||
} else {
|
||||
WireModel::HOVER
|
||||
};
|
||||
if let Some(points) = selected
|
||||
.then(|| self.picked_segment_points(handle))
|
||||
.flatten()
|
||||
{
|
||||
wires.push(WireModel::solid_f64(
|
||||
handle.value().to_string(),
|
||||
points,
|
||||
tint,
|
||||
true,
|
||||
));
|
||||
continue;
|
||||
}
|
||||
for set in interaction_meshes
|
||||
.iter()
|
||||
.filter(|set| set.entity_handle() == Some(handle))
|
||||
@@ -4890,13 +4922,23 @@ impl Scene {
|
||||
// (hatched) frame once it settles. Only applied to the on-screen
|
||||
// Model / paper content — the paper *sheet* keeps its fills.
|
||||
skip_hatch: self.hatch_lod_enabled() && navigating,
|
||||
skip_background: !inst.paper_sheet && self.current_layout != "Model",
|
||||
// A floating viewport shows the page behind it unless it has a
|
||||
// background of its own.
|
||||
skip_background: !inst.paper_sheet
|
||||
&& self.current_layout != "Model"
|
||||
&& display.background.params[0] < 0.5,
|
||||
geometry_epoch: self.geometry_epoch,
|
||||
camera_generation: self.camera_generation,
|
||||
wire_content_id,
|
||||
wire_patch,
|
||||
selected_handles: Arc::new(if show_interaction {
|
||||
self.selected.iter().copied().collect()
|
||||
// A partly picked polyline lights only its picked segments,
|
||||
// drawn with the highlight wires.
|
||||
self.selected
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|handle| self.picked_segments(*handle).is_none())
|
||||
.collect()
|
||||
} else {
|
||||
rustc_hash::FxHashSet::default()
|
||||
}),
|
||||
@@ -5419,3 +5461,11 @@ mod layer0_inherit_tests {
|
||||
assert!(scene.document_render_env_cache.borrow().is_some());
|
||||
}
|
||||
}
|
||||
|
||||
/// The display's device pixels per logical pixel, as the viewport last drew.
|
||||
static DEVICE_SCALE: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0x3f80_0000);
|
||||
|
||||
/// Device pixels per logical pixel of the drawing viewport.
|
||||
pub fn device_scale() -> f32 {
|
||||
f32::from_bits(DEVICE_SCALE.load(std::sync::atomic::Ordering::Relaxed))
|
||||
}
|
||||
Reference in new issue
Block a user