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:
Claude committed 2026-10-14 19:10:33 +00:00
commit 451b0f5d78
231 files changed
+32217 -3423

No files matched your search

File diff suppressed because it is too large. Load diff
+239 -20
View File
@@ -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
View File
@@ -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(&current_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"]);
}
}
+742
View File
@@ -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(&parameter) {
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(&parameter)
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(&parameter)? {
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(&parameter)
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(&parameter)
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}");
}
}
}
+1
View File
@@ -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;
+243 -18
View File
@@ -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
View File
@@ -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,
}
}
+151
View File
@@ -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(&current.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,
}
}
+2
View File
@@ -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,
+21 -5
View File
@@ -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
View File
@@ -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());
}
}
}
+7
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+78 -1
View File
@@ -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` —
+127
View File
@@ -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(&copy, 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,
});
}
}
+7
View File
@@ -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;
+5
View File
@@ -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
View File
@@ -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 &copy_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(&copy_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(&copy_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(&copy_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(&copy_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(&copy_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(&copy_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");
}
}
}
}
+3
View File
@@ -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
}
+80
View File
@@ -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;
+1 -1
View File
@@ -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],
+1 -1
View File
@@ -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],
+17 -7
View File
@@ -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
View File
@@ -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();
+10 -17
View File
@@ -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()
}
+4 -4
View File
@@ -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.
+98 -15
View File
@@ -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,
],
+1
View File
@@ -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;
+229
View File
@@ -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
View File
@@ -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;
+159
View File
@@ -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
}
}
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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);
}
}
+71 -5
View File
@@ -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.
+1
View File
@@ -3,3 +3,4 @@ pub mod dispatch;
pub mod render;
pub mod transform;
pub mod viewport_pane;
pub use render::device_scale;
+65 -15
View File
@@ -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))
}