cad-editor/src/scene/entity.rs
Hakan Seven 970ee5a09f fix(blocks): layer-0 children inherit the block instance layer (#221)
Entities inside a block definition that sit on layer "0" must take on the
properties of the layer the block reference is inserted on; every other
layer is "sticky" and keeps its own identity. OpenCADStudio resolved a
block child's ByLayer color/linetype/lineweight against the child's own
layer in all cases, so layer-0 content stayed layer-0 (white) instead of
inheriting the instance layer.

Resolution now follows the standard rule for block children — explicit
wins, then ByBlock → the insert's own style, then layer-0 + ByLayer →
the insert's *layer* style, else the child's own layer. The insert's
layer style threads through the whole block-expansion chain (and chains
correctly through nested inserts), with the child's own transparency
preserved.

Covered across every block-render path:
- wires (line/arc/circle/polyline/text) via the block cache + the
  explode fallback + insert attributes
- hatch / solid fills (also fixes the PDF/print export path)
- 3D solid block children (per-instance mesh recolor)
- Dimension / Table baked-block sub-entities

New shared helpers in scene::view::render: InheritStyle, layer_render_style,
lineweight_to_px; render_style_for_block_sub gained the layer-0 branch.
Adds unit tests covering the layer-0, sticky-layer, ByBlock, explicit and
transparency cases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 17:24:04 +03:00

1253 lines
54 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Auto-split from scene/mod.rs. Pure text-move; behaviour unchanged.
use super::*;
impl Scene {
// ── Entity management ─────────────────────────────────────────────────
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(_)
);
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 = if let EntityType::RasterImage(img) = &entity {
ImageModel::from_raster_image(img)
} else {
None
};
let facet_res = self.document.header.facet_resolution;
let mesh_seed = if matches!(
&entity,
EntityType::Solid3D(_) | EntityType::Region(_) | EntityType::Body(_) | EntityType::Surface(_)
) {
let color = self.render_style(&entity).0;
crate::entities::solid3d::tessellate_volume(&entity, color, facet_res)
.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);
}
}
// Route to the correct block based on current editing mode:
// - PSPACE (paper layout, no active viewport): paper-space layout block.
// - MSPACE or model layout: model space (document default).
let handle = if self.current_layout != "Model" && self.active_viewport.is_none() {
let layout_name = self.current_layout.clone();
self.document
.add_entity_to_layout(entity, &layout_name)
.unwrap_or(Handle::NULL)
} else {
self.document.add_entity(entity).unwrap_or(Handle::NULL)
};
if !handle.is_null() {
if let Some(model) = hatch_seed {
self.hatches.insert(handle, model);
}
if let Some(model) = image_seed {
self.images.insert(handle, model);
}
if let Some(model) = mesh_seed {
self.meshes.insert(handle, model);
}
if affects_blocks {
self.bump_geometry();
} else {
self.bump_geometry_no_blocks();
}
}
handle
}
/// 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::Vec3,
) -> 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).as_dvec3());
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 as f64, base.y as f64, base.z as f64),
);
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) -> 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;
}
// 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.belongs_to_visible_block(
handle,
c.owner_handle,
self.model_space_block_handle(),
)
})
.map(|(&handle, model)| {
let entity = self.document.get_entity(handle);
let mut m = model.clone();
if let Some(e) = entity {
m.color = self.render_style(e).0;
if let EntityType::Hatch(dxf) = e {
match &mut m.pattern {
model::hatch_model::HatchPattern::Pattern(_) => {
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::Solid => {}
}
}
}
if self.selected.contains(&handle) {
m.color = [0.15, 0.55, 1.00, m.color[3]];
}
m.draw_depth = depth_map.get(&handle.value()).copied().unwrap_or(0.0);
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
};
for entity in self.document.entities() {
let EntityType::Insert(ins) = entity else {
continue;
};
if ins.common.invisible || layer_hidden(&ins.common.layer) {
continue;
}
if !self.belongs_to_visible_block(
ins.common.handle,
ins.common.owner_handle,
layout_block,
) {
continue;
}
let 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 mut stack: Vec<(
EntityType,
usize,
[f32; 4],
crate::scene::view::render::InheritStyle,
)> = ins
.explode_from_document(&self.document)
.into_iter()
.map(|e| (normalize(e), 0usize, ins_color, l0))
.collect();
while let Some((sub, depth, sub_ins_color, sub_l0)) = 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 child_ins_color = if nins.common.color == Color::ByBlock {
sub_ins_color
} else if nins.common.layer == "0"
&& nins.common.color == Color::ByLayer
{
sub_l0.color
} else {
crate::scene::view::render::render_style_for(
&self.document,
&EntityType::Insert(nins.clone()),
)
.0
};
let child_l0 = if nins.common.layer == "0" {
sub_l0
} else {
crate::scene::view::render::layer_render_style(
&self.document,
&nins.common.layer,
)
};
for e in nins.explode_from_document(&self.document) {
stack.push((normalize(e), depth + 1, child_ins_color, child_l0));
}
}
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)
{
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 and Polyline2D fills
for entity in self.document.entities() {
let (common, fill_origin, fills) = match entity {
EntityType::LwPolyline(pl) => {
let (o, f) = crate::entities::lwpolyline::wide_fills(pl);
(&pl.common, o, f)
}
EntityType::Polyline2D(pl) => {
let (o, f) = crate::entities::polyline::wide_fills(pl);
(&pl.common, o, f)
}
_ => continue,
};
if fills.is_empty() {
continue;
}
if common.invisible || layer_hidden(&common.layer) {
continue;
}
if !self.belongs_to_visible_block(common.handle, common.owner_handle, layout_block) {
continue;
}
let base_color = self.render_style(entity).0;
let selected = self.selected.contains(&common.handle);
let color = if selected {
[0.15, 0.55, 1.00, 1.0]
} else {
base_color
};
for boundary in fills {
models.push(HatchModel {
boundary: Arc::new(boundary),
pattern: model::hatch_model::HatchPattern::Solid,
name: "SOLID".into(),
color,
angle_offset: 0.0,
scale: 1.0,
world_origin: fill_origin,
vp_scissor: None,
draw_depth: depth_map.get(&common.handle.value()).copied().unwrap_or(0.0),
});
}
}
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) -> 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 mut models = Vec::new();
for entity in self.document.entities() {
let EntityType::Wipeout(wo) = entity else {
continue;
};
if entity.common().invisible {
continue;
}
if self
.document
.layers
.get(&entity.common().layer)
.map(|l| l.flags.off || l.flags.frozen)
.unwrap_or(false)
{
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),
pattern: model::hatch_model::HatchPattern::Solid,
name: "WIPEOUT_FILL".into(),
color: fill_color,
angle_offset: 0.0,
scale: 1.0,
world_origin: fill_origin,
vp_scissor: None,
draw_depth: 0.0,
});
}
}
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 {
let before_path = boundary.len();
if !boundary.is_empty() {
boundary.push([f64::NAN, f64::NAN]);
}
let path_start = boundary.len();
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) => {
boundary.push(to_xy(line.start.x, line.start.y));
boundary.push(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),
);
for i in 0..=segs {
let t = sa + span * (i as f64 / segs as f64);
boundary.push(to_xy(
arc.center.x + arc.radius * t.cos(),
arc.center.y + arc.radius * t.sin(),
));
}
}
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());
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();
boundary.push(to_xy(
ell.center.x + lx * cr - ly * sr,
ell.center.y + lx * sr + ly * cr,
));
}
}
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 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 {
boundary.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 {
boundary.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 {
boundary.push(to_xy(p.x, p.y));
}
} else {
for cp in &spline.control_points {
boundary.push(to_xy(cp.x, cp.y));
}
}
}
}
}
}
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);
}
let pattern = if dxf.gradient_color.is_enabled() {
let color2 = dxf
.gradient_color
.colors
.get(1)
.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])
.unwrap_or(color);
let angle_deg = dxf.pattern_angle.to_degrees() as f32;
model::hatch_model::HatchPattern::Gradient { angle_deg, color2 }
} else if dxf.is_solid {
model::hatch_model::HatchPattern::Solid
} else {
let pat_name = &dxf.pattern.name;
if let Some(entry) = crate::scene::model::hatch_patterns::find(pat_name) {
entry.gpu.clone()
} 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]
};
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),
pattern,
name,
color,
angle_offset: dxf.pattern_angle as f32,
scale: dxf.pattern_scale as f32,
world_origin,
vp_scissor: None,
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.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);
}
}
self.bump_geometry();
}
pub fn populate_hatches_from_document(&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, e.clone())),
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();
self.bump_geometry();
}
/// 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.
pub fn populate_meshes_from_document(&mut self) {
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(_) => {
let color = self.render_style(e).0;
let top_level = layout_blocks.contains(&e.common().owner_handle);
Some((e.common().handle, e.clone(), color, top_level))
}
_ => None,
})
.collect();
use crate::par::prelude::*;
let facet_res = self.document.header.facet_resolution;
// 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).map(|m| {
let m = if top_level { offset_mesh_lod_set(m) } else { m };
(handle, m, top_level)
})
})
.collect();
for (handle, m, top_level) in built {
if top_level {
self.meshes.insert(handle, m);
} else {
self.block_meshes.insert(handle, m);
}
}
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.populate_hatches_from_document();
self.populate_images_from_document();
self.populate_meshes_from_document();
}
/// 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 {
let boundary = vec![
[
(solid.first_corner.x) as f32,
(solid.first_corner.y) as f32,
],
[
(solid.second_corner.x) as f32,
(solid.second_corner.y) as f32,
],
[
(solid.fourth_corner.x) as f32,
(solid.fourth_corner.y) as f32,
],
[
(solid.third_corner.x) as f32,
(solid.third_corner.y) as f32,
],
];
HatchModel {
boundary: std::sync::Arc::new(boundary),
pattern: model::hatch_model::HatchPattern::Solid,
name: "SOLID".into(),
color,
angle_offset: 0.0,
scale: 1.0,
world_origin: [0.0; 2],
vp_scissor: None,
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
);
// The boundary points arrive in local render space (world_offset
// already subtracted). The stored DXF entity must hold WCS, so add the
// offset back — otherwise the boundary wire, re-projected through the
// normal entity path, lands `world_offset` away from the fill.
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
};
// `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 = CadDocument::new();
self.selected = HashSet::default();
self.preview_wires = vec![];
self.current_layout = "Model".to_string();
self.hatches = HashMap::default();
self.meshes = HashMap::default();
*self.camera.borrow_mut() = Camera::default();
self.camera_generation += 1;
self.bump_geometry();
}
}