// 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]>>) -> 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(),
    }
}

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, mut entity: EntityType) -> Handle {
        // Only Insert / Block entities can introduce or reference a block
        // definition that the block cache must (re)build. Adding a plain
        // top-level entity (line, arc, text, …) leaves every block defn intact,
        // so it keeps the cache and skips the all-blocks re-tessellation.
        let affects_blocks = matches!(
            &entity,
            EntityType::Insert(_) | 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 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, isolines)
                .map(|m| offset_mesh_lod_set(m))
        } 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());
                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);
        let is_image = matches!(&entity, EntityType::RasterImage(_));
        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 / image definition) so the app knows a
            // pure-entity delta would be incomplete.
            if self.is_recording_undo() {
                self.record_undo_before(handle, None);
                if creates_layer || mutates_block_structure || is_image {
                    self.poison_undo_recording();
                }
            }
            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
    }

    /// 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;
        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 entity forces a full block-cache
        // rebuild; a plain entity only needs its own wires re-tessellated.
        let affects_blocks = matches!(
            existing,
            EntityType::Insert(_) | EntityType::Block(_) | EntityType::BlockEnd(_)
        ) || matches!(
            &entity,
            EntityType::Insert(_) | 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 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, isolines)
                .map(|m| offset_mesh_lod_set(m))
        } 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]);
    }

    /// 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 layout_blocks: std::collections::HashSet<Handle> = 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<EntityType>, [f32; 4], bool)> = handles
            .iter()
            .filter_map(|&handle| {
                let entity = self.document.get_entity_arc(handle)?;
                if !matches!(
                    entity.as_ref(),
                    EntityType::Solid3D(_)
                        | EntityType::Region(_)
                        | EntityType::Body(_)
                        | EntityType::Surface(_)
                        | EntityType::Mesh(_)
                        | EntityType::PolygonMesh(_)
                        | EntityType::PolyfaceMesh(_)
                ) {
                    return None;
                }
                let color = self.render_style(entity.as_ref()).0;
                let top_level = layout_blocks.contains(&entity.common().owner_handle);
                Some((handle, entity, color, top_level))
            })
            .collect();
        for handle in handles {
            self.meshes.remove(handle);
            self.block_meshes.remove(handle);
            self.solid_models.remove(handle);
        }
        let facet_res = self.document.header.facet_resolution;
        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,
                    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);
        [r, g, b, 1.0]
    }

    pub fn custom_block_names(&self) -> Vec<String> {
        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,
        base: glam::DVec3,
    ) -> Result<Handle, 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."));
        }

        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::Translate(-base);
        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 insert = DxfInsert::new(
            name,
            acadrust::types::Vector3::new(base.x, base.y, base.z),
        );
        Ok(self.add_entity(EntityType::Insert(insert)))
    }

    /// 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<EntityType>,
        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);
        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.document
                .add_entity(entity)
                .map_err(|e| e.to_string())?;
        }
        // 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(())
    }

    /// 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<EntityType>,
    ) {
        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,
        frozen: Option<&rustc_hash::FxHashSet<Handle>>,
    ) -> Vec<HatchModel> {
        let layout_block = self.current_layout_block_handle();

        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.
        //
        // We INCLUDE hatches from blocks other than `current_layout`'s
        // own block (specifically: paper-layout content viewports want
        // model-block hatches). Every hatch's `world_origin` is already
        // baked into the correct block coord-space at
        // `populate_hatches_from_document` time (offset for model, 0 for
        // paper), so projecting them through a camera built for the
        // wrong block lands them outside the frustum and the per-vp
        // scissor / LOD culls them out — no double-rendering.
        let depth_map = self.draw_depth_map();
        let mut models: Vec<HatchModel> = self
            .hatches
            .iter()
            .filter(|(&handle, _)| {
                let Some(entity) = self.document.get_entity(handle) else {
                    return true;
                };
                let c = entity.common();
                if c.invisible || layer_hidden(&c.layer) {
                    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) — they're tessellated separately via Insert
                // explosion and only the laid-out copies should appear.
                self.belongs_to_visible_block(handle, c.owner_handle, layout_block)
                    || (self.block_edit_block.is_none()
                        && self.belongs_to_visible_block(
                            handle,
                            c.owner_handle,
                            self.model_space_block_handle(),
                        ))
            })
            .flat_map(|(&handle, model)| {
                let contextual = self
                    .document
                    .get_entity(handle)
                    .map(|entity| {
                        crate::scene::annotative::entity_for_active_context(
                            &self.document,
                            entity,
                        )
                    });
                let entity = contextual.as_deref();
                let mut m = match entity {
                    Some(EntityType::Hatch(dxf))
                        if crate::scene::annotative::active_object_context(
                            &self.document,
                            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<HatchModel> = None;
                if let Some(e) = entity {
                    // 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 = self.render_style(e).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).
                            b.color = match bg {
                                acadrust::types::Color::ByLayer => {
                                    crate::scene::view::render::layer_render_style(
                                        &self.document,
                                        &dxf.common.layer,
                                    )
                                    .color
                                }
                                acadrust::types::Color::ByBlock => self.render_style(e).0,
                                other => {
                                    crate::scene::convert::tess_util::aci_to_rgba(&other)
                                }
                            };
                            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;
                                let anno = if self.current_layout == "Model" {
                                    self.annotation_scale
                                } else {
                                    1.0
                                };
                                m.scale = dxf.pattern_scale as f32 * anno;
                            }
                            model::hatch_model::HatchPattern::Gradient { angle_deg, .. } => {
                                *angle_deg = dxf.pattern_angle.to_degrees() 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).
        let hatch_bg: [f32; 4] = if self.current_layout != "Model" {
            self.paper_bg_color
        } else {
            self.bg_color
        };
        // Block-internal hatch fills: explode every visible INSERT of this
        // layout block and materialize its fills at world position. Shared with
        // the export path so a plot draws them identically. (tint_selected =
        // true applies the screen selection highlight.)
        models.extend(self.exploded_insert_hatch_models(layout_block, hatch_bg, true, frozen));

        // Wide LwPolyline / Polyline2D bands are no longer hatch fills at
        // model level: a flat band is drawn by expanding its centre-line wire
        // to `world_width` in the shader (depth-tested, linetype-dashed), and a
        // thickened one is extruded into a 3-D tube by the tessellator. Only
        // block-internal wide polylines still band via the hatch path (see
        // `push_block_wide_fills`), where it scales with the insert transform.

        models
    }

    /// Materialize the hatch fills that live *inside* the visible INSERTs of
    /// `layout_block`, baking each INSERT's transform (and nested-INSERT
    /// transforms) so the fills land in world position with resolved
    /// ByBlock / layer-0 colours.
    ///
    /// 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. Without this the
    /// export path only saw top-level hatches, so any fill nested in a block
    /// INSERT printed as bare monochrome outlines.
    ///
    /// `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 exploded_insert_hatch_models(
        &self,
        layout_block: Handle,
        hatch_bg: [f32; 4],
        tint_selected: bool,
        frozen: Option<&rustc_hash::FxHashSet<Handle>>,
    ) -> Vec<HatchModel> {
        let layer_hidden = |layer: &str| {
            self.document
                .layers
                .get(layer)
                .map(|l| l.flags.off || l.flags.frozen)
                .unwrap_or(false)
        };
        let mut models: Vec<HatchModel> = Vec::new();
        // Draw-order composition (#408): every fill inherits its INSERT's
        // draw-depth slot, offset by the child's rank INSIDE its block
        // (draw_depth_map ranks per owning block), scaled to stay within the
        // insert's half-gap in the host ordering. Without this every block /
        // xref fill landed at depth 0 and the stack's LIFO pop reversed the
        // block's internal stacking.
        let depth_map = self.draw_depth_map();
        let block_count = |name: &str| -> usize {
            self.document
                .block_records
                .get(name)
                .map(|br| br.entity_handles.len())
                .unwrap_or(1)
                .max(1)
        };
        // Exploding an INSERT materializes (clones) every child of its block —
        // including 3D solids that each carry megabytes of SAB geometry — just
        // to scan the result for hatch fills. On xref-heavy drawings whose
        // blocks hold thousands of solids and NO hatches, that cloned ~all block
        // content on every open for nothing (~20 s on a 5-xref BIM aggregate).
        // Skip the explode for blocks with no hatch (transitively) via the same
        // memoised `block_has_hatch` scan the pick path uses. Blocks that DO
        // contain a hatch still explode exactly as before, so output is unchanged.
        let mut hatch_block_memo: std::collections::HashMap<String, bool> =
            std::collections::HashMap::new();
        // Same gate for wide LwPolyline / Polyline2D fills — a block with a wide
        // polyline (but no hatch) must still explode so its width band renders
        // at world position, like a block-internal hatch. (#222)
        let mut wide_block_memo: std::collections::HashMap<String, bool> =
            std::collections::HashMap::new();
        // `explode_from_document` returns a Dimension child verbatim (it carries
        // no fill of its own), so a filled arrowhead that lives as a nested
        // Insert(hatch) inside the dimension's baked `*D` block never reaches
        // this fill explosion. Expand each dimension child's `*D` content into
        // the owning block's frame — translate by the dimension insertion_point
        // (WCS → block-local) then apply the insert transform (block-local →
        // world) — so its arrow inserts / hatches ride the normal walk. Only
        // fill-bearing children (Insert / Hatch) are lifted; the dimension's
        // lines and text still render through the wire path.
        fn explode_including_dims(ins: &DxfInsert, doc: &CadDocument) -> Vec<EntityType> {
            let mut out = ins.explode_from_document(doc);
            let Some(br) = doc.block_records.get(&ins.block_name) else {
                return out;
            };
            let xform = ins.get_transform();
            for &beh in &br.entity_handles {
                let Some(EntityType::Dimension(dim)) = doc.get_entity(beh) else {
                    continue;
                };
                let bn = dim.base().block_name.clone();
                if bn.trim().is_empty() {
                    continue;
                }
                let ins_pt = dim.base().insertion_point;
                let Some(dblk) = doc
                    .block_records
                    .iter()
                    .find(|r| r.name.eq_ignore_ascii_case(&bn))
                else {
                    continue;
                };
                for &deh in &dblk.entity_handles {
                    let Some(dsub) = doc.get_entity(deh) else {
                        continue;
                    };
                    if !matches!(dsub, EntityType::Insert(_) | EntityType::Hatch(_)) {
                        continue;
                    }
                    let mut placed = dsub.clone();
                    placed.as_entity_mut().translate(ins_pt);
                    placed.apply_transform(&xform);
                    out.push(placed);
                }
            }
            out
        }
        for entity in self.document.entities() {
            let contextual =
                crate::scene::annotative::entity_for_active_context(&self.document, entity);
            let EntityType::Insert(ins) = contextual.as_ref() else {
                continue;
            };
            if ins.common.invisible || layer_hidden(&ins.common.layer) {
                continue;
            }
            // Per-viewport freeze: an INSERT on a layer frozen in this content
            // viewport hides the whole instance, its internal fills included.
            if self.layer_frozen_in(&ins.common.layer, frozen) {
                continue;
            }
            if !self.belongs_to_visible_block(
                ins.common.handle,
                ins.common.owner_handle,
                layout_block,
            ) {
                continue;
            }
            // Off-scale annotative representation (model space only — paper
            // viewports use their per-viewport frozen scale layers). Skips its
            // whole fill subtree, matching the wire path.
            if frozen.is_none()
                && crate::scene::annotative::annotative_offscale(&self.document, &ins.common)
            {
                continue;
            }
            if !self.block_has_hatch(&ins.block_name, &mut hatch_block_memo)
                && !self.block_has_wide_poly(&ins.block_name, &mut wide_block_memo)
            {
                continue;
            }
            let selected = tint_selected && self.selected.contains(&ins.common.handle);
            // XCLIP: clip this insert's exploded hatch fills to the boundary,
            // matching how the line geometry is clipped in expand_insert.
            let clip_poly = pick::xclip::insert_spatial_filter(&self.document, ins)
                .map(|sf| pick::xclip::world_clip_polygon(sf, ins));
            // Walk the full block tree: `explode_from_document` only descends
            // one level, so nested INSERTs are re-exploded here. Each level
            // bakes its transform into the children it returns, so nested
            // hatches land in the correct world position. A depth guard keeps
            // a malformed cyclic block reference from looping forever.
            let normalize = crate::modules::draw::modify::explode::normalize_insert_entity;
            // Block-child colour inheritance sources (#221), kept RAW and
            // adapted at the leaf. `ins_color` feeds ByBlock; `l0` (the
            // INSERT's *layer* style) feeds the layer-0 rule. Both chain
            // through nested inserts, mirroring expand_insert.
            let ins_color =
                crate::scene::view::render::render_style_for(&self.document, entity).0;
            let l0 = crate::scene::view::render::layer_render_style(
                &self.document,
                &ins.common.layer,
            );
            let [base_depth, half_gap] = depth_map
                .get(&ins.common.handle.value())
                .copied()
                .unwrap_or([0.0, 0.0]);
            // Drop off-scale annotative representations (e.g. a 10× copy on
            // layer "0 @ 10" when the current scale is 1:1) before `normalize`
            // strips their handle — otherwise their whole fill subtree stacks
            // under the current-scale copy. Model space only; paper viewports
            // use their per-viewport frozen scale layers. `explode` preserves
            // the child handle, so the membership lookup still resolves here.
            let offscale = |e: &EntityType| -> bool {
                frozen.is_none()
                    && matches!(e, EntityType::Insert(ni)
                        if crate::scene::annotative::annotative_offscale(&self.document, &ni.common))
            };
            let mut stack: Vec<(
                EntityType,
                usize,
                [f32; 4],
                crate::scene::view::render::InheritStyle,
                (f32, f32),
            )> = explode_including_dims(ins, &self.document)
                .into_iter()
                .filter(|e| !offscale(e))
                .map(|e| {
                    crate::scene::annotative::entity_for_active_context(&self.document, &e)
                        .into_owned()
                })
                .map(|e| (normalize(e), 0usize, ins_color, l0, (base_depth, half_gap)))
                .collect();
            while let Some((sub, depth, sub_ins_color, sub_l0, (d_base, d_half))) = stack.pop() {
                match sub {
                    EntityType::Insert(nins) => {
                        if depth >= 32 {
                            continue;
                        }
                        use acadrust::types::Color;
                        // Chain the inheritance sources into the nested insert,
                        // mirroring expand_insert's nested resolution: ByBlock →
                        // parent source; layer-0 + ByLayer → parent layer-0
                        // target; else the nested insert's own resolved style.
                        let on_l0 = crate::scene::view::render::is_effective_layer_zero(
                            &nins.common.layer,
                        );
                        let nested_entity = EntityType::Insert(nins.clone());
                        let has_book_color =
                            crate::scene::view::render::has_resolved_book_color(
                                &self.document,
                                &nested_entity,
                            );
                        let child_ins_color = if !has_book_color
                            && nins.common.color == Color::ByBlock
                        {
                            sub_ins_color
                        } else if !has_book_color
                            && on_l0
                            && nins.common.color == Color::ByLayer
                        {
                            sub_l0.color
                        } else {
                            crate::scene::view::render::render_style_for(
                                &self.document,
                                &nested_entity,
                            )
                            .0
                        };
                        let child_l0 = if on_l0 {
                            sub_l0
                        } else {
                            crate::scene::view::render::layer_render_style(
                                &self.document,
                                &nins.common.layer,
                            )
                        };
                        // Nested insert: its slot = parent slot + its rank in
                        // the parent block, subdivided by its own block size.
                        let nd_base = d_base
                            + depth_map
                                .get(&nins.common.handle.value())
                                .map_or(0.0, |d| d[0])
                                * d_half;
                        let nd_half = d_half / (block_count(&nins.block_name) as f32 + 1.0);
                        for e in explode_including_dims(&nins, &self.document) {
                            if offscale(&e) {
                                continue;
                            }
                            let e =
                                crate::scene::annotative::entity_for_active_context(
                                    &self.document,
                                    &e,
                                )
                                .into_owned();
                            stack.push((
                                normalize(e),
                                depth + 1,
                                child_ins_color,
                                child_l0,
                                (nd_base, nd_half),
                            ));
                        }
                    }
                    EntityType::Hatch(dxf) => {
                        if dxf.common.invisible || layer_hidden(&dxf.common.layer) {
                            continue;
                        }
                        // Resolve ByBlock / layer-0 inheritance for this block
                        // child, then adapt to the background (#221). Pattern /
                        // lineweight args are unused by a hatch's colour.
                        let color = crate::scene::view::render::render_style_for_block_sub(
                            &self.document,
                            &EntityType::Hatch(dxf.clone()),
                            sub_ins_color,
                            0.0,
                            [0.0; 8],
                            0.0,
                            sub_l0,
                        )
                        .0;
                        let color =
                            crate::scene::view::render::adapt_to_bg(color, hatch_bg);
                        if let Some(mut model) =
                            Self::hatch_model_from_dxf(&dxf, color)
                        {
                            // In-block rank → within the insert's depth slot.
                            model.draw_depth = d_base
                                + depth_map
                                    .get(&dxf.common.handle.value())
                                    .map_or(0.0, |d| d[0])
                                    * d_half;
                            if let Some(poly) = &clip_poly {
                                let clipped = pick::xclip::clip_hatch_boundary(
                                    &model.boundary,
                                    model.world_origin,
                                    poly,
                                );
                                if clipped.is_empty() {
                                    continue;
                                }
                                model.boundary = std::sync::Arc::new(clipped);
                            }
                            if selected {
                                model.color = [0.15, 0.55, 1.00, model.color[3]];
                            }
                            models.push(model);
                        }
                    }
                    // Wide LwPolyline / Polyline2D bands inside a block are drawn
                    // by the shader wire path — block_cache carries each centre-
                    // line's `world_width` and scales it by the insert transform
                    // (`emit_wire`) — not as hatch fills here. Same depth-tested,
                    // linetype-dashed band as at model level. (#222)
                    _ => {}
                }
            }
        }
        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,
        frozen: Option<&rustc_hash::FxHashSet<Handle>>,
    ) -> Vec<HatchModel> {
        let is_paper = self.current_layout != "Model";
        let bg_color: [f32; 4] = if is_paper {
            self.paper_bg_color
        } else {
            self.bg_color
        };
        // No per-frame view-cull here: GPU wipeout buffer upload is
        // gated on geometry_epoch only (see render.rs), so any cull at
        // build time would freeze the visible subset at the geometry
        // epoch boundary and never re-evaluate as the user pans. The
        // pipeline's `wipeout_skip_flags` (compute_wipeout_lod) does
        // the per-frame skip at draw time instead.
        let depth_map = self.draw_depth_map();
        let layout_block = self.current_layout_block_handle();
        let mut models = Vec::new();
        for entity in self.document.entities() {
            let EntityType::Wipeout(wo) = entity else {
                continue;
            };
            if entity.common().invisible {
                continue;
            }
            // Reject block-defn-only wipeouts (owned by a BLOCK record that is
            // neither model nor a paper layout block): those are placed via
            // insert descent below, in world space. Without this they also draw
            // once at their raw block-local coordinates. Mirrors the hatch path.
            if !self.belongs_to_visible_block(
                wo.common.handle,
                wo.common.owner_handle,
                layout_block,
            ) {
                continue;
            }
            if self
                .document
                .layers
                .get(&entity.common().layer)
                .map(|l| l.flags.off || l.flags.frozen)
                .unwrap_or(false)
            {
                continue;
            }
            // Per-viewport freeze: a wipeout on a layer frozen in this content
            // viewport is hidden along with the rest of that layer.
            if self.layer_frozen_in(&entity.common().layer, frozen) {
                continue;
            }
            // Per-entity world_offset selection so paper-layout content
            // viewports still see model-block wipeouts at the right local
            // coordinates (same rationale as hatches).
            let (fill_origin, boundary) = Self::wipeout_boundary_2d(wo);
            if boundary.len() >= 3 {
                let mut fill_color = bg_color;
                if self.selected.contains(&wo.common.handle) {
                    fill_color = [0.15, 0.55, 1.00, 0.35];
                }
                models.push(HatchModel {
                    boundary: Arc::new(boundary),
                    boundary_wcs: None,
                    pattern: model::hatch_model::HatchPattern::Solid,
                    name: "WIPEOUT_FILL".into(),
                    color: fill_color,
                    angle_offset: 0.0,
                    scale: 1.0,
                    world_origin: fill_origin,
                    // The mask erases what draws below the wipeout and loses
                    // to what draws above — including its own frame wire,
                    // which rides a +half-rank override on top of this.
                    draw_depth: depth_map
                        .get(&wo.common.handle.value())
                        .map_or(0.0, |d| d[0]),
                });
            }
        }
        // Wipeouts nested inside block inserts: the loop above only sees
        // top-level wipeouts, so a wipeout defined inside a block never masked
        // once the block was inserted (it did in block-edit, where it is
        // top-level). Descend visible inserts and place each block-internal
        // wipeout in world space. The insert transform is applied here (apply /
        // apply_rotation) rather than through Insert::explode — the latter
        // double-scales the u/v basis.
        for entity in self.document.entities() {
            let contextual =
                crate::scene::annotative::entity_for_active_context(&self.document, entity);
            let EntityType::Insert(ins) = contextual.as_ref() else {
                continue;
            };
            let c = &ins.common;
            if c.invisible
                || self
                    .document
                    .layers
                    .get(&c.layer)
                    .map(|l| l.flags.off || l.flags.frozen)
                    .unwrap_or(false)
                || self.layer_frozen_in(&c.layer, frozen)
            {
                continue;
            }
            if !self.belongs_to_visible_block(c.handle, c.owner_handle, layout_block) {
                continue;
            }
            if frozen.is_none()
                && crate::scene::annotative::annotative_offscale(&self.document, c)
            {
                continue;
            }
            self.collect_block_wipeouts(
                &ins.get_transform(),
                &ins.block_name,
                0,
                frozen,
                bg_color,
                &depth_map,
                &mut models,
            );
        }
        models
    }

    /// Recursively collect wipeout masks defined inside a block, transformed to
    /// world space by the accumulated insert transform. See `wipeout_models`.
    #[allow(clippy::too_many_arguments)]
    fn collect_block_wipeouts(
        &self,
        xform: &acadrust::types::Transform,
        block_name: &str,
        depth: usize,
        frozen: Option<&rustc_hash::FxHashSet<Handle>>,
        bg_color: [f32; 4],
        depth_map: &HashMap<u64, [f32; 2]>,
        models: &mut Vec<HatchModel>,
    ) {
        if depth > 32 {
            return;
        }
        let Some(br) = self
            .document
            .block_records
            .iter()
            .find(|b| b.name.eq_ignore_ascii_case(block_name))
        else {
            return;
        };
        for &eh in &br.entity_handles {
            let Some(e) = self.document.get_entity(eh) else {
                continue;
            };
            let c = e.common();
            if c.invisible
                || self
                    .document
                    .layers
                    .get(&c.layer)
                    .map(|l| l.flags.off || l.flags.frozen)
                    .unwrap_or(false)
                || self.layer_frozen_in(&c.layer, frozen)
            {
                continue;
            }
            match e {
                EntityType::Wipeout(wo) => {
                    let mut w = wo.clone();
                    w.insertion_point = xform.apply(wo.insertion_point);
                    w.u_vector = xform.apply_rotation(wo.u_vector);
                    w.v_vector = xform.apply_rotation(wo.v_vector);
                    let (fill_origin, boundary) = Self::wipeout_boundary_2d(&w);
                    if boundary.len() >= 3 {
                        let mut fill_color = bg_color;
                        if self.selected.contains(&c.handle) {
                            fill_color = [0.15, 0.55, 1.00, 0.35];
                        }
                        models.push(HatchModel {
                            boundary: Arc::new(boundary),
                            boundary_wcs: None,
                            pattern: model::hatch_model::HatchPattern::Solid,
                            name: "WIPEOUT_FILL".into(),
                            color: fill_color,
                            angle_offset: 0.0,
                            scale: 1.0,
                            world_origin: fill_origin,
                            draw_depth: depth_map.get(&c.handle.value()).map_or(0.0, |d| d[0]),
                        });
                    }
                }
                EntityType::Insert(ni) => {
                    // `compose` applies the nested insert's transform first,
                    // then the accumulated parent transform.
                    let child = xform.compose(&ni.get_transform());
                    self.collect_block_wipeouts(
                        &child,
                        &ni.block_name,
                        depth + 1,
                        frozen,
                        bg_color,
                        depth_map,
                        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). Building it in absolute WCS f32
    /// collapsed the boundary into squares at UTM-scale coordinates; the
    /// relative-to-eye hatch fill reconstructs `world_origin + offset` precisely.
    pub(super) fn wipeout_boundary_2d(
        wo: &acadrust::entities::Wipeout,
    ) -> ([f64; 2], Vec<[f32; 2]>) {
        use acadrust::entities::WipeoutClipType;

        let origin = [wo.insertion_point.x, wo.insertion_point.y];

        let is_polygon = wo.clipping_enabled
            && wo.clip_boundary_vertices.len() >= 3
            && matches!(wo.clip_type, WipeoutClipType::Polygonal);

        if is_polygon {
            // DXF clip vertices live in image-pixel space, centred on the
            // image (range −size/2 … +size/2). Image-bottom-left → insertion,
            // image-y-axis points DOWN (per the DXF "v_vector points down the
            // image" convention), so map:
            //   x_off = (clip.x + size.x/2) × u_vec
            //   y_off = (size.y/2 − clip.y) × v_vec    ← y flipped
            // Offsets are relative to `origin` (the insertion point).
            let cx_of = |v: &acadrust::types::Vector2| v.x + wo.size.x * 0.5;
            let cy_of = |v: &acadrust::types::Vector2| wo.size.y * 0.5 - v.y;
            let mut poly: Vec<[f32; 2]> = wo
                .clip_boundary_vertices
                .iter()
                .map(|v| {
                    let cx = cx_of(v);
                    let cy = cy_of(v);
                    let wx = (wo.u_vector.x * cx + wo.v_vector.x * cy) as f32;
                    let wy = (wo.u_vector.y * cx + wo.v_vector.y * cy) as f32;
                    [wx, wy]
                })
                .collect();
            // Close the loop: the GPU `in_polygon` ray-cast walks
            // sequential pairs and doesn't wrap, so without an explicit
            // closing vertex the last edge (vN-1 → v0) is never tested and
            // the fill bleeds far past the boundary.
            if let Some(&first) = poly.first() {
                if poly.last() != Some(&first) {
                    poly.push(first);
                }
            }
            (origin, poly)
        } else {
            // Rectangular boundary from 4 corners, as offsets from `origin`.
            let ux = (wo.u_vector.x * wo.size.x) as f32;
            let uy = (wo.u_vector.y * wo.size.x) as f32;
            let vx = (wo.v_vector.x * wo.size.y) as f32;
            let vy = (wo.v_vector.y * wo.size.y) as f32;
            // Close the loop (repeat corner 0): the GPU `in_polygon` ray-cast
            // walks sequential vertex pairs and never wraps last→first, so an
            // unclosed quad leaves the v3→v0 edge untested and the solid mask
            // bleeds past the boundary — same reason the polygon branch closes.
            (
                origin,
                vec![
                    [0.0, 0.0],
                    [ux, uy],
                    [ux + vx, uy + vy],
                    [vx, vy],
                    [0.0, 0.0],
                ],
            )
        }
    }

    pub(super) fn hatch_model_from_dxf(
        dxf: &DxfHatch,
        color: [f32; 4],
    ) -> Option<HatchModel> {
        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 boundary: Vec<[f64; 2]> = Vec::new();

        for path in &dxf.paths {
            // Skip TEXTBOX boundary paths (flag bit 3). These are text
            // bounding-boxes AutoCAD derives for island detection; they are
            // never drawn or filled. Treating one as a fill boundary paints its
            // rectangle solid — a phantom bar AutoCAD never shows.
            if path.flags.bits() & 8 != 0 {
                continue;
            }
            let before_path = boundary.len();
            if !boundary.is_empty() {
                boundary.push([f64::NAN, f64::NAN]);
            }
            let path_start = boundary.len();

            let mut edge_polys: Vec<Vec<[f64; 2]>> = Vec::new();
            for edge in &path.edges {
                match edge {
                    BoundaryEdge::Polyline(poly) => {
                        let verts = &poly.vertices;
                        let count = verts.len();
                        if count == 0 {
                            continue;
                        }
                        let seg_count = if poly.is_closed {
                            count
                        } else {
                            count.saturating_sub(1)
                        };
                        for i in 0..seg_count {
                            let v0 = &verts[i];
                            let v1 = &verts[(i + 1) % count];
                            let bulge = v0.z;
                            // Tess in f64 to preserve ~1 cm precision at
                            // UTM-scale WCS (the f32 path used to produce
                            // visibly wavy hatch arcs at 1e5+ magnitude).
                            let arc = if bulge.abs() < 1e-9 {
                                None
                            } else {
                                crate::entities::common::BulgeArc::from_bulge(
                                    [v0.x, v0.y],
                                    [v1.x, v1.y],
                                    bulge,
                                )
                            };
                            let Some(arc) = arc else {
                                boundary.push(to_xy(v0.x, v0.y));
                                continue;
                            };
                            let segs = convert::tess_util::arc_segments(
                                arc.radius,
                                arc.sweep.abs(),
                                convert::tess_util::fill_chord_tol(arc.radius),
                            );
                            for j in 0..segs {
                                let s = arc.sample(j as f64 / segs as f64);
                                boundary.push(to_xy(s[0], s[1]));
                            }
                        }
                        if poly.is_closed {
                            if let Some(&first) = boundary.get(path_start) {
                                boundary.push(first);
                            }
                        }
                    }
                    BoundaryEdge::Line(line) => {
                        edge_polys.push(vec![
                            to_xy(line.start.x, line.start.y),
                            to_xy(line.end.x, line.end.y),
                        ]);
                    }
                    BoundaryEdge::CircularArc(arc) => {
                        let (sa, span) = convert::tess_util::arc_signed_span(
                            arc.start_angle,
                            arc.end_angle,
                            arc.counter_clockwise,
                        );
                        let segs = convert::tess_util::arc_segments(
                            arc.radius,
                            span.abs(),
                            convert::tess_util::fill_chord_tol(arc.radius),
                        );
                        let mut pts = Vec::with_capacity(segs as usize + 1);
                        for i in 0..=segs {
                            let t = sa + span * (i as f64 / segs as f64);
                            pts.push(to_xy(
                                arc.center.x + arc.radius * t.cos(),
                                arc.center.y + arc.radius * t.sin(),
                            ));
                        }
                        edge_polys.push(pts);
                    }
                    BoundaryEdge::EllipticArc(ell) => {
                        let r_maj = (ell.major_axis_endpoint.x * ell.major_axis_endpoint.x
                            + ell.major_axis_endpoint.y * ell.major_axis_endpoint.y)
                            .sqrt();
                        let r_min = r_maj * ell.minor_axis_ratio;
                        let rot = ell
                            .major_axis_endpoint
                            .y
                            .atan2(ell.major_axis_endpoint.x);
                        let (sa, span) = convert::tess_util::arc_signed_span(
                            ell.start_angle,
                            ell.end_angle,
                            ell.counter_clockwise,
                        );
                        let segs = convert::tess_util::arc_segments(
                            r_maj,
                            span.abs(),
                            convert::tess_util::fill_chord_tol(r_maj),
                        );
                        let (cr, sr) = (rot.cos(), rot.sin());
                        let mut pts = Vec::with_capacity(segs as usize + 1);
                        for i in 0..=segs {
                            let t = sa + span * (i as f64 / segs as f64);
                            let lx = r_maj * t.cos();
                            let ly = r_min * t.sin();
                            pts.push(to_xy(
                                ell.center.x + lx * cr - ly * sr,
                                ell.center.y + lx * sr + ly * cr,
                            ));
                        }
                        edge_polys.push(pts);
                    }
                    BoundaryEdge::Spline(spline) => {
                        // DXF spline control_points pack (x, y, weight) into
                        // a Vector3 — the z field is the rational weight, NOT
                        // a Z coordinate. The legacy code dropped weight and
                        // sampled with a fixed 16 segments; both bugs
                        // produced visibly wrong fill regions for spline-
                        // bounded hatches (especially block-internal ones,
                        // where boundaries are often spline curves with
                        // rational weights and short cubic segments).
                        //
                        // Build a NurbsCurve when `rational`, otherwise a
                        // plain BSplineCurve, and sample adaptively via
                        // truck's `parameter_division` at the same chord
                        // tolerance the fill polygon uses for arcs.
                        let degree = spline.degree.max(0) as usize;
                        let knot_vec = if !spline.knots.is_empty() {
                            KnotVec::from(spline.knots.clone())
                        } else if spline.control_points.len() >= degree + 1 {
                            KnotVec::uniform_knot(degree, spline.control_points.len() - 1)
                        } else {
                            KnotVec::from(vec![])
                        };
                        let knot_ok = spline.control_points.len() >= 2
                            && degree >= 1
                            && knot_vec.len() == spline.control_points.len() + degree + 1;

                        // Rough chord-tolerance: 0.1% of the control-poly
                        // diagonal so adaptive sampling produces enough
                        // points to follow the curve without exploding on
                        // huge splines.
                        let (mut sp_min_x, mut sp_min_y) = (f64::INFINITY, f64::INFINITY);
                        let (mut sp_max_x, mut sp_max_y) = (f64::NEG_INFINITY, f64::NEG_INFINITY);
                        for cp in &spline.control_points {
                            sp_min_x = sp_min_x.min(cp.x);
                            sp_min_y = sp_min_y.min(cp.y);
                            sp_max_x = sp_max_x.max(cp.x);
                            sp_max_y = sp_max_y.max(cp.y);
                        }
                        let diag = ((sp_max_x - sp_min_x).powi(2)
                            + (sp_max_y - sp_min_y).powi(2))
                        .sqrt();
                        let tol = convert::tess_util::fill_chord_tol(diag.max(1.0));

                        let mut epts: Vec<[f64; 2]> = Vec::new();
                        let mut sampled = false;
                        if knot_ok {
                            if spline.rational {
                                // NURBS: pack (x, y, 0, w) into Vector4.
                                let cps: Vec<Vector4> = spline
                                    .control_points
                                    .iter()
                                    .map(|p| {
                                        let w = if p.z.abs() > 1e-12 { p.z } else { 1.0 };
                                        Vector4::new(p.x * w, p.y * w, 0.0, w)
                                    })
                                    .collect();
                                let bspl = TruckBSpline::new(knot_vec.clone(), cps);
                                let curve = NurbsCurve::new(bspl);
                                let (t0, t1) = curve.range_tuple();
                                let (_, pts) = curve.parameter_division((t0, t1), tol);
                                for p in pts {
                                    epts.push(to_xy(p.x, p.y));
                                }
                                sampled = true;
                            } else {
                                let cps: Vec<Point3> = spline
                                    .control_points
                                    .iter()
                                    .map(|p| Point3::new(p.x, p.y, 0.0))
                                    .collect();
                                let bspl = TruckBSpline::new(knot_vec, cps);
                                let (t0, t1) = bspl.range_tuple();
                                let (_, pts) = bspl.parameter_division((t0, t1), tol);
                                for p in pts {
                                    epts.push(to_xy(p.x, p.y));
                                }
                                sampled = true;
                            }
                        }
                        if !sampled {
                            // Fallback: prefer fit_points (which lie on the
                            // curve) over control_points (which usually
                            // don't). A control-point polyline would draw
                            // the convex-hull silhouette — visibly wrong.
                            let pts: &[_] = if !spline.fit_points.is_empty() {
                                &spline.fit_points
                            } else {
                                &[]
                            };
                            if !pts.is_empty() {
                                for p in pts {
                                    epts.push(to_xy(p.x, p.y));
                                }
                            } else {
                                for cp in &spline.control_points {
                                    epts.push(to_xy(cp.x, cp.y));
                                }
                            }
                        }
                        edge_polys.push(epts);
                    }
                }
            }
            boundary.extend(chain_path_edges(edge_polys));

            if boundary.len() == path_start {
                boundary.truncate(before_path);
                continue;
            }
            if boundary.len() >= path_start + 3 {
                let first = boundary[path_start];
                let last = *boundary.last().unwrap();
                if (first[0] - last[0]).abs() > 1e-5 || (first[1] - last[1]).abs() > 1e-5 {
                    boundary.push(first);
                }
            }
        }

        if boundary.is_empty() {
            return None;
        }
        // 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 color2 = stop(1).unwrap_or(color);
            let angle_deg = dxf.pattern_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,
            }
        } 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 {
            boundary: std::sync::Arc::new(boundary_f32),
            boundary_wcs: None,
            pattern,
            name,
            // A gradient starts from its first stop; other fills use the
            // entity colour.
            color: gradient_color1.unwrap_or(color),
            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_active_context(&self.document, entity)
            });
        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_active_context(&self.document, e)
                        .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<ImageModel> {
        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<Handle> = 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 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, 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.
    /// DXF SOLID corners are in "Z-order": p0-p1 top, p2-p3 bottom.
    /// Visual quad is p0→p1→p3→p2 (closed).
    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.
        // SOLID corners are stored in OCS (like ARC/LWPOLYLINE) — map through
        // the arbitrary axis so a mirrored solid (normal 0,0,-1) fills on the
        // correct side of the drawing.
        let n = (solid.normal.x, solid.normal.y, solid.normal.z);
        let w = |v: &acadrust::types::Vector3| -> [f64; 2] {
            let (x, y, _) =
                crate::scene::view::transform::ocs_point_to_wcs((v.x, v.y, v.z), n);
            [x, y]
        };
        let corners: [[f64; 2]; 4] = [
            w(&solid.first_corner),
            w(&solid.second_corner),
            w(&solid.fourth_corner),
            w(&solid.third_corner),
        ];
        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 {
            boundary: std::sync::Arc::new(boundary),
            boundary_wcs: None,
            pattern: model::hatch_model::HatchPattern::Solid,
            name: "SOLID".into(),
            color,
            angle_offset: 0.0,
            scale: 1.0,
            world_origin,
            draw_depth: 0.0,
        }
    }

    pub fn add_hatch(&mut self, model: HatchModel) -> Handle {
        let mut dxf = DxfHatch::new();
        dxf.is_solid = matches!(
            model.pattern,
            crate::scene::model::hatch_model::HatchPattern::Solid
        );
        // Prefer the command-supplied exact f64 boundary so a typed vertex is
        // persisted without f32 quantization (issue #311). Falling back to the
        // render-side `boundary`, the points arrive in local render space
        // (world_offset already subtracted), so add `world_origin` back —
        // otherwise the boundary wire lands `world_offset` away from the fill.
        // Build one DXF boundary path per ring. The command layer separates
        // the outer boundary from its holes with NaN sentinels in
        // `boundary_wcs`; split on those so nested hatches (e.g. a small
        // rectangle inside a big one) persist with real holes instead of a
        // single self-intersecting polyline. Only the FIRST ring carries the
        // external / outermost flags — every later ring is a hole and must not
        // be flagged external, or DXF/DWG consumers treat the inner loop as
        // another outer island rather than a hole.
        if let Some(wcs) = &model.boundary_wcs {
            let mut ring: Vec<Vector2> = Vec::new();
            let mut first = true;
            let mut push_ring = |r: &mut Vec<Vector2>, is_outer: bool| {
                if !r.is_empty() {
                    let edge = PolylineEdge::new(std::mem::take(r), true);
                    let mut path = if is_outer {
                        let mut p = BoundaryPath::external();
                        p.flags = acadrust::entities::hatch::BoundaryPathFlags::from_bits(
                            p.flags.bits()
                                | acadrust::entities::hatch::BoundaryPathFlags::OUTERMOST.bits(),
                        );
                        p
                    } else {
                        BoundaryPath::new()
                    };
                    path.add_edge(BoundaryEdge::Polyline(edge));
                    dxf.paths.push(path);
                }
            };
            for &[x, y] in wcs.iter() {
                if x.is_finite() && y.is_finite() {
                    ring.push(Vector2::new(x, y));
                } else {
                    let is_outer = first;
                    first = false;
                    push_ring(&mut ring, is_outer);
                }
            }
            push_ring(&mut ring, first);
        }
        if dxf.paths.is_empty() {
            let wx = model.world_origin[0];
            let wy = model.world_origin[1];
            let verts: Vec<Vector2> = model
                .boundary
                .iter()
                .filter(|v| v[0].is_finite() && v[1].is_finite())
                .map(|&[x, y]| Vector2::new(x as f64 + wx, y as f64 + wy))
                .collect();
            let edge = PolylineEdge::new(verts, true);
            let mut path = BoundaryPath::external();
            path.add_edge(BoundaryEdge::Polyline(edge));
            dxf.paths.push(path);
        }
        if let Some(entry) = crate::scene::model::hatch_patterns::find(&model.name) {
            dxf.pattern = crate::scene::model::hatch_patterns::build_dxf_pattern(entry);
        }
        dxf.pattern_angle = model.angle_offset as f64;
        dxf.pattern_scale = if model.scale.abs() > 1e-6 {
            model.scale as f64
        } else {
            1.0
        };
        // 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,
        } = &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();
            // The render model reads the gradient angle from pattern_angle
            // (radians); the gradient record keeps its own copy for the file.
            dxf.pattern_angle = (*angle_deg as f64).to_radians();
            dxf.gradient_color.angle = (*angle_deg as f64).to_radians();
            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.
        self.add_entity(EntityType::Hatch(dxf))
    }

    pub fn clear(&mut self) {
        self.document.record_all_entities_for_transaction();
        self.document = CadDocument::new();
        self.selected = HashSet::default();
        self.preview_wires = vec![];
        self.preview_text = vec![];
        self.current_layout = "Model".to_string();
        self.hatches = HashMap::default();
        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();
    }
}
