// Auto-split from scene/mod.rs. Pure text-move; behaviour unchanged. use super::*; /// Convert a HATCH's own resolved pattern line (world-unit `offset` = step to /// the next parallel line, plus a base angle) into a render `PatFamily` whose /// geometry is already final — the HatchModel that carries it uses scale 1 and /// angle_offset 0 (see `prebaked` in `hatch_model_from_dxf`). The world-space /// offset is rotated into the line's local frame so `pattern_segments` and the /// GPU shader, which rotate `(dx, dy)` back out by the family angle, reproduce /// the exact stored step. `x0/y0` are filled in by the caller (from the stored /// `base_point`, relative to `world_origin`) once the boundary anchor is known; /// they set the pattern origin, observable for dashed / offset patterns. /// Order a hatch boundary path's sampled edges into one tip-to-tail loop. /// /// Real files do not store boundary edges as a sequential walk: associative /// hatches list them in boundary-source-entity order, with arbitrary /// direction — the next edge in the list may attach to either end of the /// chain built so far, or belong to the far side of the loop entirely. /// Concatenating them verbatim draws a self-crossing "bowtie" outline and /// flips the even-odd fill over the wrong region. /// /// Greedy nearest-endpoint assembly: keep the chain open at both ends and, at /// each step, attach the unused edge whose endpoint lies closest to either /// end (reversing / prepending as needed). Distance comparison, no tolerance: /// a correctly-ordered file matches at distance 0 and reproduces exactly. fn chain_path_edges(mut polys: Vec>) -> Vec<[f64; 2]> { let d2 = |a: [f64; 2], b: [f64; 2]| (a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2); polys.retain(|p| !p.is_empty()); if polys.is_empty() { return Vec::new(); } let mut chain: std::collections::VecDeque<[f64; 2]> = polys.swap_remove(0).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 mut p = polys.swap_remove(idx); 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() } fn family_from_stored_line( ln: &acadrust::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 = acadrust::tables::Layer::new(name); layer.handle = self.document.allocate_handle(); let _ = self.document.layers.add(layer); } 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 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 = self.document.header.isolines.max(0) as usize; 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 }; // Auto-create an ImageDefinition object for new RasterImage entities // that don't already reference one. if let EntityType::RasterImage(ref mut img) = entity { if img.definition_handle.is_none() { use acadrust::objects::{ImageDefinition, ObjectType}; let def_handle = Handle::new(self.document.next_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); } } // 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); // 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() { 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 / block) so the app knows a pure-entity delta // would be incomplete. Raster image definitions are captured as // targeted object before-images above. if self.is_recording_undo() { self.record_undo_before(handle, None); if creates_layer || mutates_block_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, mut entity: EntityType) -> 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; }; // 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 = self.document.header.isolines.max(0) as usize; 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); self.bump_geometry(); } 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). self.bump_entities(&[(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. 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]); if let Some(operation) = self.document.solid_history_operation(handle).cloned() { if let Ok(body) = cadkernel::acis::rebuild_body(&operation) { self.solid_models.insert(handle, body); } } } /// 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 { acadrust::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 = self.document.header.isolines.max(0) as usize; 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(&acadrust::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: &acadrust::types::Transform, block_to_world: &acadrust::types::Transform, ) -> Result { 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.")); } let source_entities: Vec<_> = 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 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 = acadrust::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, acadrust::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::Affine(*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())?; self.erase_entities(&[old_handle]); } let mut insert = DxfInsert::new(name, acadrust::types::Vector3::ZERO); acadrust::Entity::apply_transform(&mut insert, block_to_world); let insert_handle = self.add_entity(EntityType::Insert(insert)); // A new block definition landed in the document; advance the block // epoch so consumers (the block palette stale check) notice it even // when the panel stays open. Mirrors `define_block_from_owned_entities`. 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) 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 = acadrust::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, acadrust::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; 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: acadrust::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 = acadrust::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(); } pub(super) fn synced_hatch_models( &self, target_block: Handle, frozen: Option<&rustc_hash::FxHashSet>, annotation_scale_handle: Option, all_visible: bool, viewport: Option, ) -> 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 { acadrust::types::Color::ByLayer => { let layer = self.document.layers.get(&dxf.common.layer); let aci = layer .and_then(|layer| match &layer.color { acadrust::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, ) } acadrust::types::Color::ByBlock => (style.0, style.4), acadrust::types::Color::Index(index) => ( crate::scene::convert::tess_util::aci_to_rgba( &acadrust::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 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, true, 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, ) } /// 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, ) } 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, ) -> 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_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| { if context.is_instanced() { return true; } let common = entity.common(); if self.object_isolation.hides(common.handle) || (!include_preview_hidden && self.preview_hidden.contains(&common.handle)) { return false; } if let Some(targets) = targets { return matches!(entity, EntityType::Insert(_)) && targets.contains(&common.handle); } match entity { EntityType::Insert(insert) => { crate::scene::render_graph::block_contains_hatch( &self.document, &insert.block_name, &mut hatch_block_memo, ) } EntityType::Dimension(dimension) => { let name = dimension.base().block_name.trim(); !name.is_empty() && crate::scene::render_graph::block_contains_hatch( &self.document, name, &mut hatch_block_memo, ) } _ => true, } }, |entity, context| { if !context.is_instanced() { return; } let EntityType::Hatch(source_hatch) = entity else { return; }; let style = context.style_for(&self.document, entity); let preserve_white_mask = source_hatch.is_solid && matches!( source_hatch.common.color, acadrust::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 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, ) } 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, ) -> 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(), ); let mut models = Vec::new(); let mut wipeout_sources = rustc_hash::FxHashMap::default(); graph.walk_root( self.render_scene_root(target_block), |entity, context| { context.is_instanced() || !self.entity_temporarily_hidden(entity.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 { render_instance, boundary: Arc::new(boundary), boundary_wcs: None, fill_plane, fill_plane_boundary, boundary_exterior: None, boundary_sources: None, 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: &acadrust::entities::Wipeout, ) -> ([f64; 2], Vec<[f32; 2]>) { let origin = [wo.insertion_point.x, wo.insertion_point.y]; let plane = cadkernel::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: &acadrust::entities::Wipeout) -> Vec<[f64; 2]> { use acadrust::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: &acadrust::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]; cadkernel::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 = cadkernel::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 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(cadkernel::tessellation::DEFAULT_ANGLE) .into_iter() .map(|point| to_xy(point[0], point[1])) .collect(), ); } } let mut ring = chain_path_edges(edge_polys); if ring.is_empty() { continue; } if ring.len() >= 3 { let first = ring[0]; let last = *ring.last().unwrap(); if (first[0] - last[0]).abs() > 1e-5 || (first[1] - last[1]).abs() > 1e-5 { ring.push(first); } } rings.push(ring); ring_sources.push(path.boundary_handles.clone()); } if rings.is_empty() { return None; } let depths = cadkernel::geom2d::ring_nesting_depths(&rings); let mut boundary = Vec::new(); let mut boundary_exterior = Vec::new(); let mut boundary_sources = Vec::new(); for ((ring, sources), depth) in rings.into_iter().zip(ring_sources).zip(depths) { let keep = match dxf.style { acadrust::entities::HatchStyleType::Normal => true, acadrust::entities::HatchStyleType::Outer => depth <= 1, acadrust::entities::HatchStyleType::Ignore => depth == 0, }; if !keep { continue; } if !boundary.is_empty() { boundary.push([f64::NAN, f64::NAN]); } boundary.extend(ring); 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, acadrust::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 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(); Some(HatchModel { render_instance: None, boundary: std::sync::Arc::new(boundary_f32), boundary_wcs: None, fill_plane: None, fill_plane_boundary: None, boundary_exterior: Some(std::sync::Arc::new(boundary_exterior)), boundary_sources: Some(std::sync::Arc::new(boundary_sources)), 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, acadrust::entities::RasterImage)> = self .document .entities() .filter_map(|e| { if let EntityType::RasterImage(img) = e { Some((img.common.handle, img.clone())) } else { None } }) .collect(); for (handle, img) in entries { if let Some(model) = ImageModel::from_raster_image(&img) { 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(); } /// Tessellate all `Solid3D` entities in the current document into /// GPU-ready `MeshModel`s and store them in `self.meshes`. /// /// Called after loading a document or after undo/redo so that every /// `Solid3D` entity is represented in the mesh cache. /// ImageModel for an edited image-bearing entity (RasterImage, or a PDF /// UNDERLAY resolved through its definition object). `None` for others. fn image_seed_for(&self, entity: &acadrust::entities::EntityType) -> Option { match entity { EntityType::RasterImage(img) => ImageModel::from_raster_image(img), EntityType::Underlay(u) => match self.document.objects.get(&u.definition_handle) { Some(acadrust::objects::ObjectType::UnderlayDefinition(def)) => { ImageModel::from_underlay(u, def) } _ => 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 { acadrust::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 = self.document.header.isolines.max(0) as usize; // 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. /// Conventional DXF SOLID corners use Z-order; legacy entities may already /// be in perimeter order. Use the same non-crossing resolver as wire fill. 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 = crate::entities::solid::perimeter_indices(&wcs); 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 { 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, 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<(acadrust::types::Color, acadrust::types::Transparency)>, ) -> Handle { let mut dxf = DxfHatch::new(); dxf.is_solid = matches!( model.pattern, crate::scene::model::hatch_model::HatchPattern::Solid ); // Prefer exact command geometry; otherwise reconstruct every ring from // the render offsets without dropping its separators. // Build one DXF path per NaN-separated ring and retain each outer/hole role. 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 |= acadrust::entities::hatch::BoundaryPathFlags::OUTERMOST.bits(); } if !handles.is_empty() { bits |= acadrust::entities::hatch::BoundaryPathFlags::EXTERNAL.bits(); } let mut path = BoundaryPath::with_flags( acadrust::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 }; if let crate::scene::model::hatch_model::HatchPattern::Pattern(families) = &model.pattern { let mut pattern = acadrust::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(acadrust::entities::HatchPatternLine { angle, base_point: Vector2::new( model.world_origin[0] + base_x * rotation_cos - base_y * rotation_sin, model.world_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]| acadrust::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![ acadrust::entities::hatch::GradientColorEntry { value: 0.0, color: to_color(c0), }, acadrust::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. 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; } 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; self.bump_geometry(); } }