use super::*; #[derive(Debug, Clone)] pub struct CreateBlockOptions { pub name: String, pub handles: Vec, pub base_point: glam::DVec3, pub world_to_block: codec::types::Transform, pub block_to_world: codec::types::Transform, pub mode: crate::ui::window::block_definition::BlockObjectMode, pub annotative: bool, pub match_orientation: bool, pub scale_uniformly: bool, pub allow_exploding: bool, pub unit: i16, pub description: String, pub hyperlink_url: String, pub hyperlink_desc: String, pub redefine: bool, } /// Order sampled boundary edges into one tip-to-tail loop. pub(super) fn chain_path_edges(polys: Vec>) -> Vec<[f64; 2]> { chain_path_edges_with_directions(polys).0 } pub(super) fn chain_path_edges_with_directions( polys: Vec>, ) -> (Vec<[f64; 2]>, Vec) { let d2 = |a: [f64; 2], b: [f64; 2]| (a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2); let mut directions = vec![0.0; polys.len()]; let mut polys: Vec<_> = polys .into_iter() .enumerate() .filter(|(_, points)| !points.is_empty()) .collect(); if polys.is_empty() { return (Vec::new(), directions); } let (first_index, first) = polys.swap_remove(0); directions[first_index] = 1.0; let mut chain: std::collections::VecDeque<[f64; 2]> = first.into(); while !polys.is_empty() { let head = *chain.front().unwrap(); let tail = *chain.back().unwrap(); // (distance, index, reverse-points, attach-at-front) let mut best = (f64::MAX, 0usize, false, false); for (i, (_, p)) in polys.iter().enumerate() { let s = p[0]; let e = *p.last().unwrap(); for c in [ (d2(tail, s), i, false, false), (d2(tail, e), i, true, false), (d2(head, e), i, false, true), (d2(head, s), i, true, true), ] { if c.0 < best.0 { best = c; } } } let (_, idx, rev, at_front) = best; let (original_index, mut p) = polys.swap_remove(idx); directions[original_index] = if rev { -1.0 } else { 1.0 }; if rev { p.reverse(); } if at_front { if d2(p[p.len() - 1], head) < 1e-18 { p.pop(); } for q in p.into_iter().rev() { chain.push_front(q); } } else { let mut it = p.into_iter(); if let Some(first) = it.next() { if d2(first, tail) >= 1e-18 { chain.push_back(first); } } chain.extend(it); } } (chain.into(), directions) } fn family_from_stored_line( ln: &codec::entities::hatch::HatchPatternLine, ) -> crate::scene::model::hatch_model::PatFamily { let (ca, sa) = (ln.angle.cos(), ln.angle.sin()); let dx = ln.offset.x * ca + ln.offset.y * sa; let dy = -ln.offset.x * sa + ln.offset.y * ca; crate::scene::model::hatch_model::PatFamily { angle_deg: ln.angle.to_degrees() as f32, x0: 0.0, y0: 0.0, dx: dx as f32, dy: dy as f32, dashes: ln.dash_lengths.iter().map(|&d| d as f32).collect(), } } /// Preserve the selected hue while moving its HSL lightness towards the /// persisted one-colour tint/shade target (0 = black, 1 = white). fn gradient_tint_color(base: [f32; 4], target: f32) -> [f32; 4] { let max = base[0].max(base[1]).max(base[2]); let min = base[0].min(base[1]).min(base[2]); let lightness = (max + min) * 0.5; let target = target.clamp(0.0, 1.0); let mut result = base; if target <= lightness { let factor = if lightness > 1.0e-6 { target / lightness } else { 0.0 }; for channel in &mut result[..3] { *channel *= factor; } } else { let factor = if lightness < 1.0 - 1.0e-6 { (target - lightness) / (1.0 - lightness) } else { 1.0 }; for channel in &mut result[..3] { *channel += (1.0 - *channel) * factor; } } result } impl Scene { // ── Entity management ───────────────────────────────────────────────── /// Register `name` in the layer table if it isn't already there, giving the /// new layer a real handle so it survives a DWG save (handle-based format; /// issue #67). Called whenever an entity is added or edited: an entity that /// names a layer no explicit LAYER command ever created — e.g. one supplied /// by a plugin through `add_entity` — otherwise has no table entry, so the /// DWG writer resolves its layer name to a NULL handle and it reopens on /// layer 0. Auto-registering keeps it on its own layer (#252). Names are /// registered verbatim so the writer's (case-insensitive) lookup matches; /// the always-present default layer "0" and empty names are no-ops. pub fn ensure_layer(&mut self, name: &str) { if name.trim().is_empty() || self.document.layers.contains(name) { return; } let mut layer = codec::tables::Layer::new(name); layer.handle = self.document.allocate_handle(); let _ = self.document.layers.add(layer); } pub(crate) fn ensure_app_id(&mut self, name: &str) { if name.trim().is_empty() || self.document.app_ids.contains(name) { return; } let mut app_id = codec::tables::AppId::new(name); app_id.handle = self.document.allocate_handle(); let _ = self.document.app_ids.add(app_id); } fn prepare_section_objects(&mut self, entity: &mut EntityType) -> bool { use codec::entities::ExtendedEntityData; use codec::objects::{ ClassObject, ClassObjectData, SectionManager, SectionSettings, SectionTypeSettings, }; let EntityType::Extended(extended) = entity else { return false; }; let ExtendedEntityData::SectionObject(data) = &mut extended.data else { return false; }; if extended.common.handle.is_null() { extended.common.handle = self.document.allocate_handle(); } let entity_handle = extended.common.handle; let valid_settings = matches!( self.document.objects.get(&data.settings_handle), Some(ObjectType::ClassObject(object)) if matches!(&object.data, ClassObjectData::SectionSettings(_)) ); if !valid_settings { let settings_handle = self.document.allocate_handle(); let mut settings = ClassObject::new(ClassObjectData::SectionSettings(SectionSettings { current_type: 4, types: vec![SectionTypeSettings { section_type: 4, generation: 17, ..SectionTypeSettings::default() }], })); settings.handle = settings_handle; settings.owner = entity_handle; self.document .objects .insert(settings_handle, ObjectType::ClassObject(settings)); data.settings_handle = settings_handle; } else if let Some(ObjectType::ClassObject(settings)) = self.document.objects.get_mut(&data.settings_handle) { settings.owner = entity_handle; } let root = crate::scene::annotative::root_named_dict_handle(&mut self.document); let manager_handle = self .document .objects .get(&root) .and_then(|object| match object { ObjectType::Dictionary(dictionary) => dictionary .entries .iter() .find(|(name, _)| name.eq_ignore_ascii_case("ACAD_SECTION_MANAGER")) .map(|(_, handle)| *handle), _ => None, }) .filter(|handle| { matches!( self.document.objects.get(handle), Some(ObjectType::ClassObject(object)) if matches!(&object.data, ClassObjectData::SectionManager(_)) ) }) .unwrap_or_else(|| { let handle = self.document.allocate_handle(); let mut manager = ClassObject::new(ClassObjectData::SectionManager(SectionManager::default())); manager.handle = handle; manager.owner = root; self.document .objects .insert(handle, ObjectType::ClassObject(manager)); if let Some(ObjectType::Dictionary(dictionary)) = self.document.objects.get_mut(&root) { dictionary .entries .retain(|(name, _)| !name.eq_ignore_ascii_case("ACAD_SECTION_MANAGER")); dictionary.add_entry("ACAD_SECTION_MANAGER", handle); } handle }); if let Some(ObjectType::ClassObject(manager)) = self.document.objects.get_mut(&manager_handle) { manager.owner = root; if let ClassObjectData::SectionManager(manager) = &mut manager.data { if !manager.sections.contains(&entity_handle) { manager.sections.push(entity_handle); } } } true } pub fn add_entity(&mut self, entity: EntityType) -> Handle { self.add_entity_internal(entity, true) } /// Batch-add several entities, publishing geometry changes once at the end. /// This is the fast path used by plugin `add_entities` requests. #[cfg(not(target_arch = "wasm32"))] pub fn add_entities(&mut self, entities: Vec) -> Vec { let mut handles = Vec::with_capacity(entities.len()); let mut changes = Vec::with_capacity(entities.len()); let mut needs_geometry_bump = false; for entity in entities { let affects_blocks = matches!(&entity, EntityType::Block(_) | EntityType::BlockEnd(_)); let handle = self.add_entity_internal(entity, false); handles.push(handle); if handle.is_null() { continue; } if affects_blocks { needs_geometry_bump = true; } else { changes.push((handle, ChangeKind::Added)); } } if needs_geometry_bump { self.bump_geometry(); } else if !changes.is_empty() { self.bump_entities(&changes); } handles } fn add_entity_internal(&mut self, mut entity: EntityType, bump: bool) -> Handle { // Only block sentinels mutate a block definition and require rebuilding // the block cache. A top-level INSERT merely references an existing // definition, so adding it can patch just that new render handle. let affects_blocks = matches!(&entity, EntityType::Block(_) | EntityType::BlockEnd(_)); // INSERT invalidates rendered block instances, but it does not mutate // the referenced block definition. Only block sentinels require a // structure image for undo; ordinary owner membership is intrinsic add // bookkeeping and remains in place while an entity delta is undone. let mutates_block_structure = matches!(&entity, EntityType::Block(_) | EntityType::BlockEnd(_)); let creates_section_structure = self.prepare_section_objects(&mut entity); let hatch_seed = if let EntityType::Hatch(dxf) = &entity { let color = self.render_style(&entity).0; Self::hatch_model_from_dxf(dxf, color) } else if let EntityType::Solid(solid) = &entity { let color = self.render_style(&entity).0; Some(Self::solid_hatch_model(solid, color)) } else { None }; let image_seed = self.image_seed_for(&entity); let facet_res = self.document.header.facet_resolution; let chordal_deflection = crate::entities::solid3d::display_deflection(&self.document.header, facet_res); let isolines = crate::entities::solid3d::clamp_header_isolines(self.document.header.isolines); let mesh_seed = if matches!( &entity, EntityType::Solid3D(_) | EntityType::Region(_) | EntityType::Body(_) | EntityType::Surface(_) | EntityType::Mesh(_) | EntityType::PolygonMesh(_) | EntityType::PolyfaceMesh(_) ) { let color = self.render_style(&entity).0; crate::entities::solid3d::tessellate_volume( &entity, color, facet_res, chordal_deflection, isolines, ) .map(offset_mesh_lod_set) } else { None }; self.ensure_image_definition(&mut entity); // Register the entity's layer if it names one no LAYER command created // (e.g. a plugin-supplied layer) so it survives a DWG save instead of // collapsing to layer 0 in the reopened file (#252). let layer = entity.common().layer.clone(); // Delta-undo poison inputs (captured before the mutations below): an // add that also creates a new layer, adds a block, or inserts an image // definition mutates non-entity state a pure-entity delta can't undo. let creates_layer = self.is_recording_undo() && !layer.trim().is_empty() && !self.document.layers.contains(&layer); self.ensure_layer(&layer); let app_ids: Vec = entity .common() .extended_data .records() .iter() .map(|record| record.application_name.clone()) .collect(); let creates_app_id = self.is_recording_undo() && app_ids .iter() .any(|name| !name.trim().is_empty() && !self.document.app_ids.contains(name)); for name in &app_ids { self.ensure_app_id(name); } // Route to the correct block based on current editing mode: // - BEDIT block editor: geometry belongs to the edited block record, // so it becomes part of the block definition (issue #261). // - PSPACE (paper layout, no active viewport): paper-space layout block. // - MSPACE or model layout: model space (document default). let handle = if let Some(br) = self.block_edit_block { entity.common_mut().owner_handle = br; self.document.add_entity(entity).unwrap_or(Handle::NULL) } else if self.current_layout != "Model" && self.active_viewport.is_none() { let layout_name = self.current_layout.clone(); self.document .add_entity_to_layout(entity, &layout_name) .unwrap_or(Handle::NULL) } else { self.document.add_entity(entity).unwrap_or(Handle::NULL) }; if !handle.is_null() { if let Some(keep) = self.refedit_keep.as_mut() { keep.insert(handle); } self.invalidate_dependency_index(); if let Some(model) = hatch_seed { self.hatches.insert(handle, model); } if let Some(model) = image_seed { self.images.insert(handle, model); } if let Some(mut model) = mesh_seed { if let Some(entity) = self.document.get_entity(handle) { let color = self.render_style(entity).0; let material = crate::scene::model::material_model::resolve_material_with_base( &self.document, entity, color, None, self.material_base_dir.as_deref(), ); material.apply_to_with_face_overrides( &mut model, &self.document, self.material_base_dir.as_deref(), ); crate::scene::model::visual_style_model::apply_mesh_visual_style( &mut model, &self.document, entity, ); } self.meshes.insert(handle, model); } // Delta-undo: the new handle's before-image is "nothing" (it did not // exist). Poison the recording if this add also mutated non-entity // state (a new layer, application ID, or block). if self.is_recording_undo() { self.record_undo_before(handle, None); if creates_layer || creates_app_id || mutates_block_structure || creates_section_structure { self.poison_undo_recording(); } } if bump { if affects_blocks { self.bump_geometry(); } else { // Plain top-level add: name the new handle so every derived cache // patches in just this one entity instead of rebuilding. self.bump_entities(&[(handle, ChangeKind::Added)]); } } } handle } pub fn rename_layer(&mut self, old: &str, new: &str) -> bool { if self.document.rename_layer(old, new).is_err() { return false; } self.invalidate_dependency_index(); true } /// Rename a block definition: re-key its record, update the Block marker's /// name, and repoint every INSERT that referenced the old name so all /// instances keep resolving. Returns false if `old` is missing or /// anonymous/xref, `new` is invalid or already taken, or the names are /// equal (case-insensitive). (#261) pub fn rename_block(&mut self, old: &str, new: &str) -> bool { if !crate::scene::valid_block_name(new) { return false; } if old.eq_ignore_ascii_case(new) { return false; } if self.document.block_records.get(new).is_some() { return false; } // Anonymous (*) names are program-owned and re-numbered on save; an // xref('|') symbol name is bound to the referenced file. if self .document .block_records .get(old) .map(|br| br.is_anonymous() || br.flags.is_xref || br.name.contains('|')) .unwrap_or(true) { return false; } let Some(mut br) = self.document.block_records.remove(old) else { return false; }; let block_marker = br.block_entity_handle; br.name = new.to_string(); if self.document.block_records.add(br).is_err() { return false; } // Keep the Block marker entity's name in sync (used on DXF/DWG save). if let Some(EntityType::Block(b)) = self.document.get_entity_mut(block_marker) { b.name = new.to_string(); } // Repoint every INSERT reference so all instances keep resolving. for e in self.document.entities_mut() { if let EntityType::Insert(ins) = e { if ins.block_name.eq_ignore_ascii_case(old) { ins.block_name = new.to_string(); } } } self.invalidate_dependency_index(); self.bump_geometry(); true } /// Replace the entity stored under `entity`'s handle with `entity`, keeping /// its identity (handle + owning block), and refresh the derived /// hatch/image/mesh caches so the edit is visible. Returns `false` when no /// entity has that handle. This is the in-place counterpart to /// [`add_entity`](Self::add_entity) used to commit a plugin's edit of an /// existing entity. #[cfg_attr(target_arch = "wasm32", allow(dead_code))] pub fn update_entity(&mut self, entity: EntityType) -> bool { let mut changed = Vec::with_capacity(1); let mut block_change = false; if !self.update_entity_impl(entity, &mut changed, &mut block_change) { return false; } self.publish_entity_updates(changed, block_change); true } /// Batched variant of [`update_entity`]: writes every replacement, then /// issues a single geometry bump for the whole set instead of one bump per /// entity. pub fn update_entities(&mut self, updates: Vec) -> usize { let mut moved = 0usize; let mut changed = Vec::with_capacity(updates.len()); let mut block_change = false; for entity in updates { if self.update_entity_impl(entity, &mut changed, &mut block_change) { moved += 1; } } if !changed.is_empty() || block_change { self.publish_entity_updates(changed, block_change); } moved } /// One geometry bump for a set of slot writes already reflected in /// `changed` / `block_change`. fn publish_entity_updates(&mut self, changed: Vec<(Handle, ChangeKind)>, block_change: bool) { if block_change { self.bump_geometry(); } else { self.bump_entities(&changed); } } /// Shared write path for `update_entity` / `update_entities`: validates, /// records undo, writes the slot, and reseeds derived caches, recording /// the change instead of bumping geometry. fn update_entity_impl( &mut self, mut entity: EntityType, changed: &mut Vec<(Handle, ChangeKind)>, block_change: &mut bool, ) -> bool { let handle = entity.common().handle; if self.is_layer_locked(handle) { return false; } let Some(existing) = self.document.get_entity(handle) else { return false; }; // IPC snapshots omit storage-only payloads (notably Unknown/Extended // entities). Preserve them when replacing a clone from a plugin. entity.preserve_storage_data_from(existing); // The caller edited a snapshot copy; keep the live entity in its block. entity.common_mut().owner_handle = existing.common().owner_handle; // Replacing (or becoming) a block sentinel forces a full block-cache // rebuild. INSERT edits (including retargeting to another existing // definition) only change that top-level render handle. let affects_blocks = matches!(existing, EntityType::Block(_) | EntityType::BlockEnd(_)) || matches!(&entity, EntityType::Block(_) | EntityType::BlockEnd(_)); // A plugin edit may retarget the entity to a novel layer; register it // so the edited entity keeps that layer on save instead of collapsing // to layer 0 in the reopened file (#252). let new_layer = entity.common().layer.clone(); let creates_layer = self.is_recording_undo() && !new_layer.trim().is_empty() && !self.document.layers.contains(&new_layer); self.ensure_layer(&new_layer); // Rebuild the derived-model seeds from the new entity (as add_entity). let hatch_seed = if let EntityType::Hatch(dxf) = &entity { let color = self.render_style(&entity).0; Self::hatch_model_from_dxf(dxf, color) } else if let EntityType::Solid(solid) = &entity { let color = self.render_style(&entity).0; Some(Self::solid_hatch_model(solid, color)) } else { None }; let image_seed = self.image_seed_for(&entity); let facet_res = self.document.header.facet_resolution; let chordal_deflection = crate::entities::solid3d::display_deflection(&self.document.header, facet_res); let isolines = crate::entities::solid3d::clamp_header_isolines(self.document.header.isolines); let mesh_seed = if matches!( &entity, EntityType::Solid3D(_) | EntityType::Region(_) | EntityType::Body(_) | EntityType::Surface(_) | EntityType::Mesh(_) | EntityType::PolygonMesh(_) | EntityType::PolyfaceMesh(_) ) { let color = self.render_style(&entity).0; crate::entities::solid3d::tessellate_volume( &entity, color, facet_res, chordal_deflection, isolines, ) .map(offset_mesh_lod_set) } else { None }; // Delta-undo: capture the entity's pre-edit image (before the slot is // overwritten) so an undo can restore it, and poison if this replace // also created a layer or crossed a block boundary. if self.is_recording_undo() { let before = self.document.get_entity_arc(handle); self.record_undo_before(handle, before); if creates_layer || affects_blocks { self.poison_undo_recording(); } } // Write the new entity into the live slot. let Some(slot) = self.document.get_entity_mut(handle) else { return false; }; *slot = entity; self.invalidate_dependency_index(); // Drop stale derived caches for this handle, then reseed for the new // entity's type (which may differ from the old one). self.hatches.remove(&handle); self.images.remove(&handle); self.meshes.remove(&handle); self.solid_models.remove(&handle); if let Some(model) = hatch_seed { self.hatches.insert(handle, model); } if let Some(model) = image_seed { self.images.insert(handle, model); } if let Some(mut model) = mesh_seed { if let Some(entity) = self.document.get_entity(handle) { let color = self.render_style(entity).0; let material = crate::scene::model::material_model::resolve_material_with_base( &self.document, entity, color, None, self.material_base_dir.as_deref(), ); material.apply_to_with_face_overrides( &mut model, &self.document, self.material_base_dir.as_deref(), ); crate::scene::model::visual_style_model::apply_mesh_visual_style( &mut model, &self.document, entity, ); } self.meshes.insert(handle, model); } if affects_blocks { self.mark_entity_dirty(handle); *block_change = true; } else { // One entity changed in place: report just this handle so every // derived cache patches it instead of rebuilding (bump_entities also // drops it from the tessellation memos). changed.push((handle, ChangeKind::Modified)); } true } /// Rebuild the per-entity derived caches (hatch fill / raster image / solid /// mesh) for a single handle from whatever entity currently lives at it — /// or drop them all if the handle is now absent. Mirrors the reseed block in /// [`Scene::update_entity`]; used by delta-undo when it re-applies an /// entity's before / after image so the fills and meshes follow. /// Rebuild every underlay's raster and wire, after a setting they draw /// or snap by (PDFOSNAP / UOSNAP) changed. pub(crate) fn reseed_underlays(&mut self) { let handles: Vec = self .document .entities() .filter(|entity| matches!(entity, EntityType::Underlay(_))) .map(|entity| entity.common().handle) .collect(); for handle in &handles { self.reseed_derived_caches(*handle); } let changes: Vec<_> = handles .iter() .map(|handle| (*handle, crate::scene::ChangeKind::Modified)) .collect(); self.bump_entities(&changes); } pub(crate) fn reseed_derived_caches(&mut self, handle: Handle) { let (hatch_seed, image_seed) = match self.document.get_entity(handle) { None => (None, None), Some(entity) => { let hatch_seed = if let EntityType::Hatch(dxf) = entity { let color = self.render_style(entity).0; Self::hatch_model_from_dxf(dxf, color) } else if let EntityType::Solid(solid) = entity { let color = self.render_style(entity).0; Some(Self::solid_hatch_model(solid, color)) } else { None }; let image_seed = self.image_seed_for(entity); (hatch_seed, image_seed) } }; self.hatches.remove(&handle); self.images.remove(&handle); self.meshes.remove(&handle); self.solid_models.remove(&handle); if let Some(model) = hatch_seed { self.hatches.insert(handle, model); } if let Some(model) = image_seed { self.images.insert(handle, model); } self.refresh_meshes_for_handles(&[handle]); self.restore_solid_models(&[handle]); } pub fn restore_solid_models(&mut self, handles: &[Handle]) { let bodies: Vec<(Handle, kernel::brep::Body)> = handles .iter() .filter(|handle| !self.solid_models.contains_key(handle)) .filter_map(|&handle| { let from_history = self .document .solid_history_operations(handle) .and_then(|operations| kernel::acis::rebuild_history(&operations).ok()); let body = from_history.or_else(|| match self.document.get_entity(handle) { Some(EntityType::Solid3D(solid)) => { crate::scene::convert::solid3d_tess::kernel_body(solid) } Some(EntityType::Region(region)) => { crate::scene::convert::solid3d_tess::kernel_region_body(region) } Some(EntityType::Surface(surface)) => { crate::scene::convert::solid3d_tess::kernel_surface_body(surface) } _ => None, })?; Some((handle, body)) }) .collect(); self.solid_models.extend(bodies); } /// Re-tessellate only the named ACIS entities. The former edit path /// cleared and rebuilt every solid in the drawing when one selected solid /// moved or was copied. pub fn refresh_meshes_for_handles(&mut self, handles: &[Handle]) { if handles.is_empty() { return; } let mesh_entities: Vec<(Handle, std::sync::Arc)> = handles .iter() .filter_map(|&handle| { let entity = self.document.get_entity_arc(handle)?; matches!( entity.as_ref(), EntityType::Solid3D(_) | EntityType::Region(_) | EntityType::Body(_) | EntityType::Surface(_) | EntityType::Mesh(_) | EntityType::PolygonMesh(_) | EntityType::PolyfaceMesh(_) ) .then_some((handle, entity)) }) .collect(); for handle in handles { self.meshes.remove(handle); self.block_meshes.remove(handle); self.solid_models.remove(handle); } // The overwhelmingly common Undo/Redo target is 2-D geometry. Return // before scanning every Layout object just to discover there is no mesh // to rebuild. if mesh_entities.is_empty() { return; } let layout_blocks: std::collections::HashSet = self .document .objects .values() .filter_map(|o| match o { codec::objects::ObjectType::Layout(l) if !l.block_record.is_null() => { Some(l.block_record) } _ => None, }) .collect(); let entries: Vec<(Handle, std::sync::Arc, [f32; 4], bool)> = mesh_entities .into_iter() .map(|(handle, entity)| { let color = self.render_style(entity.as_ref()).0; let top_level = layout_blocks.contains(&entity.common().owner_handle); (handle, entity, color, top_level) }) .collect(); let facet_res = self.document.header.facet_resolution; let chordal_deflection = crate::entities::solid3d::display_deflection(&self.document.header, facet_res); let isolines = crate::entities::solid3d::clamp_header_isolines(self.document.header.isolines); use crate::par::prelude::*; let built: Vec<(Handle, MeshLodSet, bool)> = entries .into_par_iter() .filter_map(|(handle, entity, color, top_level)| { crate::entities::solid3d::tessellate_volume( entity.as_ref(), color, facet_res, chordal_deflection, isolines, ) .map(|mut mesh| { crate::scene::model::material_model::resolve_material_with_base( &self.document, entity.as_ref(), color, None, self.material_base_dir.as_deref(), ) .apply_to_with_face_overrides( &mut mesh, &self.document, self.material_base_dir.as_deref(), ); crate::scene::model::visual_style_model::apply_mesh_visual_style( &mut mesh, &self.document, entity.as_ref(), ); let mesh = if top_level { offset_mesh_lod_set(mesh) } else { mesh }; (handle, mesh, top_level) }) }) .collect(); for (handle, mut mesh, top_level) in built { if top_level { self.meshes.insert(handle, mesh); } else { mesh.prepare_instance_source(handle); self.block_meshes.insert(handle, mesh); } } } /// Returns the RGBA color for the given layer name. pub fn layer_color(&self, layer: &str) -> [f32; 4] { let layer_entry = self.document.layers.get(layer); let color = layer_entry .map(|l| &l.color) .unwrap_or(&codec::types::Color::WHITE); let [r, g, b, _] = crate::scene::convert::tess_util::aci_to_rgba(color); let alpha = layer_entry .map(|layer| 1.0 - layer.transparency.as_percent() as f32) .unwrap_or(1.0); [r, g, b, alpha] } pub fn custom_block_names(&self) -> Vec { self.document .block_records .iter() .filter(|br| !br.is_standard() && !br.is_layout()) .map(|br| br.name.clone()) .collect() } pub fn create_block_from_entities( &mut self, handles: &[Handle], name: &str, world_to_block: &codec::types::Transform, block_to_world: &codec::types::Transform, ) -> Result { self.create_block_with_options(CreateBlockOptions { name: name.to_string(), handles: handles.to_vec(), base_point: glam::DVec3::ZERO, world_to_block: *world_to_block, block_to_world: *block_to_world, mode: crate::ui::window::block_definition::BlockObjectMode::Convert, annotative: false, match_orientation: false, scale_uniformly: false, allow_exploding: true, unit: 0, description: String::new(), hyperlink_url: String::new(), hyperlink_desc: String::new(), redefine: false, }) } pub fn create_block_with_options( &mut self, options: CreateBlockOptions, ) -> Result { let name = options.name.trim(); if name.is_empty() { return Err("Block name cannot be empty.".into()); } if name.starts_with('*') { return Err("Block name cannot start with '*'.".into()); } let existing = self .document .block_records .get(name) .map(|br| (br.handle, br.block_entity_handle)); let (br_handle, _block_handle) = if let Some((existing_br_h, existing_blk_h)) = existing { if !options.redefine { return Err(format!("Block \"{name}\" already exists.")); } // Redefining: remove all existing entities owned by this block record (except Block/BlockEnd) let owned: Vec = self .document .entities() .filter(|e| { e.common().owner_handle == existing_br_h && !matches!(e, EntityType::Block(_) | EntityType::BlockEnd(_)) }) .map(|e| e.common().handle) .collect(); for h in owned { self.document.remove_entity(h); } // Update existing BlockRecord fields if let Some(br) = self.document.block_records.get_mut(name) { br.units = options.unit; br.description = options.description.clone(); br.explodable = options.allow_exploding; br.scale_uniformly = options.scale_uniformly; } // Update existing Block entity fields if let Some(EntityType::Block(b)) = self.document.get_entity_mut(existing_blk_h) { b.description = options.description.clone(); } if options.annotative { let annotative_vals = vec![codec::xdata::XDataValue::Integer16(1)]; view::dispatch::set_entity_xdata( &mut self.document, existing_blk_h, "AcadAnnotative", Some(annotative_vals), ); } if !options.hyperlink_url.is_empty() { let link_vals = vec![codec::xdata::XDataValue::String( options.hyperlink_url.clone(), )]; view::dispatch::set_entity_xdata( &mut self.document, existing_blk_h, "PE_URL", Some(link_vals), ); } (existing_br_h, existing_blk_h) } else { let next = self.document.next_handle(); let br_handle = Handle::new(next); let block_handle = Handle::new(next + 1); let end_handle = Handle::new(next + 2); let mut block_record = codec::tables::BlockRecord::new(name); block_record.handle = br_handle; block_record.block_entity_handle = block_handle; block_record.block_end_handle = end_handle; block_record.units = options.unit; block_record.description = options.description.clone(); block_record.explodable = options.allow_exploding; block_record.scale_uniformly = options.scale_uniformly; self.document .block_records .add(block_record) .map_err(|e| e.to_string())?; let mut block = Block::new(name, codec::types::Vector3::ZERO); block.common.handle = block_handle; block.common.owner_handle = br_handle; block.description = options.description.clone(); self.document .add_entity(EntityType::Block(block)) .map_err(|e| e.to_string())?; let mut block_end = BlockEnd::new(); block_end.common.handle = end_handle; block_end.common.owner_handle = br_handle; self.document .add_entity(EntityType::BlockEnd(block_end)) .map_err(|e| e.to_string())?; if options.annotative { let annotative_vals = vec![codec::xdata::XDataValue::Integer16(1)]; view::dispatch::set_entity_xdata( &mut self.document, block_handle, "AcadAnnotative", Some(annotative_vals), ); } if !options.hyperlink_url.is_empty() { let link_vals = vec![codec::xdata::XDataValue::String( options.hyperlink_url.clone(), )]; view::dispatch::set_entity_xdata( &mut self.document, block_handle, "PE_URL", Some(link_vals), ); } (br_handle, block_handle) }; let source_entities: Vec<_> = options .handles .iter() .filter_map(|&h| self.document.get_entity(h).cloned().map(|e| (h, e))) .collect(); if source_entities.is_empty() { return Err("No valid entities selected for block creation.".into()); } let local = EntityTransform::Affine(options.world_to_block); for (old_handle, mut entity) in source_entities { view::dispatch::apply_transform(&mut entity, &local); entity = crate::modules::draw::modify::explode::normalize_entity_for_block(entity); entity.common_mut().handle = Handle::NULL; entity.common_mut().owner_handle = br_handle; self.document .add_entity(entity) .map_err(|e| e.to_string())?; if options.mode != crate::ui::window::block_definition::BlockObjectMode::Retain { self.erase_entities(&[old_handle]); } } let mut insert_handle = Handle::NULL; if options.mode == crate::ui::window::block_definition::BlockObjectMode::Convert { let mut insert = DxfInsert::new(name, codec::types::Vector3::ZERO); codec::Entity::apply_transform(&mut insert, &options.block_to_world); insert_handle = self.add_entity(EntityType::Insert(insert)); } // Advance the block epoch so all consumers and viewport renderers re-tessellate self.bump_geometry(); Ok(insert_handle) } /// Define a new block named `name` from `entities` (owned, not yet in the /// document), with `base` as its insertion origin. Unlike /// [`create_block_from_entities`] this does NOT place an insert — the /// caller starts an interactive insert so paste-as-block can prompt for the /// drop point. The geometry comes from the clipboard rather than live /// entities, so there is nothing to stage or erase. (#129) /// Give a new RasterImage the ImageDefinition object it must reference, /// unless it already names one. fn ensure_image_definition(&mut self, entity: &mut EntityType) { let EntityType::RasterImage(img) = entity else { return; }; if img.definition_handle.is_some() { return; } use codec::objects::{ImageDefinition, ObjectType}; // Allocate, not peek: a peeked handle is then handed to the image // itself, and the DWG writer drops the definition that shares it. (#1421) let def_handle = self.document.allocate_handle(); if self.is_recording_undo() { self.record_undo_object_before(def_handle, None); } let mut img_def = ImageDefinition::with_dimensions(&img.file_path, img.size.x as u32, img.size.y as u32); img_def.handle = def_handle; img_def.is_loaded = true; self.document .objects .insert(def_handle, ObjectType::ImageDefinition(img_def)); img.definition_handle = Some(def_handle); } pub fn define_block_from_owned_entities( &mut self, entities: Vec, name: &str, base: glam::DVec3, ) -> Result, String> { let name = name.trim(); if name.is_empty() { return Err("Block name cannot be empty.".into()); } if name.starts_with('*') { return Err("Block name cannot start with '*'.".into()); } if self.document.block_records.get(name).is_some() { return Err(format!("Block \"{name}\" already exists.")); } if entities.is_empty() { return Err("Nothing to make into a block.".into()); } let next = self.document.next_handle(); let br_handle = Handle::new(next); let block_handle = Handle::new(next + 1); let end_handle = Handle::new(next + 2); let mut block_record = codec::tables::BlockRecord::new(name); block_record.handle = br_handle; block_record.block_entity_handle = block_handle; block_record.block_end_handle = end_handle; self.document .block_records .add(block_record) .map_err(|e| e.to_string())?; let mut block = Block::new(name, codec::types::Vector3::ZERO); block.common.handle = block_handle; block.common.owner_handle = br_handle; self.document .add_entity(EntityType::Block(block)) .map_err(|e| e.to_string())?; let mut block_end = BlockEnd::new(); block_end.common.handle = end_handle; block_end.common.owner_handle = br_handle; self.document .add_entity(EntityType::BlockEnd(block_end)) .map_err(|e| e.to_string())?; let local = EntityTransform::Translate(-base); let mut entity_handles = Vec::with_capacity(entities.len()); for mut entity in entities { view::dispatch::apply_transform(&mut entity, &local); entity = crate::modules::draw::modify::explode::normalize_entity_for_block(entity); Self::reset_clone_subhandles(&mut self.document, &mut entity); entity.common_mut().handle = Handle::NULL; entity.common_mut().owner_handle = br_handle; self.ensure_image_definition(&mut entity); let handle = self .document .add_entity(entity) .map_err(|e| e.to_string())?; entity_handles.push(handle); } // Block defns don't render on their own, but the geometry cache must // pick up the new definition so the interactive insert can preview it. self.bump_geometry(); Ok(entity_handles) } /// Recreate a block definition verbatim — the entities are already in /// block-local coordinates (unlike `define_block_from_owned_entities`, /// which folds in a base offset). No-op if the block already exists. /// Used when pasting an INSERT whose block this drawing lacks. (#135) pub fn define_block_raw( &mut self, name: &str, base_point: codec::types::Vector3, entities: Vec, ) { if name.is_empty() || self.document.block_records.get(name).is_some() { return; } let next = self.document.next_handle(); let br_handle = Handle::new(next); let block_handle = Handle::new(next + 1); let end_handle = Handle::new(next + 2); let mut block_record = codec::tables::BlockRecord::new(name); block_record.handle = br_handle; block_record.block_entity_handle = block_handle; block_record.block_end_handle = end_handle; if self.document.block_records.add(block_record).is_err() { return; } let mut block = Block::new(name, base_point); block.common.handle = block_handle; block.common.owner_handle = br_handle; let _ = self.document.add_entity(EntityType::Block(block)); let mut block_end = BlockEnd::new(); block_end.common.handle = end_handle; block_end.common.owner_handle = br_handle; let _ = self.document.add_entity(EntityType::BlockEnd(block_end)); for mut entity in entities { Self::reset_clone_subhandles(&mut self.document, &mut entity); entity.common_mut().handle = Handle::NULL; entity.common_mut().owner_handle = br_handle; let _ = self.document.add_entity(entity); } self.bump_geometry(); } /// Replace the contents and base point of block `name`, defining it when /// absent. References keep pointing at it and show the new contents. pub fn redefine_block_raw( &mut self, name: &str, base_point: codec::types::Vector3, entities: Vec, ) { 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); }; let owned: Vec = self .document .entities() .filter(|e| { e.common().owner_handle == br_handle && !matches!(e, EntityType::Block(_) | EntityType::BlockEnd(_)) }) .map(|e| e.common().handle) .collect(); for h in owned { self.document.remove_entity(h); } if let Some(EntityType::Block(block)) = self.document.get_entity_mut(block_handle) { block.base_point = base_point; } for mut entity in entities { Self::reset_clone_subhandles(&mut self.document, &mut entity); entity.common_mut().handle = Handle::NULL; entity.common_mut().owner_handle = br_handle; let _ = self.document.add_entity(entity); } self.bump_geometry(); } pub(super) fn synced_hatch_models( &self, target_block: Handle, frozen: Option<&rustc_hash::FxHashSet>, annotation_scale_handle: Option, all_visible: bool, viewport: Option, tint_selected: bool, ) -> Vec { let layer_hidden = |layer: &str| { self.document .layers .get(layer) .map(|l| l.flags.off || l.flags.frozen) .unwrap_or(false) }; // synced_hatch_models is cached on geometry_epoch and the GPU // upload is keyed on geometry_epoch only (see render.rs — hatch // buffers are "static"). Don't view-cull here; the per-frame // skip flag in compute_hatch_lod handles frustum + sub-pixel // culling at draw time, which keeps the GPU upload set stable // across pan/zoom. // // Every content viewport supplies the block it renders. Do not depend // on camera/frustum culling to separate paper and model coordinates: // overlapping coordinates otherwise make foreign fills visible. let hatch_bg = if self.current_layout != "Model" { self.paper_bg_color } else { self.bg_color }; let depth_map = self.draw_depth_map(); let mut models: Vec = self .hatches .iter() .filter(|(&handle, _)| { let Some(entity) = self.document.get_entity(handle) else { return true; }; // SOLID already renders through its WCS-aware fill triangles. // Its cached HatchModel is an XY-only plot fallback; sending // that to the screen too adds a flattened, grip-less copy at // z=0 for elevated or angled geometry (#617). if matches!(entity, EntityType::Solid(_)) { return false; } let c = entity.common(); if c.invisible || self.entity_temporarily_hidden(handle) || layer_hidden(&c.layer) || crate::scene::annotative::annotative_offscale_for( &self.document, c, annotation_scale_handle, all_visible, ) { return false; } // Per-viewport layer freeze: a content viewport that freezes // this layer hides its fills too, not just its wires. if self.layer_frozen_in(&c.layer, frozen) { return false; } // Reject block-defn-only hatches (entities owned by a // BLOCK record that's neither model nor a paper layout // block) — the scene graph emits only their laid-out copies. self.belongs_to_visible_block(handle, c.owner_handle, target_block) }) .flat_map(|(&handle, model)| { let contextual = self.document.get_entity(handle).map(|entity| { crate::scene::annotative::entity_for_annotation_context( &self.document, entity, annotation_scale_handle, ) }); let entity = contextual.as_deref(); let mut m = match entity { Some(EntityType::Hatch(dxf)) if crate::scene::annotative::active_object_context_for_scale( &self.document, handle, annotation_scale_handle, ) .is_some() => { Self::hatch_model_from_dxf(dxf, model.color) .unwrap_or_else(|| model.clone()) } _ => model.clone(), }; // Optional solid backdrop drawn behind the pattern/gradient when // the hatch carries a HATCHBACKGROUNDCOLOR. Same draw_depth + // emitted first so LessEqual layering keeps it underneath. let mut backdrop: Option = None; if let Some(e) = entity { let mut style = crate::scene::view::render::render_style_for_viewport( &self.document, e, viewport, ); style.0 = crate::scene::view::render::adapt_to_bg(style.0, hatch_bg); m.aci = style.4; m.line_weight_px = style.3; // A gradient's colour is its first stop (already baked into // the cached model); only solid / pattern fills take the // entity's resolved colour. if !matches!(m.pattern, model::hatch_model::HatchPattern::Gradient { .. }) { m.color = style.0; } if let EntityType::Hatch(dxf) = e { if let Some(bg) = crate::entities::hatch::background_color(dxf) { let mut b = m.clone(); b.pattern = model::hatch_model::HatchPattern::Solid; // ByLayer / ByBlock backgrounds resolve through the // normal style chain instead of the raw ACI table // (#415). let (bg_color, bg_aci) = match bg { codec::types::Color::ByLayer => { let layer = self.document.layers.get(&dxf.common.layer); let aci = layer .and_then(|layer| match &layer.color { codec::types::Color::Index(index) => Some(*index), _ => None, }) .unwrap_or(0); ( crate::scene::view::render::layer_render_style_viewport( &self.document, &dxf.common.layer, viewport, ) .color, aci, ) } codec::types::Color::ByBlock => (style.0, style.4), codec::types::Color::Index(index) => ( crate::scene::convert::tess_util::aci_to_rgba( &codec::types::Color::Index(index), ), index, ), other => (crate::scene::convert::tess_util::aci_to_rgba(&other), 0), }; b.color = bg_color; b.aci = bg_aci; b.name = "SOLID".into(); backdrop = Some(b); } match &mut m.pattern { // Pattern built from the hatch's own stored lines is // already final (scale 1 / angle 0) — don't re-apply // pattern_scale/angle. Only the catalog-derived path // (empty stored lines) needs the override. model::hatch_model::HatchPattern::Pattern(_) if dxf.pattern.lines.is_empty() => { m.angle_offset = dxf.pattern_angle as f32; m.scale = dxf.pattern_scale as f32; } model::hatch_model::HatchPattern::Gradient { angle_deg, shift, .. } => { *angle_deg = dxf.gradient_color.angle.to_degrees() as f32; *shift = dxf.gradient_color.shift as f32; } model::hatch_model::HatchPattern::Pattern(_) | model::hatch_model::HatchPattern::Solid => {} } } } if tint_selected && self.selected.contains(&handle) { m.color = [0.15, 0.55, 1.00, m.color[3]]; } let d = depth_map.get(&handle.value()).map_or(0.0, |d| d[0]); m.draw_depth = d; if let Some(b) = &mut backdrop { b.draw_depth = d; } backdrop.into_iter().chain(std::iter::once(m)) }) .collect(); // Background for adapting block-child hatch colours at the leaf (#221). // Instanced/owned hatch leaves are produced by the shared scene graph. models.extend(self.instanced_hatch_models( target_block, hatch_bg, tint_selected, frozen, annotation_scale_handle, all_visible, viewport, )); // Wide polyline bands remain on the wire path, including inside block // instances; the graph does not reclassify them as hatch fills. models } /// Materialize hatch leaves reached through visible scene containers in /// `layout_block`, with transforms and inherited styles already resolved. /// /// Shared by the on-screen hatch set (`synced_hatch_models`) and the /// paper/export hatch set (`paper_canvas_hatches`) so a plot draws /// block-internal hatches identically to the viewport. /// /// `hatch_bg` adapts pure black/white leaf colours to the target /// background; `tint_selected` re-colours fills of a selected INSERT /// (screen highlight) and should be `false` for export. pub(super) fn instanced_hatch_models( &self, layout_block: Handle, hatch_bg: [f32; 4], tint_selected: bool, frozen: Option<&rustc_hash::FxHashSet>, annotation_scale_handle: Option, all_visible: bool, viewport: Option, ) -> Vec { self.instanced_hatch_models_filtered( layout_block, hatch_bg, tint_selected, frozen, annotation_scale_handle, all_visible, viewport, None, false, false, ) } fn layer_plottable_in_context( &self, entity: &EntityType, context: &crate::scene::render_graph::InstanceContext, ) -> bool { let common = entity.common(); let layer = if crate::scene::view::render::is_effective_layer_zero(&common.layer) { context .insert_path .iter() .rev() .find(|insert| { !crate::scene::view::render::is_effective_layer_zero(&insert.common.layer) }) .map(|insert| insert.common.layer.as_str()) .unwrap_or(common.layer.as_str()) } else { common.layer.as_str() }; self.document .layers .get(layer) .map(|layer| layer.is_plottable) .unwrap_or(true) } pub(super) fn instanced_plot_hatch_models( &self, layout_block: Handle, hatch_bg: [f32; 4], frozen: Option<&rustc_hash::FxHashSet>, annotation_scale_handle: Option, all_visible: bool, viewport: Option, ) -> Vec { self.instanced_hatch_models_filtered( layout_block, hatch_bg, false, frozen, annotation_scale_handle, all_visible, viewport, None, false, true, ) } /// Build live hatch overlays for INSERT grip previews. The edited INSERT /// is intentionally hidden from the resident scene while its current /// document entity moves, so include that hidden target and reuse the full /// block-expansion path for nested inserts, inherited styles and XCLIP. pub(super) fn preview_insert_hatch_models(&self, handles: &[Handle]) -> Vec { let targets: rustc_hash::FxHashSet = handles .iter() .copied() .filter(|&handle| { matches!( self.document.get_entity(handle), Some(EntityType::Insert(_)) ) }) .collect(); if targets.is_empty() { return Vec::new(); } let hatch_bg = if self.current_layout != "Model" { self.paper_bg_color } else { self.bg_color }; let frozen: rustc_hash::FxHashSet = self .interaction_viewport_frozen_layers() .into_iter() .flatten() .copied() .collect(); self.instanced_hatch_models_filtered( self.interaction_block_handle(), hatch_bg, true, (!frozen.is_empty()).then_some(&frozen), self.displayed_annotation_scale_handle(), self.annotation_all_visible(), self.active_viewport, Some(&targets), true, false, ) } fn instanced_hatch_models_filtered( &self, layout_block: Handle, hatch_bg: [f32; 4], tint_selected: bool, frozen: Option<&rustc_hash::FxHashSet>, annotation_scale_handle: Option, all_visible: bool, viewport: Option, targets: Option<&rustc_hash::FxHashSet>, include_preview_hidden: bool, plot_only: bool, ) -> Vec { let depth_map = self.draw_depth_map(); let graph = crate::scene::render_graph::RenderSceneGraph::new( &self.document, frozen, annotation_scale_handle, all_visible, depth_map.as_ref(), ) .with_annotation_scale(self.annotation_scale) .with_viewport(viewport); let mut hatch_block_memo = std::collections::HashMap::new(); let mut models = Vec::new(); let mut hatch_sources = rustc_hash::FxHashMap::default(); graph.walk_root( self.render_scene_root(layout_block), |entity, context| { let common = entity.common(); if plot_only && !self.layer_plottable_in_context(entity, context) { return false; } if context.is_instanced() { return true; } if self.object_isolation.hides(common.handle) || (!include_preview_hidden && self.preview_hidden.contains(&common.handle)) || self.hidden_dynamic_dimensions.contains(&common.handle) // A dynamic dimension never plots. || (plot_only && self.is_dynamic_dimension(common.handle)) { return false; } if let Some(targets) = targets { return matches!(entity, EntityType::Insert(_)) && targets.contains(&common.handle); } let block_uses: Vec<_> = crate::scene::render_graph::entity_render_block_uses( &self.document, entity, 1.0, ) .into_iter() .filter(|block_use| block_use.active) .collect(); if block_uses.is_empty() { true } else { block_uses.into_iter().any(|block_use| { crate::scene::render_graph::block_contains_hatch( &self.document, &block_use.insert.block_name, &mut hatch_block_memo, ) }) } }, |entity, context| { if !context.is_instanced() { return; } let EntityType::Hatch(source_hatch) = entity else { return; }; if plot_only && !self.layer_plottable_in_context(entity, context) { return; } let style = context.style_for(&self.document, entity); let preserve_white_mask = source_hatch.is_solid && matches!(source_hatch.common.color, codec::types::Color::Index(7)); let color = if preserve_white_mask { style.0 } else { crate::scene::view::render::adapt_to_bg(style.0, hatch_bg) }; let mut placed = EntityType::Hatch(source_hatch.clone()); placed.apply_transform(&context.transform); let EntityType::Hatch(hatch) = placed else { return; }; let Some(mut model) = Self::hatch_model_from_dxf(&hatch, color) else { return; }; model.aci = style.4; model.line_weight_px = style.3; model.draw_depth = context.draw_depth(source_hatch.common.handle, depth_map.as_ref()); for clip in &context.clips { let clip: Vec<[f32; 2]> = clip .iter() .map(|point| [point[0] as f32, point[1] as f32]) .collect(); let clipped = pick::xclip::clip_hatch_boundary( &model.boundary, model.world_origin, &clip, ); if clipped.is_empty() { return; } model.boundary = std::sync::Arc::new(clipped); } if !context.clips.is_empty() { // The plane-local outline is a second copy of the boundary // that viewport plotting prefers; rebuild it from the // clipped outline or the XCLIP is lost there. (#1508) model.fill_plane_boundary = model.fill_plane.map(|plane| { let origin = glam::DVec3::from_array(plane.origin); let (x_axis, y_axis) = ( glam::DVec3::from_array(plane.x_axis), glam::DVec3::from_array(plane.y_axis), ); std::sync::Arc::new( model .boundary .iter() .map(|&[x, y]| { if x.is_nan() || y.is_nan() { return [f32::NAN, f32::NAN]; } let world = glam::DVec3::new( model.world_origin[0] + x as f64, model.world_origin[1] + y as f64, origin.z, ); let local = world - origin; [local.dot(x_axis) as f32, local.dot(y_axis) as f32] }) .collect(), ) }); } if tint_selected && self.selected.contains(&context.root_handle) { model.color = [0.15, 0.55, 1.00, model.color[3]]; } let matrix = &context.transform.matrix.m; let linear = [ matrix[0][0].to_bits(), matrix[0][1].to_bits(), matrix[0][2].to_bits(), matrix[1][0].to_bits(), matrix[1][1].to_bits(), matrix[1][2].to_bits(), matrix[2][0].to_bits(), matrix[2][1].to_bits(), matrix[2][2].to_bits(), ]; let key = ( source_hatch.common.handle.value(), linear, model .boundary .iter() .flat_map(|point| point.map(f32::to_bits)) .collect::>(), model.color.map(f32::to_bits), model.angle_offset.to_bits(), model.scale.to_bits(), model.line_weight_px.to_bits(), model.aci, ); let source_id = *hatch_sources .entry(key) .or_insert_with(crate::scene::model::instance_model::next_source_id); model.render_instance = Some(crate::scene::model::instance_model::RenderInstance { source_id, translation: [matrix[0][3], matrix[1][3], matrix[2][3]], }); models.push(model); }, ); models } /// Wipeout fill models — rendered in a separate pass AFTER wires so that /// wipeouts correctly mask everything below them in the draw order. pub(crate) fn wipeout_models( &self, target_block: Handle, frozen: Option<&rustc_hash::FxHashSet>, annotation_scale_handle: Option, all_visible: bool, ) -> Vec { let bg_color = if self.current_layout != "Model" { self.paper_bg_color } else { self.bg_color }; self.wipeout_models_for_block_graph( target_block, frozen, annotation_scale_handle, all_visible, bg_color, false, false, ) } pub(super) fn wipeout_models_for_block_graph( &self, target_block: Handle, frozen: Option<&rustc_hash::FxHashSet>, annotation_scale_handle: Option, all_visible: bool, bg_color: [f32; 4], tint_insert_selection: bool, plot_only: bool, ) -> Vec { let depth_map = self.draw_depth_map(); let graph = crate::scene::render_graph::RenderSceneGraph::new( &self.document, frozen, annotation_scale_handle, all_visible, depth_map.as_ref(), ) .with_annotation_scale(self.annotation_scale); let mut models = Vec::new(); let mut wipeout_sources = rustc_hash::FxHashMap::default(); graph.walk_root( self.render_scene_root(target_block), |entity, context| { let common = entity.common(); if plot_only && !self.layer_plottable_in_context(entity, context) { return false; } context.is_instanced() || !self.entity_temporarily_hidden(common.handle) }, |entity, context| { let EntityType::Wipeout(source) = entity else { return; }; let mut wipeout = source.clone(); if context.is_instanced() { wipeout.insertion_point = context.transform.apply(source.insertion_point); wipeout.u_vector = context.transform.apply_rotation(source.u_vector); wipeout.v_vector = context.transform.apply_rotation(source.v_vector); } let Some(mut fill_plane) = Self::wipeout_fill_plane(&wipeout) else { return; }; let (world_origin, mut boundary) = Self::wipeout_boundary_2d(&wipeout); for clip in &context.clips { let clip: Vec<[f32; 2]> = clip .iter() .map(|point| [point[0] as f32, point[1] as f32]) .collect(); boundary = pick::xclip::clip_hatch_boundary(&boundary, world_origin, &clip); } if boundary.len() < 3 { return; } if !context.clips.is_empty() { let Some(local) = Self::wipeout_boundary_at_xy(fill_plane.0, world_origin, &boundary) else { return; }; fill_plane.1 = local; } let selection_handle = if tint_insert_selection && context.is_instanced() { context.root_handle } else { source.common.handle }; let color = if self.selected.contains(&selection_handle) { [0.15, 0.55, 1.00, 0.35] } else { bg_color }; let render_instance = if context.is_instanced() { let matrix = &context.transform.matrix.m; let linear = [ matrix[0][0].to_bits(), matrix[0][1].to_bits(), matrix[0][2].to_bits(), matrix[1][0].to_bits(), matrix[1][1].to_bits(), matrix[1][2].to_bits(), matrix[2][0].to_bits(), matrix[2][1].to_bits(), matrix[2][2].to_bits(), ]; let key = ( source.common.handle.value(), linear, boundary .iter() .flat_map(|point| point.map(f32::to_bits)) .collect::>(), color.map(f32::to_bits), ); let source_id = *wipeout_sources .entry(key) .or_insert_with(crate::scene::model::instance_model::next_source_id); Some(crate::scene::model::instance_model::RenderInstance { source_id, translation: [matrix[0][3], matrix[1][3], matrix[2][3]], }) } else { None }; let fill_plane_boundary = Some(Arc::new(fill_plane.1)); let fill_plane = Some(fill_plane.0); models.push(HatchModel { pattern_origin: None, render_instance, boundary: Arc::new(boundary), boundary_wcs: None, fill_plane, fill_plane_boundary, boundary_exterior: None, boundary_sources: None, boundary_paths: None, style: codec::entities::HatchStyleType::Normal, pattern: model::hatch_model::HatchPattern::Solid, name: "WIPEOUT_FILL".into(), color, aci: 0, line_weight_px: 1.0, angle_offset: 0.0, scale: 1.0, world_origin, draw_depth: context.draw_depth(source.common.handle, depth_map.as_ref()), }); }, ); models } /// Compute the 2D (XY) boundary polygon for a Wipeout entity. /// Wipeout fill boundary as small f32 offsets from the returned world_origin /// (the insertion point, kept in f64). pub(super) fn wipeout_boundary_2d( wo: &codec::entities::Wipeout, ) -> ([f64; 2], Vec<[f32; 2]>) { let origin = [wo.insertion_point.x, wo.insertion_point.y]; let plane = kernel::space::Plane::from_axes( [ wo.insertion_point.x, wo.insertion_point.y, wo.insertion_point.z, ], [wo.u_vector.x, wo.u_vector.y, wo.u_vector.z], [wo.v_vector.x, wo.v_vector.y, wo.v_vector.z], ); let boundary = Self::wipeout_local_boundary(wo) .into_iter() .map(|point| { if point[0].is_finite() && point[1].is_finite() { let offset = plane.vector_at(point); [offset[0] as f32, offset[1] as f32] } else { [f32::NAN; 2] } }) .collect(); (origin, boundary) } fn wipeout_local_boundary(wo: &codec::entities::Wipeout) -> Vec<[f64; 2]> { use codec::entities::{WipeoutClipMode, WipeoutClipType}; let mut outer = vec![ [0.0, 0.0], [wo.size.x, 0.0], [wo.size.x, wo.size.y], [0.0, wo.size.y], [0.0, 0.0], ]; if !wo.clipping_enabled || wo.clip_boundary_vertices.len() < 3 || !matches!(wo.clip_type, WipeoutClipType::Polygonal) { return outer; } let mut clip: Vec<[f64; 2]> = wo .clip_boundary_vertices .iter() .map(|point| [point.x + wo.size.x * 0.5, wo.size.y * 0.5 - point.y]) .collect(); if clip.last() != clip.first() { clip.push(clip[0]); } if matches!(wo.clip_mode, WipeoutClipMode::Inside) { outer.push([f64::NAN; 2]); outer.extend(clip); outer } else { clip } } fn wipeout_fill_plane( wipeout: &codec::entities::Wipeout, ) -> Option<(model::hatch_model::FillPlane, Vec<[f32; 2]>)> { let origin = [ wipeout.insertion_point.x, wipeout.insertion_point.y, wipeout.insertion_point.z, ]; let x_axis = [wipeout.u_vector.x, wipeout.u_vector.y, wipeout.u_vector.z]; let y_axis = [wipeout.v_vector.x, wipeout.v_vector.y, wipeout.v_vector.z]; kernel::space::Plane::from_axes(origin, x_axis, y_axis).normal()?; let boundary = Self::wipeout_local_boundary(wipeout) .into_iter() .map(|point| [point[0] as f32, point[1] as f32]) .collect(); Some(( model::hatch_model::FillPlane { origin, x_axis, y_axis, }, boundary, )) } fn wipeout_boundary_at_xy( plane: model::hatch_model::FillPlane, world_origin: [f64; 2], boundary: &[[f32; 2]], ) -> Option> { let plane = kernel::space::Plane::from_axes(plane.origin, plane.x_axis, plane.y_axis); boundary .iter() .map(|point| { if point[0].is_finite() && point[1].is_finite() { plane .coordinates_at_xy([ world_origin[0] + point[0] as f64, world_origin[1] + point[1] as f64, ]) .map(|point| [point[0] as f32, point[1] as f32]) } else { Some([f32::NAN; 2]) } }) .collect() } pub(crate) fn hatch_model_from_dxf(dxf: &DxfHatch, color: [f32; 4]) -> Option { let normal = (dxf.normal.x, dxf.normal.y, dxf.normal.z); // Build the boundary in f64 first so the precision-preserving // origin computation below sees full WCS precision. We only cast // to f32 once at the end, after subtracting the AABB centre, so // the stored offsets are small-magnitude with high f32 precision // even on large UTM-scale drawings. let to_xy = |x: f64, y: f64| -> [f64; 2] { let (wx, wy, _) = crate::scene::view::transform::ocs_point_to_wcs((x, y, dxf.elevation), normal); [wx, wy] }; if dxf.paths.is_empty() { return None; } let mut rings = Vec::new(); let mut local_rings = Vec::new(); let mut ring_sources = Vec::new(); for path in &dxf.paths { // Skip TEXTBOX boundary paths (flag bit 3). These are text // derived bounding boxes used for island detection; they are // never drawn or filled. Treating one as a fill boundary paints its // rectangle solid and creates a phantom bar. if path.flags.bits() & 8 != 0 { continue; } let mut edge_polys: Vec> = Vec::new(); for edge in &path.edges { if let Some(curve) = crate::entities::hatch::edge_curve(edge) { edge_polys.push(curve.tessellate_angle(kernel::tessellation::DEFAULT_ANGLE)); } } let mut local_ring = chain_path_edges(edge_polys); if local_ring.is_empty() { continue; } if local_ring.len() >= 3 { let first = local_ring[0]; let last = *local_ring.last().unwrap(); if (first[0] - last[0]).abs() > 1e-5 || (first[1] - last[1]).abs() > 1e-5 { local_ring.push(first); } } let ring = local_ring .iter() .map(|point| to_xy(point[0], point[1])) .collect(); rings.push(ring); local_rings.push((local_ring, path)); ring_sources.push(path.boundary_handles.clone()); } if rings.is_empty() { return None; } let depths = kernel::geom2d::ring_nesting_depths(&rings); let mut boundary = Vec::new(); let mut local_boundary = Vec::new(); let mut boundary_exterior = Vec::new(); let mut boundary_sources = Vec::new(); let mut spline_paths = Vec::new(); for (((ring, (local_ring, path)), sources), depth) in rings .into_iter() .zip(local_rings) .zip(ring_sources) .zip(depths) { let keep = match dxf.style { codec::entities::HatchStyleType::Normal => true, codec::entities::HatchStyleType::Outer => depth <= 1, codec::entities::HatchStyleType::Ignore => depth == 0, }; if !keep { continue; } if !boundary.is_empty() { boundary.push([f64::NAN, f64::NAN]); local_boundary.push([f32::NAN, f32::NAN]); } let range = boundary.len()..boundary.len() + ring.len(); boundary.extend(ring); local_boundary.extend(local_ring.iter().map(|&[x, y]| [x as f32, y as f32])); if path.edges.iter().any(|edge| { matches!(edge, codec::entities::BoundaryEdge::Spline(_)) }) { spline_paths.push((path, local_ring, range)); } boundary_exterior.push(depth == 0); boundary_sources.push(sources); } // The batched hatch renderer keeps boundaries in a GPU storage // buffer (no fixed length), so a hatch with many island loops must // retain *every* loop or even-odd island detection breaks. The old // flat `truncate(1024)` cut complex multi-loop hatches mid-boundary: // trailing islands were dropped and the final partial loop was left // open, flipping the even-odd parity so the fill bled across the // rest of the shape. Only guard against pathological vertex counts, // and when trimming, cut at a whole-loop (NaN sentinel) boundary so // no sub-loop is ever left open. (#148) const MAX_HATCH_MODEL_VERTS: usize = 16_384; if boundary.len() > MAX_HATCH_MODEL_VERTS { // Drop only whole trailing loops: cut at the last NaN sentinel // at/before the cap. If the first loop alone exceeds the cap, // keep it whole rather than leaving it open. let cut = boundary[..MAX_HATCH_MODEL_VERTS] .iter() .rposition(|&[x, y]| x.is_nan() || y.is_nan()) .unwrap_or(boundary.len()); boundary.truncate(cut); } // When the HATCH carries its own resolved pattern-line geometry // (angle + world-unit offset, exactly as the DWG stores it), use THAT // instead of re-deriving spacing from the name-matched catalog entry // × pattern_scale. The catalog's base spacing (e.g. metric acadiso // ANSI31 = 3.175) rarely matches what the drawing was authored against // (imperial 0.125), so the catalog path rendered lines up to ~25× // (= inch→mm) too coarse — a dense fill collapsed to a few stray // lines. The stored offset is the authoritative world-unit spacing, so // the resulting families are already final: no pattern_scale / angle // is re-applied (see `prebaked` below and the HatchModel fields). let prebaked = !dxf.is_solid && !dxf.gradient_color.is_enabled() && !dxf.pattern.lines.is_empty(); // The gradient's first stop is the fill's start colour (not the // entity colour); capture it so the HatchModel draws stop-0 → stop-1. let mut gradient_color1: Option<[f32; 4]> = None; let mut pattern = if dxf.gradient_color.is_enabled() { let stop = |i: usize| { dxf.gradient_color .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, 1.0]) }; gradient_color1 = stop(0); let color1 = gradient_color1.unwrap_or(color); let color2 = if dxf.gradient_color.is_single_color { gradient_tint_color(color1, dxf.gradient_color.color_tint as f32) } else { stop(1).unwrap_or(color) }; let angle_deg = dxf.gradient_color.angle.to_degrees() as f32; let (kind, invert) = model::hatch_model::GradientKind::from_name(&dxf.gradient_color.name); model::hatch_model::HatchPattern::Gradient { angle_deg, color2, kind, invert, shift: dxf.gradient_color.shift as f32, } } else if dxf.is_solid { model::hatch_model::HatchPattern::Solid } else if prebaked { model::hatch_model::HatchPattern::Pattern( dxf.pattern .lines .iter() .map(family_from_stored_line) .collect(), ) } else { let pat_name = &dxf.pattern.name; if let Some(entry) = crate::scene::model::hatch_patterns::find(pat_name) { entry.gpu.clone() } else if matches!( dxf.pattern_type, codec::entities::hatch::HatchPatternType::UserDefined ) { // User-defined hatch: parallel lines at `pattern_angle`, spaced // `pattern_scale` apart, plus a perpendicular set when // `is_double`. Its name ("_USER") is not a catalog pattern. // Build BASE families (angle 0, and 90 for the cross set) with // unit perpendicular spacing; the HatchModel's angle_offset // (= pattern_angle) and scale (= pattern_scale) below rotate and // space them — exactly as a predefined .PAT pattern is applied — // so the angle/scale is applied once, not doubled. Replaces the // old fallback that forced every user-defined hatch to flat // horizontal lines at the wrong spacing (#278). let fam = |angle_deg: f32| model::hatch_model::PatFamily { angle_deg, x0: 0.0, y0: 0.0, dx: 0.0, dy: 1.0, dashes: vec![], }; let mut fams = vec![fam(0.0)]; if dxf.is_double { fams.push(fam(90.0)); } model::hatch_model::HatchPattern::Pattern(fams) } else { model::hatch_model::HatchPattern::Pattern(vec![model::hatch_model::PatFamily { angle_deg: 0.0, x0: 0.0, y0: 0.0, dx: 0.0, dy: 5.0 * dxf.pattern_scale as f32, dashes: vec![], }]) } }; let name = if dxf.gradient_color.is_enabled() { dxf.gradient_color.name.clone() } else if dxf.is_solid { "SOLID".into() } else { dxf.pattern.name.clone() }; // Precision-preserving cast f64 → f32: pick an `world_origin` // anchor (boundary AABB centre in f64) and store every vertex // as a small f32 offset from it. NaN separators are preserved // so the in_polygon ray-cast still sees the path breaks. let mut min_x = f64::INFINITY; let mut min_y = f64::INFINITY; let mut max_x = f64::NEG_INFINITY; let mut max_y = f64::NEG_INFINITY; for &[x, y] in &boundary { if x.is_finite() && y.is_finite() { if x < min_x { min_x = x; } if y < min_y { min_y = y; } if x > max_x { max_x = x; } if y > max_y { max_y = y; } } } let world_origin = if min_x.is_finite() && min_y.is_finite() { [(min_x + max_x) * 0.5, (min_y + max_y) * 0.5] } else { [0.0, 0.0] }; // Anchor the prebaked families near the geometry, tracking the pattern // origin. The DWG base points sit at the pattern's *authored* origin, // which on a UTM drawing can be ~1e6 units from the hatch. Handing the // shader `base - world_origin` (~1e6) is wrong two ways: it consumes // `x0/y0` as f32 (so ~0.5 m quantization shreds the phase — boundaries // are unaffected, they ride the double-single relative-to-eye path), AND // it evaluates the pattern that far from its origin. Multi-family // aggregate patterns (AR-CONC, AR-SAND, GRAVEL, …) are effectively // quasi-periodic: their families only cohere into the intended // stones/grains near their shared origin and dissolve into scattered // dashes at large offsets. // // So fold the origin's offset from `world_origin` down to a small, // coherence-safe remainder on a grid of `spacing * 64` (many multiples // of the pattern spacing — well inside the coherence range yet far // larger than any realistic grip drag). Applying ONE common fold (from // the reference line) preserves each family's relative phase, which is // what forms the stones. Because the origin grip / Origin X/Y edit // shifts every base point by the same delta, the sub-grid remainder // tracks that delta 1:1, so the grip still moves the fill; a grid // crossing (a whole `spacing * 64` drag) is never hit in practice. if prebaked { if let model::hatch_model::HatchPattern::Pattern(fams) = &mut pattern { if let Some(rb) = dxf.pattern.lines.first().map(|l| l.base_point) { let spacing = dxf .pattern .lines .iter() .map(|l| l.offset.length()) .fold(0.0_f64, f64::max) .max(1e-6); let cell = spacing * 64.0; let fold = |v: f64, o: f64| -> f64 { let d = v - o; d - (d / cell).round() * cell }; let ox = fold(rb.x, world_origin[0]); let oy = fold(rb.y, world_origin[1]); for (fam, ln) in fams.iter_mut().zip(dxf.pattern.lines.iter()) { fam.x0 = (ln.base_point.x - rb.x + ox) as f32; fam.y0 = (ln.base_point.y - rb.y + oy) as f32; } } } } let project = |point: [f64; 2]| { let [x, y] = to_xy(point[0], point[1]); [(x - world_origin[0]) as f32 as f64, (y - world_origin[1]) as f32 as f64] }; for (path, ring, range) in spline_paths.into_iter().rev() { if range.end > boundary.len() { continue; } let curves = || path.edges.iter().filter_map(crate::entities::hatch::edge_curve).collect(); if let Some(refined) = kernel::geom2d::refine_spline_boundary(&ring, curves, &project) { if boundary.len() - range.len() + refined.len() > MAX_HATCH_MODEL_VERTS { continue; } boundary.splice(range.clone(), refined.iter().map(|p| to_xy(p[0], p[1]))); local_boundary.splice(range, refined.into_iter().map(|[x, y]| [x as f32, y as f32])); } } let boundary_f32: Vec<[f32; 2]> = boundary .iter() .map(|&[x, y]| { if x.is_finite() && y.is_finite() { [(x - world_origin[0]) as f32, (y - world_origin[1]) as f32] } else { [f32::NAN, f32::NAN] } }) .collect(); let storage = crate::entities::curve::ocs_plane(dxf.normal, dxf.elevation); Some(HatchModel { pattern_origin: Some([dxf.pattern_origin().x, dxf.pattern_origin().y]), render_instance: None, boundary: std::sync::Arc::new(boundary_f32), boundary_wcs: None, fill_plane: Some(model::hatch_model::FillPlane { origin: storage.origin, x_axis: storage.x_axis, y_axis: storage.y_axis, }), fill_plane_boundary: Some(std::sync::Arc::new(local_boundary)), boundary_exterior: Some(std::sync::Arc::new(boundary_exterior)), boundary_sources: Some(std::sync::Arc::new(boundary_sources)), boundary_paths: Some(std::sync::Arc::new(dxf.paths.clone())), style: dxf.style, pattern, name, // A gradient starts from its first stop; other fills use the // entity colour. color: gradient_color1.unwrap_or(color), aci: 0, line_weight_px: 1.0, angle_offset: if prebaked { 0.0 } else { dxf.pattern_angle as f32 }, scale: if prebaked { 1.0 } else { dxf.pattern_scale as f32 }, world_origin, draw_depth: 0.0, }) } /// Decode and cache all RasterImage entities from the current document. /// Silently skips images whose files cannot be read. pub fn populate_images_from_document(&mut self) { self.populate_images_from_document_unbumped(); self.bump_geometry(); } fn populate_images_from_document_unbumped(&mut self) { self.images.clear(); let entries: Vec<(Handle, codec::entities::EntityType)> = self .document .entities() .filter(|e| { matches!( e, EntityType::RasterImage(_) | EntityType::Ole2Frame(_) | EntityType::Underlay(_) ) }) .map(|e| (e.common().handle, e.clone())) .collect(); for (handle, entity) in entries { if let Some(model) = self.image_seed_for(&entity) { self.images.insert(handle, model); } } } /// Rebuild the cached fill model (hatch / DXF SOLID) for `handle` after /// its document entity was edited in place. The fill models are prebuilt /// at load, so a pattern-scale / background / boundary edit stays /// invisible until the cached model is refreshed (#415). pub fn refresh_fill_model(&mut self, handle: Handle) { let contextual = self.document.get_entity(handle).map(|entity| { crate::scene::annotative::entity_for_annotation_context( &self.document, entity, self.displayed_annotation_scale_handle(), ) }); let new_model = match contextual.as_deref() { Some(EntityType::Hatch(dxf)) => { let color = convert::tess_util::aci_to_rgba(&dxf.common.color); Self::hatch_model_from_dxf(dxf, color) } Some(EntityType::Solid(s)) => { let color = convert::tess_util::aci_to_rgba(&s.common.color); Some(Self::solid_hatch_model(s, color)) } _ => None, }; if let Some(model) = new_model { self.hatches.insert(handle, model); } } pub fn populate_hatches_from_document(&mut self) { self.populate_hatches_from_document_unbumped(); self.bump_geometry(); } fn populate_hatches_from_document_unbumped(&mut self) { self.hatches.clear(); let entries: Vec<(Handle, EntityType)> = self .document .entities() .filter_map(|e| match e { EntityType::Hatch(h) => Some(( h.common.handle, crate::scene::annotative::entity_for_annotation_context( &self.document, e, self.displayed_annotation_scale_handle(), ) .into_owned(), )), EntityType::Solid(s) => Some((s.common.handle, e.clone())), _ => None, }) .collect(); use crate::par::prelude::*; self.hatches = entries .into_par_iter() .filter_map(|(handle, kind)| { // Paper-space entities live in sheet coordinates — world_offset must not let model = match &kind { EntityType::Hatch(dxf) => { let color = convert::tess_util::aci_to_rgba(&dxf.common.color); Self::hatch_model_from_dxf(dxf, color) } EntityType::Solid(solid) => { let color = convert::tess_util::aci_to_rgba(&solid.common.color); Some(Self::solid_hatch_model(solid, color)) } _ => None, }; model.map(|m| (handle, m)) }) .collect(); } /// ImageModel for an image-bearing entity. `None` when it cannot decode. pub(super) fn image_seed_for( &self, entity: &codec::entities::EntityType, ) -> Option { match entity { EntityType::RasterImage(img) => ImageModel::from_raster_image(img), EntityType::Ole2Frame(ole) => ImageModel::from_ole2frame(ole), EntityType::Underlay(u) => match self.document.objects.get(&u.definition_handle) { Some(codec::objects::ObjectType::UnderlayDefinition(def)) => { // A paper-space underlay adjusts to the sheet, not the canvas. let owner = u.common.owner_handle; let background = if owner.is_null() || owner == self.model_space_block_handle() { self.bg_color } else { self.paper_bg_color }; ImageModel::from_underlay(u, def, background, self.underlay_world_per_pixel) } _ => None, }, _ => None, } } pub fn populate_meshes_from_document(&mut self) { self.populate_meshes_impl(false, true); } /// Like [`populate_meshes_from_document`] but tessellates only solids /// whose handle is not already cached — the existing meshes are kept. /// /// Used after an XREF merge: the host document's solids were already /// tessellated by the background loader, and the merge assigns brand-new /// handles to every imported xref entity (see `merge_xref_into_block`), /// so cached handles are guaranteed to be host solids. This turns the /// post-xref pass from "re-tessellate host + all xrefs" into "tessellate /// only the newly merged xref solids" — the dominant cost when a drawing /// attaches several large xrefs. (#203) pub fn populate_missing_meshes_from_document(&mut self) { self.populate_meshes_impl(true, true); } fn populate_meshes_impl(&mut self, incremental: bool, bump: bool) { if !incremental { self.meshes.clear(); self.block_meshes.clear(); } // BLOCK-entity handles of the layout (model + paper) blocks. A solid // owned by one of these is top-level; anything else lives in a block // definition and is instanced per INSERT instead. (#123) let layout_blocks: std::collections::HashSet = self .document .objects .values() .filter_map(|o| match o { codec::objects::ObjectType::Layout(l) if !l.block_record.is_null() => { Some(l.block_record) } _ => None, }) .collect(); // Resolve color through `render_style` so the same bg adaptation // wires use kicks in (pure black on dark bg → white, pure white // on light bg → black). Without this, ACIS meshes ignore // `adapt_to_bg` and stay invisible against matching bg colours. let entries: Vec<(Handle, EntityType, [f32; 4], bool)> = self .document .entities() .filter_map(|e| match e { EntityType::Solid3D(_) | EntityType::Region(_) | EntityType::Body(_) | EntityType::Surface(_) | EntityType::Mesh(_) | EntityType::PolygonMesh(_) | EntityType::PolyfaceMesh(_) => { let handle = e.common().handle; // Incremental (post-xref) pass: leave already-tessellated // host solids untouched, only build the newly merged ones. if incremental && (self.meshes.contains_key(&handle) || self.block_meshes.contains_key(&handle)) { return None; } let color = self.render_style(e).0; let top_level = layout_blocks.contains(&e.common().owner_handle); Some((handle, e.clone(), color, top_level)) } _ => None, }) .collect(); use crate::par::prelude::*; let facet_res = self.document.header.facet_resolution; let chordal_deflection = crate::entities::solid3d::display_deflection(&self.document.header, facet_res); let isolines = crate::entities::solid3d::clamp_header_isolines(self.document.header.isolines); // Top-level solids: offset into the render frame, drawn flat. // Block-definition solids: keep block-local coords for per-INSERT // instancing (no offset applied here). let built: Vec<(Handle, MeshLodSet, bool)> = entries .into_par_iter() .filter_map(|(handle, entity, color, top_level)| { crate::entities::solid3d::tessellate_volume( &entity, color, facet_res, chordal_deflection, isolines, ) .map(|mut mesh| { let material = crate::scene::model::material_model::resolve_material_with_base( &self.document, &entity, color, None, self.material_base_dir.as_deref(), ); material.apply_to_with_face_overrides( &mut mesh, &self.document, self.material_base_dir.as_deref(), ); crate::scene::model::visual_style_model::apply_mesh_visual_style( &mut mesh, &self.document, &entity, ); let mesh = if top_level { offset_mesh_lod_set(mesh) } else { mesh }; (handle, mesh, top_level) }) }) .collect(); for (handle, mut m, top_level) in built { if top_level { self.meshes.insert(handle, m); } else { m.prepare_instance_source(handle); self.block_meshes.insert(handle, m); } } if bump { self.bump_geometry(); } } /// Rebuild hatch / image / mesh caches after the document is modified /// outside the normal `add_entity` path (e.g. REFCLOSE SAVE). pub fn rebuild_derived_caches(&mut self) { self.invalidate_dependency_index(); self.populate_hatches_from_document_unbumped(); self.populate_images_from_document_unbumped(); self.populate_meshes_impl(false, false); self.bump_geometry(); } /// Build a solid-fill HatchModel for a DXF Solid entity. /// SOLID corners use Z-order. Preserve it in the projected hatch as well so /// intentionally crossing geometry is not silently rewritten. pub(super) fn solid_hatch_model(solid: &DxfSolid, color: [f32; 4]) -> HatchModel { // Keep the corners in f64 until the AABB centre is known, then store // each as a small f32 offset from it — same precision-preserving anchor // `hatch_model_from_dxf` uses. Casting the absolute WCS corner straight // to f32 costs ~0.06 units of resolution at UTM magnitudes (~1e6), so // the quad snapped to a grid and the fill drifted off its outline. let wcs = crate::entities::solid::wcs_corners(solid); let order = [0, 1, 3, 2]; let corners: [[f64; 2]; 4] = order.map(|index| [wcs[index][0], wcs[index][1]]); let mut min = [f64::INFINITY; 2]; let mut max = [f64::NEG_INFINITY; 2]; for c in &corners { for i in 0..2 { if c[i] < min[i] { min[i] = c[i]; } if c[i] > max[i] { max[i] = c[i]; } } } let world_origin = if min[0].is_finite() && min[1].is_finite() { [(min[0] + max[0]) * 0.5, (min[1] + max[1]) * 0.5] } else { [0.0, 0.0] }; let boundary = corners .iter() .map(|c| { [ (c[0] - world_origin[0]) as f32, (c[1] - world_origin[1]) as f32, ] }) .collect(); HatchModel { pattern_origin: None, render_instance: None, boundary: std::sync::Arc::new(boundary), boundary_wcs: None, fill_plane: None, fill_plane_boundary: None, boundary_exterior: None, boundary_sources: None, boundary_paths: None, style: codec::entities::HatchStyleType::Normal, pattern: model::hatch_model::HatchPattern::Solid, name: "SOLID".into(), color, aci: 0, line_weight_px: 1.0, angle_offset: 0.0, scale: 1.0, world_origin, draw_depth: 0.0, } } pub fn add_hatch( &mut self, model: HatchModel, layer: Option<&str>, entity_style: Option<(codec::types::Color, codec::types::Transparency)>, ) -> Handle { let mut dxf = DxfHatch::new(); dxf.style = model.style; if let Some(plane) = model.fill_plane { let x = glam::DVec3::from_array(plane.x_axis); let y = glam::DVec3::from_array(plane.y_axis); let normal = x.cross(y).normalize_or(glam::DVec3::Z); dxf.normal = codec::types::Vector3::new(normal.x, normal.y, normal.z); dxf.elevation = glam::DVec3::from_array(plane.origin).dot(normal); } dxf.is_solid = matches!( model.pattern, crate::scene::model::hatch_model::HatchPattern::Solid ); // Persist analytic command geometry when available. if let Some(paths) = model.boundary_paths.as_deref() { dxf.paths = paths.clone(); } else { // Otherwise reconstruct every ring from render offsets. let reconstructed_wcs: Vec<[f64; 2]> = if model.boundary_wcs.is_none() { let [wx, wy] = model.world_origin; model .boundary .iter() .map(|&[x, y]| { if x.is_finite() && y.is_finite() { [x as f64 + wx, y as f64 + wy] } else { [f64::NAN, f64::NAN] } }) .collect() } else { Vec::new() }; let wcs = model .boundary_wcs .as_deref() .map(|points| points.as_slice()) .unwrap_or(reconstructed_wcs.as_slice()); let mut ring: Vec = Vec::new(); let mut first = true; let mut ring_index = 0usize; let mut push_ring = |r: &mut Vec, is_outer: bool, index: usize| { if !r.is_empty() { let edge = PolylineEdge::new(std::mem::take(r), true); let handles: Vec<_> = model .boundary_sources .as_deref() .and_then(|sources| sources.get(index)) .into_iter() .flatten() .copied() .filter(|handle| handle.is_valid()) .collect(); let mut bits = 0; if is_outer { bits |= codec::entities::hatch::BoundaryPathFlags::OUTERMOST.bits(); bits |= codec::entities::hatch::BoundaryPathFlags::EXTERNAL.bits(); } let mut path = BoundaryPath::with_flags( codec::entities::hatch::BoundaryPathFlags::from_bits(bits), ); path.add_edge(BoundaryEdge::Polyline(edge)); for handle in handles { path.add_boundary_handle(handle); } dxf.paths.push(path); } }; for &[x, y] in wcs { if x.is_finite() && y.is_finite() { ring.push(Vector2::new(x, y)); } else { let is_outer = model .boundary_exterior .as_deref() .and_then(|roles| roles.get(ring_index)) .copied() .unwrap_or(first); first = false; if !ring.is_empty() { push_ring(&mut ring, is_outer, ring_index); ring_index += 1; } } } let is_outer = model .boundary_exterior .as_deref() .and_then(|roles| roles.get(ring_index)) .copied() .unwrap_or(first); push_ring(&mut ring, is_outer, ring_index); } dxf.is_associative = dxf .paths .iter() .any(|path| !path.boundary_handles.is_empty()); let pattern_scale = if model.scale.abs() > 1e-6 { model.scale as f64 } else { 1.0 }; let pattern_origin = model .fill_plane_boundary .as_deref() .and_then(|points| { points .iter() .find(|point| point[0].is_finite() && point[1].is_finite()) }) .map(|point| [point[0] as f64, point[1] as f64]) .unwrap_or(model.world_origin); if let crate::scene::model::hatch_model::HatchPattern::Pattern(families) = &model.pattern { let mut pattern = codec::entities::HatchPattern::new(&model.name); let rotation = model.angle_offset as f64; let (rotation_sin, rotation_cos) = rotation.sin_cos(); for family in families { let family_angle = (family.angle_deg as f64).to_radians(); let angle = family_angle + rotation; let (family_sin, family_cos) = family_angle.sin_cos(); let local_offset_x = family.dx as f64 * family_cos - family.dy as f64 * family_sin; let local_offset_y = family.dx as f64 * family_sin + family.dy as f64 * family_cos; let base_x = family.x0 as f64 * pattern_scale; let base_y = family.y0 as f64 * pattern_scale; pattern.lines.push(codec::entities::HatchPatternLine { angle, base_point: Vector2::new( pattern_origin[0] + base_x * rotation_cos - base_y * rotation_sin, pattern_origin[1] + base_x * rotation_sin + base_y * rotation_cos, ), offset: Vector2::new( (local_offset_x * rotation_cos - local_offset_y * rotation_sin) * pattern_scale, (local_offset_x * rotation_sin + local_offset_y * rotation_cos) * pattern_scale, ), dash_lengths: family .dashes .iter() .map(|dash| *dash as f64 * pattern_scale) .collect(), }); } dxf.pattern = pattern; } dxf.pattern_angle = model.angle_offset as f64; dxf.pattern_scale = pattern_scale; // A gradient fill must be encoded on the DXF entity itself: the render // model is rebuilt from the entity below (`add_entity` → // `hatch_model_from_dxf`), so a gradient kept only on the command's // model silently degraded to a plain pattern hatch. if let crate::scene::model::hatch_model::HatchPattern::Gradient { angle_deg, color2, kind, invert, shift, } = &model.pattern { let to_color = |c: [f32; 4]| codec::types::Color::Rgb { r: (c[0] * 255.0).round().clamp(0.0, 255.0) as u8, g: (c[1] * 255.0).round().clamp(0.0, 255.0) as u8, b: (c[2] * 255.0).round().clamp(0.0, 255.0) as u8, }; dxf.is_solid = true; dxf.gradient_color.enabled = true; dxf.gradient_color.name = kind.dxf_name(*invert).to_string(); // Keep both persisted angle fields aligned. dxf.pattern_angle = (*angle_deg as f64).to_radians(); dxf.gradient_color.angle = (*angle_deg as f64).to_radians(); dxf.gradient_color.shift = *shift as f64; dxf.gradient_color.is_single_color = false; // Linear has no INV name in the standard set: persist an inverted // linear by swapping the colour stops instead. let (c0, c1) = if *invert && matches!(kind, crate::scene::model::hatch_model::GradientKind::Linear) { (*color2, model.color) } else { (model.color, *color2) }; dxf.gradient_color.colors = vec![ codec::entities::hatch::GradientColorEntry { value: 0.0, color: to_color(c0), }, codec::entities::hatch::GradientColorEntry { value: 1.0, color: to_color(c1), }, ]; } // `add_entity` already builds the render model from the DXF entity via // `hatch_model_from_dxf` and inserts it with a correct `world_origin` // (AABB-centred) for the relative-to-eye fill. The command-built `model` // carries `world_origin: [0, 0]`, which after the world_offset removal // leaves the fill mis-placed and effectively invisible until a later // edit rebuilds it from the DXF — so keep the seed, don't overwrite it. if let Some(origin) = model.pattern_origin { dxf.record_pattern_origin(Vector2::new(origin[0], origin[1])); } let mut entity = EntityType::Hatch(dxf); if let Some(layer) = layer { entity.as_entity_mut().set_layer(layer.to_string()); } if let Some((color, transparency)) = entity_style { entity.common_mut().color = color; entity.common_mut().transparency = transparency; } else { entity.common_mut().transparency = self.document.current_entity_transparency(); } self.add_entity(entity) } pub fn clear(&mut self) { self.document.record_all_entities_for_transaction(); self.document = CadDocument::new(); self.replace_selection(HashSet::default()); self.preview_wires = vec![]; self.preview_text = vec![]; self.current_layout = "Model".to_string(); self.hatches = HashMap::default(); self.associative_hatch_source_cache.borrow_mut().take(); self.images = HashMap::default(); self.meshes = HashMap::default(); self.block_meshes = HashMap::default(); self.solid_models = HashMap::default(); *self.camera.borrow_mut() = Camera::default(); self.camera_generation += 1; // A brand-new/replaced document has none of these yet — without // resetting them, a "new"/"open" (e.g. via automation, which reuses // this same reset) would leak the previous document's constraints // and named parameters into the fresh one. self.parametric_constraints.clear(); self.named_parameters = crate::scene::named_parameters::ParameterTable::new(); self.bump_constraints_epoch(); self.bump_geometry(); self.bump_layout_epoch(); self.bump_scale_epoch(); } /// Reset to the drawing File → New produces. Every path that starts a /// fresh drawing (the New tab, CLEAR, the automation `new` op) goes /// through here so they all agree on what a blank drawing contains. pub fn reset_to_new_drawing(&mut self) { self.clear(); self.populate_new_drawing_defaults(); } /// What a blank document gets on top of the codec's defaults: the /// standard linetypes and a compatible page setup on every paper layout. pub fn populate_new_drawing_defaults(&mut self) { crate::io::linetypes::populate_document(&mut self.document); // A new drawing is metric, as the reference's new drawing: metre // insertion units, metric measurement, and the ISO-25 dimension style // current. Standard stays available for imperial work. self.document.header.measurement = 1; self.document.header.insertion_units = 6; let iso = crate::scene::creation_style::ensure_iso_dim_style(&mut self.document); self.document.header.current_dimstyle_name = "ISO-25".to_string(); // 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; 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, ""); } } } } }