diff --git a/src/app/properties.rs b/src/app/properties.rs index 546c0dc5a..5b6405b8b 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -29,14 +29,19 @@ fn material_map_text(map: &acadrust::objects::MaterialMap) -> String { } } 2 => format!( - "Procedural {}", - map.texture.as_ref().map_or(0, |texture| texture.mode) + "Procedural {:#?}", + map.texture ), - _ => "None".to_string(), + 0 => "Scene".to_string(), + value => format!("Source {value}"), }; format!( - "{source}; blend {:.3}; projection {}; tiling {}; auto {}", - map.blend_factor, map.projection, map.tiling, map.auto_transform + "{source}; blend {:.3}; projection {}; tiling {}; auto {}; transform {:?}", + map.blend_factor, + map.projection, + map.tiling, + map.auto_transform, + map.transform ) } @@ -568,11 +573,31 @@ impl OpenCADStudio { t!("Advanced").as_ref(), "mat_advanced", format!( - "normal {:.3}; bump {:.3}; reflect {:.3}; transmit {:.3}", + "normal method {}; strength {:.3}; bump {:.3}; reflect {:.3}; transmit {:.3}; bleed {:.3}", + material.normal_map_method, material.normal_map_strength, material.indirect_bump_scale, material.reflectance_scale, - material.transmittance_scale + material.transmittance_scale, + material.color_bleed_scale + ), + ), + ro_prop( + t!("Indirect Lighting").as_ref(), + "mat_indirect_lighting", + format!( + "global {}; final gather {}", + material.global_illumination, + material.final_gather + ), + ), + ro_prop( + t!("Source State").as_ref(), + "mat_source_state", + format!( + "advanced {}; anonymous {}", + material.advanced_data_present, + material.is_anonymous ), ), ], diff --git a/src/entities/dimension.rs b/src/entities/dimension.rs index 007e8d760..e1f33f7fe 100644 --- a/src/entities/dimension.rs +++ b/src/entities/dimension.rs @@ -1309,7 +1309,8 @@ use acadrust::{CadDocument, EntityType, Handle}; use crate::scene::convert::tess_util::aci_to_rgba; use crate::scene::convert::tessellate::{ - add_polyline, add_segment, append_arrow, arrow_from_block, normalized_or, ArrowKind, DimGeom, + add_polyline, add_segment, append_arrow, arrow_from_block_with_deferred_hatch, + normalized_or, ArrowKind, DimGeom, }; use crate::scene::model::wire_model::{SnapHint, WireModel}; @@ -1660,6 +1661,16 @@ fn tessellate_dimension_inner( // 3. DIMSAH true → DIMBLK1 (first end), DIMBLK2 (second end). // Unknown / NULL block handles fall back to ClosedFilled. let dimasz = (dimasz_raw as f32).max(0.001); + let defer_arrow_hatches = { + let name = dim.base().block_name.trim(); + let mut memo = std::collections::HashMap::new(); + !name.is_empty() + && crate::scene::render_graph::block_contains_hatch( + document, + name, + &mut memo, + ) + }; let (arrow1, arrow2) = if dimtsz_raw > 1e-9 { let t = ArrowKind::Tick { size: (dimtsz_raw as f32).max(0.001), @@ -1668,11 +1679,26 @@ fn tessellate_dimension_inner( } else if let Some(s) = style { if dimsah { ( - arrow_from_block(document, s.dimblk1, dimasz), - arrow_from_block(document, s.dimblk2, dimasz), + arrow_from_block_with_deferred_hatch( + document, + s.dimblk1, + dimasz, + defer_arrow_hatches, + ), + arrow_from_block_with_deferred_hatch( + document, + s.dimblk2, + dimasz, + defer_arrow_hatches, + ), ) } else { - let a = arrow_from_block(document, s.dimblk, dimasz); + let a = arrow_from_block_with_deferred_hatch( + document, + s.dimblk, + dimasz, + defer_arrow_hatches, + ); (a.clone(), a) } } else { diff --git a/src/entities/multileader.rs b/src/entities/multileader.rs index 5c8e7ce3b..0d2a7d478 100644 --- a/src/entities/multileader.rs +++ b/src/entities/multileader.rs @@ -1538,9 +1538,9 @@ impl MultiLeaderTess for MultiLeader { // ── Block content ─────────────────────────────────────────────────────── // When content_type == Block, the MultiLeader displays a block reference - // at block_content_location with the recorded rotation/scale. Synthesize - // an Insert and explode it through the standard tessellator. The block - // resolves via block_content_handle (handle → block_record name). + // at block_content_location with the recorded rotation/scale. A + // synthetic Insert supplies that context to the shared scene graph; + // every child then uses its normal tessellator. if ml.content_type == LeaderContentType::Block && ml.context.has_block_contents { let block_name = match ml.block_content_handle { Some(h) if !h.is_null() => document @@ -1567,29 +1567,42 @@ impl MultiLeaderTess for MultiLeader { // anchor when *creating* the multileader; at render time the // file's stored leader endpoints already encode that choice. let _ = ml.block_connection_type; - for sub in synth_ins.explode_from_document(document) { - let normalized = - crate::modules::draw::modify::explode::normalize_insert_entity(sub); - let mut sub_wires = tessellate( - document, - handle, - &normalized, - selected, - block_color, - leader_pat_len, - leader_pat, - leader_lw_px, - 1.0, - None, - None, - bg_color, - false, - ); - for w in &mut sub_wires { - w.name = name.clone(); - } - wires.extend(sub_wires); - } + let depths = rustc_hash::FxHashMap::default(); + let graph = crate::scene::render_graph::RenderSceneGraph::new( + document, + None, + None, + true, + &depths, + ); + graph.walk_insert( + &synth_ins, + handle, + |_, _| true, + |entity, context| { + let mut placed = entity.clone(); + placed.apply_transform(&context.transform); + let mut sub_wires = tessellate( + document, + handle, + &placed, + selected, + block_color, + leader_pat_len, + leader_pat, + leader_lw_px, + 1.0, + None, + None, + bg_color, + false, + ); + for w in &mut sub_wires { + w.name = name.clone(); + } + wires.extend(sub_wires); + }, + ); // Block attributes attached to the multileader — render each as // its own attribute entity at WCS location like INSERT does. for ba in &ml.block_attributes { diff --git a/src/scene/cache/block_cache.rs b/src/scene/cache/block_cache.rs index 4610b38bb..c681db791 100644 --- a/src/scene/cache/block_cache.rs +++ b/src/scene/cache/block_cache.rs @@ -100,27 +100,7 @@ pub struct LocalWire { pub struct NestedRef { pub block_name: String, pub xform: Transform, - /// Nested INSERT's own resolved style (used when child wires need - /// to inherit something via ByBlock). - pub ins_color: [f32; 4], - pub ins_pat_len: f32, - pub ins_pat: [f32; 8], - pub ins_lw_px: f32, - pub color_is_byblock: bool, - pub lt_is_byblock: bool, - pub lw_is_byblock: bool, - /// The nested INSERT's own properties are ByLayer. Combined with - /// `layer_is_zero` these drive the layer-0 rule for the nested insert - /// *itself* (so its ByBlock leaves inherit the outer layer, not layer 0). - pub color_is_bylayer: bool, - pub lt_is_bylayer: bool, - pub lw_is_bylayer: bool, - /// The nested INSERT itself sits on layer "0": its own layer-0 children - /// chain up to the outer insert's layer rather than resolving to `l0`. - pub layer_is_zero: bool, - /// The nested INSERT's own layer style — the layer-0 inheritance target - /// for its children when the nested insert is not itself on layer 0. - pub l0: crate::scene::view::render::InheritStyle, + pub style: crate::scene::render_graph::InsertStyleSpec, pub instance_offsets: Vec<[f64; 3]>, /// XCLIP boundary for this nested insert, in the parent defn's local frame /// (`None` = unclipped). Baked at build time because the clip's spatial @@ -142,6 +122,7 @@ pub enum LocalSub { #[derive(Clone, Debug, Default)] pub struct BlockDefn { pub subs: Vec, + pub base_point: Vector3, /// Union of every sub's local AABB (including nested-INSERT contributions /// resolved at expand time via their own defn's `aabb_local`). XY only — /// the wire renderer is 2D-dominant. Expressed in this defn's *offset* @@ -149,8 +130,8 @@ pub struct BlockDefn { pub aabb_local: [f32; 4], /// Raw entity count of the source block record (`entity_handles.len()`). /// Divisor for nested depth composition: a nested insert's children get a - /// sub-range of `parent_scale / (child_count + 1)` — the same formula the - /// exploded-fill path uses, so bands and fills stay on one depth scale. + /// sub-range of `parent_scale / (child_count + 1)`, shared with the scene + /// graph so bands and fills stay on one depth scale. pub child_count: usize, } @@ -203,8 +184,7 @@ impl BlockCache { all_visible: bool, bg_color: [f32; 4], // Scene draw-depth map ([depth, half] per handle) — source of each - // block child's in-block rank, so band depth composition agrees with - // the SortEntitiesTable-aware ranking the fill explosion uses. + // block child's in-block rank, shared by cached wires and graph leaves. depth_map: &HashMap, ) -> Self { use crate::par::prelude::*; @@ -249,6 +229,41 @@ impl BlockCache { cache } + /// Build a small cache rooted at one block. Used only when a synthetic or + /// newly-added Insert is not present in the resident cache yet. + pub fn build_for_block( + doc: &CadDocument, + block_name: &str, + anno_scale: f32, + annotation_scale_handle: Option, + all_visible: bool, + bg_color: [f32; 4], + depth_map: &HashMap, + ) -> Self { + use crate::par::prelude::*; + let mut cache = Self::new(); + let referenced = referenced_block_tree(doc, block_name); + cache.defns = referenced + .par_iter() + .map(|name| { + ( + name.clone(), + Arc::new(build_defn( + doc, + name, + anno_scale, + annotation_scale_handle, + all_visible, + bg_color, + depth_map, + )), + ) + }) + .collect(); + cache.compute_block_aabbs(&referenced); + cache + } + /// Compute and store the `aabb_local` for every cached defn. Direct wires /// contribute their own aabb_local; nested INSERT references look up the /// nested defn (already cached) and transform its aabb_local by the @@ -346,6 +361,26 @@ fn collect_referenced_blocks(doc: &CadDocument) -> Vec { seen.into_iter().collect() } +fn referenced_block_tree(doc: &CadDocument, root: &str) -> Vec { + let mut seen: HashSet = HashSet::default(); + let mut queue = vec![root.to_string()]; + seen.insert(root.to_string()); + while let Some(name) = queue.pop() { + let Some(record) = doc.block_records.get(&name) else { + continue; + }; + for &handle in &record.entity_handles { + let Some(EntityType::Insert(insert)) = doc.get_entity(handle) else { + continue; + }; + if seen.insert(insert.block_name.clone()) { + queue.push(insert.block_name.clone()); + } + } + } + seen.into_iter().collect() +} + /// True when `layer` is turned off or frozen — entities on it never render. fn layer_hidden(doc: &CadDocument, layer: &str) -> bool { doc.layers @@ -441,79 +476,67 @@ fn build_defn( subs.push(LocalSub::Wire(wire)); } } - // A table nested in a block bakes its geometry (gridlines, cell - // text, fills) into a `*T` block, laid out in block-LOCAL space with - // the table carrying the world placement in insertion_point + - // horizontal_direction (like an INSERT). The top-level path expands - // and places it in `tessellate_entity`; the block-expand path skips - // that, so a nested table drew nothing. Place each `*T` sub by the - // table's rotation+insertion here (defn-local), then tessellate — the - // parent insert transform composes on top at expand time. - EntityType::Table(tab) => { - let tblk = tab - .block_record_handle - .and_then(|h| doc.block_records.iter().find(|br| br.handle == h)); - let mut used_baked = false; - if let Some(tblk) = tblk { - used_baked = !tblk.entity_handles.is_empty(); - let ins = tab.insertion_point; - let angle = tab.horizontal_direction.y.atan2(tab.horizontal_direction.x); - for &teh in &tblk.entity_handles { - let Some(tsub) = doc.get_entity(teh) else { - continue; - }; - if tsub.common().invisible || layer_hidden(doc, &tsub.common().layer) { - continue; - } - let mut placed = tsub.clone(); - { - let ent = placed.as_entity_mut(); - if angle.abs() > 1e-9 { - ent.apply_rotation( - acadrust::types::Vector3::new(0.0, 0.0, 1.0), - angle, - ); + // A nested table's stored graphics use the same scene-graph + // traversal as top-level table content. The resulting leaf wires + // remain local to this cached definition; the parent instance + // transform is composed later by the cache expansion. + EntityType::Table(table) => { + let baked = table.block_record_handle.and_then(|handle| { + doc.block_records + .iter() + .find(|record| record.handle == handle) + }); + if let Some(record) = baked.filter(|record| { + !record.entity_handles.is_empty() + }) { + let mut insert = acadrust::entities::Insert::new( + record.name.clone(), + table.insertion_point, + ); + insert.rotation = table + .horizontal_direction + .y + .atan2(table.horizontal_direction.x); + insert.common = table.common.clone(); + let graph = crate::scene::render_graph::RenderSceneGraph::new( + doc, + None, + annotation_scale_handle, + all_visible, + depth_map, + ); + graph.walk_insert( + &insert, + table.common.handle, + |_, _| true, + |leaf, context| { + let mut placed = leaf.clone(); + placed.apply_transform(&context.transform); + for wire in tessellate_sub_local( + doc, + &placed, + anno_scale, + annotation_scale_handle, + bg_color, + depth_map, + ) { + subs.push(LocalSub::Wire(wire)); } - ent.translate(ins); - } - // A baked table can nest inserts too (block cells); - // route those through the nested-ref path like the - // dimension arrows above. - if let EntityType::Insert(cell_ins) = &placed { - subs.push(LocalSub::Nested(build_nested_ref( - cell_ins, doc, bg_color, depth_map, - ))); - } else { - for lw in - tessellate_sub_local( - doc, - &placed, - anno_scale, - annotation_scale_handle, - bg_color, - depth_map, - ) - { - subs.push(LocalSub::Wire(lw)); - } - } - } - } - if !used_baked { - for lw in - tessellate_sub_local( - doc, - entity, - anno_scale, - annotation_scale_handle, - bg_color, - depth_map, - ) - { - subs.push(LocalSub::Wire(lw)); + }, + ); + } else { + for wire in tessellate_sub_local( + doc, + entity, + anno_scale, + annotation_scale_handle, + bg_color, + depth_map, + ) { + subs.push(LocalSub::Wire(wire)); } for insert in crate::entities::table::block_cell_inserts( - tab, + table, doc, anno_scale, ) { @@ -546,6 +569,7 @@ fn build_defn( } BlockDefn { subs, + base_point: crate::scene::render_graph::block_base_point(doc, block_name), aabb_local: [0.0; 4], child_count: br.entity_handles.len(), } @@ -557,49 +581,22 @@ fn build_nested_ref( bg_color: [f32; 4], depth_map: &HashMap, ) -> NestedRef { - // Store the RAW colour — `adapt_to_bg` runs at emit time - // (`Batches::finalize`) so the same cached defn can serve renders - // against different backgrounds without rebuilding. - let (ins_color, ins_pat_len, ins_pat, ins_lw_px, _) = - crate::scene::view::render::render_style_for(doc, &EntityType::Insert(nested_ins.clone())); - // The nested insert's own layer style — the layer-0 target for its - // children (raw colour; adapted at emit like `ins_color`). - let l0 = crate::scene::view::render::layer_render_style(doc, &nested_ins.common.layer); let _ = bg_color; - let nested_entity = EntityType::Insert(nested_ins.clone()); - let has_book_color = - crate::scene::view::render::has_resolved_book_color(doc, &nested_entity); // Bake the XCLIP boundary (parent-defn-local) so the nested insert keeps // its clip when the parent block is expanded — the spatial filter object // isn't reachable at expand time. + let xform = crate::scene::render_graph::insert_transform(doc, nested_ins); let clip_poly = crate::scene::pick::xclip::insert_spatial_filter(doc, nested_ins) - .map(|sf| crate::scene::pick::xclip::world_clip_polygon_f64(sf, nested_ins)); + .map(|filter| { + crate::scene::pick::xclip::world_clip_polygon_for_transform(filter, &xform) + }); NestedRef { block_name: nested_ins.block_name.clone(), - xform: nested_ins.get_transform(), - ins_color, - ins_pat_len, - ins_pat, - ins_lw_px, - color_is_byblock: !has_book_color && nested_ins.common.color == AcadColor::ByBlock, - lt_is_byblock: nested_ins.common.linetype.eq_ignore_ascii_case("byblock"), - lw_is_byblock: matches!(nested_ins.common.line_weight, LineWeight::ByBlock), - color_is_bylayer: !has_book_color && nested_ins.common.color == AcadColor::ByLayer, - lt_is_bylayer: { - let lt = &nested_ins.common.linetype; - lt.is_empty() || lt.eq_ignore_ascii_case("bylayer") - }, - lw_is_bylayer: matches!( - nested_ins.common.line_weight, - LineWeight::ByLayer | LineWeight::Default - ), - layer_is_zero: crate::scene::view::render::is_effective_layer_zero( - &nested_ins.common.layer, - ), - l0, - instance_offsets: array_offsets(nested_ins), + xform, + style: crate::scene::render_graph::InsertStyleSpec::new(doc, nested_ins), + instance_offsets: crate::scene::render_graph::array_offsets(nested_ins), clip_poly, local_rank: depth_map .get(&nested_ins.common.handle.value()) @@ -877,7 +874,9 @@ pub fn expand_insert( anno_scale: f32, ) -> Option> { let defn = cache.defn(&ins.block_name)?; - let mut xform = ins.get_transform(); + let base = defn.base_point; + let mut xform = Transform::from_translation(Vector3::new(-base.x, -base.y, -base.z)) + .then(&ins.get_transform()); // Annotative blocks (the flag lives on the block definition; the instance is // marked with the AcAnnotativeData XDATA) scale as ONE uniform unit about // their insertion point — internal geometry/text/attributes are carried by @@ -979,7 +978,7 @@ pub fn expand_insert( } } - for offset in &array_offsets(ins) { + for offset in &crate::scene::render_graph::array_offsets(ins) { let base_xform = if offset == &[0.0; 3] { xform.clone() } else { @@ -1475,38 +1474,25 @@ fn expand_defn( // itself on layer "0" with ByLayer props inherits the outer // layer-0 target (so its ByBlock leaves resolve to that layer, // not layer 0). Mirrors the leaf resolution in emit_wire. - let nested_color = if nref.color_is_byblock { - ctx.ins_color - } else if nref.layer_is_zero && nref.color_is_bylayer { - ctx.l0.color - } else { - nref.ins_color + let parent_style = crate::scene::render_graph::BlockStyle { + insert: ( + ctx.ins_color, + ctx.ins_pat_len, + ctx.ins_pat, + ctx.ins_lw_px, + 0, + ), + layer0: ctx.l0, + layer0_aci: 0, }; - let (nested_pat_len, nested_pat) = if nref.lt_is_byblock { - (ctx.ins_pat_len, ctx.ins_pat) - } else if nref.layer_is_zero && nref.lt_is_bylayer { - (ctx.l0.pat_len, ctx.l0.pat) - } else { - (nref.ins_pat_len, nref.ins_pat) - }; - let nested_lw_px = if nref.lw_is_byblock { - ctx.ins_lw_px - } else if nref.layer_is_zero && nref.lw_is_bylayer { - ctx.l0.lw_px - } else { - nref.ins_lw_px - }; - // Layer-0 target for the nested expansion: a nested insert that - // is itself on layer 0 chains up to the outer target; otherwise - // its layer-0 children resolve to the nested insert's own layer. - let nested_l0 = if nref.layer_is_zero { ctx.l0 } else { nref.l0 }; + let nested_style = nref.style.resolve(parent_style); let inner_ctx = ExpandCtx { cache: ctx.cache, - ins_color: nested_color, - ins_pat_len: nested_pat_len, - ins_pat: nested_pat, - ins_lw_px: nested_lw_px, - l0: nested_l0, + ins_color: nested_style.insert.0, + ins_pat_len: nested_style.insert.1, + ins_pat: nested_style.insert.2, + ins_lw_px: nested_style.insert.3, + l0: nested_style.layer0, selected: ctx.selected, pslt_factor: ctx.pslt_factor, view_aabb: ctx.view_aabb, @@ -1516,7 +1502,7 @@ fn expand_defn( }; visited.push(nref.block_name.clone()); // Children of this nested insert stack inside the slot its own - // rank owns — same composition the exploded-fill path applies. + // rank owns — same composition the scene graph applies. let nested_range = ( d_range.0 + nref.local_rank * d_range.1, d_range.1 / (nested_defn.child_count.max(1) as f32 + 1.0), @@ -1985,20 +1971,3 @@ fn is_unreasonable_extent(e: &EntityType) -> bool { _ => false, } } - -fn array_offsets(ins: &acadrust::entities::Insert) -> Vec<[f64; 3]> { - if !ins.is_minsert() { - return vec![[0.0; 3]]; - } - let mut offsets = Vec::with_capacity(ins.instance_count()); - for row in 0..ins.row_count { - for col in 0..ins.column_count { - offsets.push([ - col as f64 * ins.column_spacing, - row as f64 * ins.row_spacing, - 0.0, - ]); - } - } - offsets -} diff --git a/src/scene/convert/solid3d_tess.rs b/src/scene/convert/solid3d_tess.rs index bf15eb69b..52172eeca 100644 --- a/src/scene/convert/solid3d_tess.rs +++ b/src/scene/convert/solid3d_tess.rs @@ -4,8 +4,8 @@ // • plane-surface faces → collect coedge-loop polygon, fan-triangulate. // • cone-surface faces → sample a parametric grid (handles both cylinders // and true cones). -// • sphere-surface faces → sample a full UV grid. -// • torus-surface faces → sample a full UV grid. +// • sphere-surface faces → sample a boundary-clipped UV grid. +// • torus-surface faces → sample a boundary-clipped UV grid. // // Coverage is tracked explicitly. Unsupported or malformed faces retain // feature/display wires, and partial shells are marked non-complete so solid @@ -552,16 +552,21 @@ fn torus_isolines(sat: &SatDocument, face: &SatFace, count: usize, out: &mut Vec let v = cross3(axis, u); let major = torus.major_radius(); let minor = torus.minor_radius(); - let (phi_min, phi_span, full) = torus_phi_range(sat, face, [cx, cy, cz], u, v); + let (phi_min, phi_span, full) = + torus_phi_range(sat, face, [cx, cy, cz], u, v, major); let phi_total = if full { TAU } else { phi_span }; + let (theta_min, theta_span, theta_full) = + torus_theta_range(sat, face, [cx, cy, cz], axis, major, minor); + let theta_total = if theta_full { TAU } else { theta_span }; - // Minor (cross-section) circles — constant revolution angle, full tube. + // Minor (cross-section) arcs — constant revolution angle, clipped to the + // face's tube-angle window. // Segment count from the tube (minor) radius at the shared edge tolerance. - let m = edge_arc_segs(minor, TAU); + let m = edge_arc_segs(minor, theta_total); for phi in iso_params(phi_min, phi_span, full, count) { for t in 0..m { - let t0 = TAU * (t as f64 / m as f64); - let t1 = TAU * ((t + 1) as f64 / m as f64); + let t0 = theta_min + theta_total * (t as f64 / m as f64); + let t1 = theta_min + theta_total * ((t + 1) as f64 / m as f64); out.push(torus_pt(cx, cy, cz, axis, u, v, major, minor, t0, phi)); out.push(torus_pt(cx, cy, cz, axis, u, v, major, minor, t1, phi)); } @@ -573,8 +578,7 @@ fn torus_isolines(sat: &SatDocument, face: &SatFace, count: usize, out: &mut Vec // cross-sections. `count.max(2)` guarantees outer + inner even at ISOLINES=1. let ring_segs = edge_arc_segs(major, phi_total).max(2); let n_ring = count.max(2); - for k in 0..n_ring { - let theta = TAU * (k as f64 / n_ring as f64); + for theta in iso_params(theta_min, theta_span, theta_full, n_ring) { for s in 0..ring_segs { let p0 = phi_min + phi_total * (s as f64 / ring_segs as f64); let p1 = phi_min + phi_total * ((s + 1) as f64 / ring_segs as f64); @@ -622,54 +626,24 @@ fn sphere_param_range( /// Revolution-angle arc a torus face spans, walking all its boundary loops. /// -/// A partial tube ends in two minor-circle caps sitting in constant-φ planes; -/// the arc between them is the opening. But the tube body can also carry -/// interior hole loops where another solid punches through it, so the widest -/// empty gap is *not* reliably the opening — a hole splits the body into wide -/// hole-free stretches that masquerade as it. Only a gap flanked by two end -/// caps is genuinely surface-free, so that is the opening; the body is the rest -/// of the turn. Returns `(body_start, body_span, full)`. +/// A partial tube ends in two minor-circle caps sitting in constant-φ planes. +/// Their adjacent planar faces carry outward normals that identify which cap is +/// the start and which is the end, so both short elbows and long open tubes use +/// the correct side of the analytic surface. If that topology is absent or +/// ambiguous, the face remains untrimmed instead of guessing between the two +/// possible arcs. Returns `(body_start, body_span, full)`. pub(crate) fn torus_phi_range( sat: &SatDocument, face: &SatFace, center: [f64; 3], u: [f64; 3], v: [f64; 3], + major: f64, ) -> (f64, f64, bool) { - // Below this revolution-angle spread a loop is a minor circle sitting in a - // constant-φ plane — a tube end cap. Interference loops (where another solid - // punches through the tube) wander several degrees in φ, well above it. - const CAP_SPREAD: f64 = 0.15; // rad (~8.6°) - // Ignore sub-degree gaps *within* a cap's own point cluster; a genuine tube - // opening is far wider. - const MIN_OPENING: f64 = 0.02; // rad (~1.1°) - - let phi_of = |p: [f64; 3]| -> f64 { - let rel = [p[0] - center[0], p[1] - center[1], p[2] - center[2]]; - dot3(rel, v).atan2(dot3(rel, u)).rem_euclid(TAU) - }; - // Angular spread of a set of φ values, wrap-aware: the turn minus the widest - // gap between them. A cap collapses to ~0; a hole loop keeps its real width. - let spread = |phis: &[f64]| -> f64 { - if phis.len() < 2 { - return 0.0; - } - let mut s = phis.to_vec(); - s.sort_by(|a, b| a.partial_cmp(b).unwrap()); - let mut gmax = 0.0f64; - for i in 0..s.len() { - let next = if i + 1 < s.len() { - s[i + 1] - } else { - s[0] + TAU - }; - gmax = gmax.max(next - s[i]); - } - TAU - gmax - }; - - // Every boundary point, tagged by whether its loop is a tube end cap. - let mut tagged: Vec<(f64, bool)> = Vec::new(); + const GEOM_EPS: f64 = 1e-6; + let axis = norm3(cross3(u, v)); + let mut starts = Vec::new(); + let mut ends = Vec::new(); let mut lp = face.first_loop(); let mut seen: HashSet = HashSet::default(); while !lp.is_null() && seen.insert(lp.0) { @@ -677,46 +651,215 @@ pub(crate) fn torus_phi_range( let Some(sl) = SatLoop::from_record(lr) else { break; }; - let poly = collect_loop_polygon(sat, &sl, BOUNDARY_CHORD_FRAC); lp = sl.next_loop(); - if poly.is_empty() { + let Some((cap_center, cap_normal)) = adjacent_planar_circle_cap(sat, &sl) else { + continue; + }; + let rel = [ + cap_center[0] - center[0], + cap_center[1] - center[1], + cap_center[2] - center[2], + ]; + let axial = dot3(rel, axis); + let radial_u = dot3(rel, u); + let radial_v = dot3(rel, v); + let radial_len = (radial_u * radial_u + radial_v * radial_v).sqrt(); + let scale = major.abs().max(radial_len).max(1.0); + if axial.abs() > scale * GEOM_EPS + || (radial_len - major.abs()).abs() > scale * GEOM_EPS + { continue; } - let phis: Vec = poly.iter().map(|&p| phi_of(p)).collect(); - let is_cap = spread(&phis) < CAP_SPREAD; - for phi in phis { - tagged.push((phi, is_cap)); - } - } - if tagged.len() < 2 { - return (0.0, TAU, true); - } - tagged.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap()); - - // The opening is the arc with no surface: a gap flanked on *both* sides by - // end-cap points (a hole never bounds the opening). Of those, the tube's own - // opening is the narrowest above the intra-cluster noise floor; the body is - // the rest of the turn. No cap-cap gap ⇒ the tube closes into a full ring. - let n = tagged.len(); - let mut opening_gap = f64::MAX; - let mut body_start = 0.0; - for i in 0..n { - let (phi_i, cap_i) = tagged[i]; - let (phi_j, cap_j) = if i + 1 < n { - tagged[i + 1] + let physical_phi = radial_v.atan2(radial_u); + let phi = if major < 0.0 { + (physical_phi - std::f64::consts::PI).rem_euclid(TAU) } else { - (tagged[0].0 + TAU, tagged[0].1) + physical_phi.rem_euclid(TAU) }; - let gap = phi_j - phi_i; - if cap_i && cap_j && gap > MIN_OPENING && gap < opening_gap { - opening_gap = gap; - body_start = phi_j.rem_euclid(TAU); // body resumes past the far cap + let major_sign = if major < 0.0 { -1.0 } else { 1.0 }; + let tangent = norm3([ + major_sign * (-u[0] * phi.sin() + v[0] * phi.cos()), + major_sign * (-u[1] * phi.sin() + v[1] * phi.cos()), + major_sign * (-u[2] * phi.sin() + v[2] * phi.cos()), + ]); + let alignment = dot3(tangent, cap_normal); + if (alignment.abs() - 1.0).abs() > GEOM_EPS { + continue; + } + if alignment < 0.0 { + starts.push(phi); + } else { + ends.push(phi); } } - if opening_gap == f64::MAX { + + starts.sort_by(|left, right| left.partial_cmp(right).unwrap()); + ends.sort_by(|left, right| left.partial_cmp(right).unwrap()); + starts.dedup_by(|left, right| (*left - *right).abs() < GEOM_EPS); + ends.dedup_by(|left, right| (*left - *right).abs() < GEOM_EPS); + if let ([start], [end]) = (starts.as_slice(), ends.as_slice()) { + let span = (end - start).rem_euclid(TAU); + if span > GEOM_EPS && span < TAU - GEOM_EPS { + return (*start, span, false); + } + } + (0.0, TAU, true) +} + +/// Analytic circle data carried by an ellipse edge in a boundary loop. +fn loop_circle_geometry( + sat: &SatDocument, + sat_loop: &SatLoop, +) -> Option<([f64; 3], [f64; 3], f64)> { + let first = sat_loop.first_coedge(); + let mut current = first; + let mut seen: HashSet = HashSet::default(); + while !current.is_null() && seen.insert(current.0) { + let coedge = SatCoedge::from_record(sat.resolve(current)?)?; + if let Some(ellipse) = sat + .resolve(coedge.edge()) + .and_then(SatEdge::from_record) + .and_then(|edge| sat.resolve(edge.curve())) + .and_then(SatEllipseCurve::from_record) + { + if (ellipse.ratio() - 1.0).abs() <= 1e-6 { + let (cx, cy, cz) = ellipse.center(); + let (nx, ny, nz) = ellipse.normal(); + let (mx, my, mz) = ellipse.major_axis(); + let radius = (mx * mx + my * my + mz * mz).sqrt(); + return Some(([cx, cy, cz], norm3([nx, ny, nz]), radius)); + } + } + current = coedge.next(); + if current == first { + break; + } + } + None +} + +/// Circle centre and outward normal of the planar cap sharing a boundary loop +/// with a curved face. Both values come from analytic SAT records. +fn adjacent_planar_circle_cap( + sat: &SatDocument, + sat_loop: &SatLoop, +) -> Option<([f64; 3], [f64; 3])> { + let (circle_center, _, _) = loop_circle_geometry(sat, sat_loop)?; + let first = sat_loop.first_coedge(); + let mut current = first; + let mut seen: HashSet = HashSet::default(); + while !current.is_null() && seen.insert(current.0) { + let coedge = SatCoedge::from_record(sat.resolve(current)?)?; + if let Some(partner) = sat.resolve(coedge.partner()).and_then(SatCoedge::from_record) { + if let Some(partner_loop) = sat + .resolve(partner.owner_loop()) + .and_then(SatLoop::from_record) + { + if let Some(adjacent_face) = sat + .resolve(partner_loop.face()) + .and_then(SatFace::from_record) + { + if let Some(plane) = sat + .resolve(adjacent_face.surface()) + .and_then(SatPlaneSurface::from_record) + { + let (nx, ny, nz) = plane.normal(); + let normal = if matches!(adjacent_face.sense(), Sense::Reversed) { + [-nx, -ny, -nz] + } else { + [nx, ny, nz] + }; + return Some((circle_center, norm3(normal))); + } + } + } + } + current = coedge.next(); + if current == first { + break; + } + } + None +} + +/// Tube-angle span of a torus face from its boundary loops. +/// +/// Ring tori normally cover the complete minor circle, while a spindle-torus +/// cap terminates at the surface's axial singularity and owns just one +/// constant-θ boundary ring. Sampling the complete minor circle in that case +/// duplicates the cap across the rest of the self-intersecting analytic +/// surface, producing a ball much larger than the bounded face. +fn torus_theta_range( + sat: &SatDocument, + face: &SatFace, + center: [f64; 3], + axis: [f64; 3], + major: f64, + minor: f64, +) -> (f64, f64, bool) { + const GEOM_EPS: f64 = 1e-6; + + if minor <= 1e-9 || major.abs() >= minor { return (0.0, TAU, true); } - (body_start, TAU - opening_gap, false) + + let mut rims = Vec::new(); + let mut lp = face.first_loop(); + let mut seen: HashSet = HashSet::default(); + while !lp.is_null() && seen.insert(lp.0) { + let Some(sat_loop) = sat.resolve(lp).and_then(SatLoop::from_record) else { + break; + }; + lp = sat_loop.next_loop(); + let Some((circle_center, circle_normal, circle_radius)) = + loop_circle_geometry(sat, &sat_loop) + else { + continue; + }; + let rel = [ + circle_center[0] - center[0], + circle_center[1] - center[1], + circle_center[2] - center[2], + ]; + let axial = dot3(rel, axis); + let planar = [ + rel[0] - axial * axis[0], + rel[1] - axial * axis[1], + rel[2] - axial * axis[2], + ]; + let scale = major.abs().max(minor).max(circle_radius).max(1.0); + if dot3(planar, planar).sqrt() > scale * GEOM_EPS + || (dot3(circle_normal, axis).abs() - 1.0).abs() > GEOM_EPS + { + continue; + } + + // The principal spindle branch has non-negative revolution radius: + // circle_radius = major + minor·cos(θ). Together with the circle + // plane's axial coordinate this recovers θ directly. + let sin_theta = axial / minor; + let cos_theta = (circle_radius - major) / minor; + if (sin_theta * sin_theta + cos_theta * cos_theta - 1.0).abs() > GEOM_EPS { + continue; + } + rims.push(sin_theta.atan2(cos_theta)); + } + + rims.sort_by(|left, right| left.partial_cmp(right).unwrap()); + rims.dedup_by(|left, right| (*left - *right).abs() < GEOM_EPS); + if rims.len() != 1 || rims[0].abs() < GEOM_EPS { + return (0.0, TAU, true); + } + + let rim = rims[0]; + let singular = (-major / minor).clamp(-1.0, 1.0).acos(); + if rim > 0.0 && rim < singular { + (rim, singular - rim, false) + } else if rim < 0.0 && rim > -singular { + (-singular, singular + rim, false) + } else { + (0.0, TAU, true) + } } /// Reduce a set of angles to a `(min, span, full)` arc. Mirrors `angular_range`'s @@ -868,7 +1011,22 @@ fn collect_curved_gens( torus.u_direction().2, ]); let v = cross3(axis, u); - let (pmin, pspan, full) = torus_phi_range(sat, &face, [cx, cy, cz], u, v); + let (pmin, pspan, full) = torus_phi_range( + sat, + &face, + [cx, cy, cz], + u, + v, + torus.major_radius(), + ); + let (tmin, tspan, tfull) = torus_theta_range( + sat, + &face, + [cx, cy, cz], + axis, + torus.major_radius(), + torus.minor_radius(), + ); let (center, center_low) = place([cx, cy, cz]); out.push(CurvedGen::Torus { center, @@ -881,6 +1039,9 @@ fn collect_curved_gens( phi_min: pmin as f32, phi_span: pspan as f32, full, + theta_min: tmin as f32, + theta_span: tspan as f32, + theta_full: tfull, }); } _ => {} @@ -2093,14 +2254,19 @@ pub(crate) fn tess_torus_face( let v_dir = cross3(axis, u_dir); let major_r = torus.major_radius(); let minor_r = torus.minor_radius(); - - // Tube cross-section is a closed minor circle → full-circle segment count at - // the tube (minor) radius. - let nu = lod.circle_segs(minor_r).max(3); // around the tube + let (theta_start, theta_arc, theta_full) = + torus_theta_range(sat, face, [cx, cy, cz], axis, major_r, minor_r); + let theta_total = if theta_full { TAU } else { theta_arc }; + let nu = if theta_full { + lod.circle_segs(minor_r).max(3) + } else { + lod.arc_segs(minor_r, theta_total).max(1) + }; // Mesh only the revolution arc the face covers. A partial tube (an open "C") // otherwise builds as a full closed ring where the solid is open. - let (phi_start, phi_arc, full) = torus_phi_range(sat, face, [cx, cy, cz], u_dir, v_dir); + let (phi_start, phi_arc, full) = + torus_phi_range(sat, face, [cx, cy, cz], u_dir, v_dir, major_r); let phi_total = if full { TAU } else { phi_arc }; // Along-length divisions from the ring (major) radius and the covered arc at // the LOD's chord tolerance — a short arc samples proportionally less. @@ -2111,8 +2277,8 @@ pub(crate) fn tess_torus_face( let phi1 = phi_start + phi_total * ((j + 1) as f64 / nv as f64); for i in 0..nu { - let theta0 = TAU * (i as f64 / nu as f64); - let theta1 = TAU * ((i + 1) as f64 / nu as f64); + let theta0 = theta_start + theta_total * (i as f64 / nu as f64); + let theta1 = theta_start + theta_total * ((i + 1) as f64 / nu as f64); let p = [ torus_pt( @@ -2162,14 +2328,20 @@ fn torus_pt( theta: f64, // tube angle phi: f64, // revolution angle ) -> [f64; 3] { - // Ring center at angle phi. + // Parametric radial direction at angle phi. Keep this independent from the + // signed major radius: a zero-major spindle is still well-defined, while + // normalizing `ring - center` would collapse it and would flip the tube + // frame whenever the stored major radius is negative. + let radial = [ + u_dir[0] * phi.cos() + v_dir[0] * phi.sin(), + u_dir[1] * phi.cos() + v_dir[1] * phi.sin(), + u_dir[2] * phi.cos() + v_dir[2] * phi.sin(), + ]; let ring = [ - cx + major_r * (u_dir[0] * phi.cos() + v_dir[0] * phi.sin()), - cy + major_r * (u_dir[1] * phi.cos() + v_dir[1] * phi.sin()), - cz + major_r * (u_dir[2] * phi.cos() + v_dir[2] * phi.sin()), + cx + major_r * radial[0], + cy + major_r * radial[1], + cz + major_r * radial[2], ]; - // Radial direction from torus axis to ring center. - let radial = norm3([ring[0] - cx, ring[1] - cy, ring[2] - cz]); // Point on tube. [ ring[0] + minor_r * (radial[0] * theta.cos() + axis[0] * theta.sin()), diff --git a/src/scene/convert/tess.rs b/src/scene/convert/tess.rs index 0ab9d86e2..8d104db0f 100644 --- a/src/scene/convert/tess.rs +++ b/src/scene/convert/tess.rs @@ -800,58 +800,53 @@ pub(crate) fn tessellate_entity( // re-apply the table's scale factor on top of already-baked geometry. // When the block exists we render it directly. Same pattern as // Dimension's `block_name`. - if let EntityType::Table(tab) = e { - if let Some(br_h) = tab.block_record_handle { - if let Some(br) = document.block_records.iter().find(|br| br.handle == br_h) { - if !br.entity_handles.is_empty() { - let mut wires: Vec = Vec::with_capacity(br.entity_handles.len()); - // The Table's own layer style — layer-0 inheritance target - // for baked sub-entities on layer "0" (#221). - let tab_l0_color = view::render::adapt_to_bg( - view::render::layer_render_style(document, &e.common().layer).color, - bg_color, + if let EntityType::Table(table) = e { + if let Some(record) = table.block_record_handle.and_then(|handle| { + document + .block_records + .iter() + .find(|record| record.handle == handle) + }) { + if !record.entity_handles.is_empty() { + let mut insert = + acadrust::entities::Insert::new( + record.name.clone(), + table.insertion_point, ); - let tab_l0_aci = document - .layers - .get(&e.common().layer) - .map(|l| match &l.color { - acadrust::types::Color::Index(i) => *i, - _ => 0, - }) - .unwrap_or(0); - // The *T block is laid out in block-local space (origin at - // the table's top-left corner); the Table entity carries the - // world placement in `insertion_point` + `horizontal_direction` - // (like an INSERT). Place each baked sub-entity there before - // tessellating — without it the whole table renders at the - // origin. Rotating about the local origin then translating - // gives world = insertion + R·local; tessellate_entity then - // handles the UTM-scale relative-to-eye split as usual. - let ins = tab.insertion_point; - let angle = tab.horizontal_direction.y.atan2(tab.horizontal_direction.x); - for &eh in &br.entity_handles { - let Some(sub) = document.get_entity(eh) else { - continue; - }; + insert.rotation = table + .horizontal_direction + .y + .atan2(table.horizontal_direction.x); + insert.common = table.common.clone(); + + let depths = rustc_hash::FxHashMap::default(); + let graph = crate::scene::render_graph::RenderSceneGraph::new( + document, + None, + annotation_scale_handle, + true, + &depths, + ); + let mut wires = Vec::new(); + graph.walk_insert( + &insert, + h, + |_, _| true, + |sub, context| { let has_book_color = view::render::has_resolved_book_color(document, sub); - let sub_color_is_byblock = !has_book_color - && sub.common().color == acadrust::types::Color::ByBlock; - let sub_is_l0_bylayer = - !has_book_color - && view::render::is_effective_layer_zero(&sub.common().layer) - && sub.common().color == acadrust::types::Color::ByLayer; + let color_byblock = !has_book_color + && sub.common().color + == acadrust::types::Color::ByBlock; + let color_layer0 = !has_book_color + && view::render::is_effective_layer_zero( + &sub.common().layer, + ) + && sub.common().color + == acadrust::types::Color::ByLayer; + let style = context.style_for(document, sub); let mut placed = sub.clone(); - { - let ent = placed.as_entity_mut(); - if angle.abs() > 1e-9 { - ent.apply_rotation( - acadrust::types::Vector3::new(0.0, 0.0, 1.0), - angle, - ); - } - ent.translate(ins); - } + placed.apply_transform(&context.transform); let sub_wires = tessellate_entity( document, selected, @@ -865,43 +860,39 @@ pub(crate) fn tessellate_entity( world_per_pixel, paper_space, ); - for mut w in sub_wires { - w.name = h.value().to_string(); - if sub_color_is_byblock { - w.color = if sel { + for mut wire in sub_wires { + wire.name = h.value().to_string(); + if color_byblock { + wire.color = if sel { WireModel::SELECTED } else { - entity_color + view::render::adapt_to_bg(style.0, bg_color) }; - w.aci = aci; - } else if sub_is_l0_bylayer && !sel { - w.color = tab_l0_color; - w.aci = tab_l0_aci; + wire.aci = style.4; + } else if color_layer0 && !sel { + wire.color = + view::render::adapt_to_bg(style.0, bg_color); + wire.aci = style.4; } - wires.push(w); + wires.push(wire); + } + }, + ); + if !wires.is_empty() { + let aabb = entity_aabb(e); + for wire in &mut wires { + if !wire.points.is_empty() || !wire.fill_tris.is_empty() { + set_wire_aabb(wire, aabb); } } - if !wires.is_empty() { - let aabb = entity_aabb(e); - for w in &mut wires { - // Empty SDF-text cells keep their tight glyph-box - // AABB; only stroke/fill wires take the whole-block - // box as a broad-phase pick hint. - if !w.points.is_empty() || !w.fill_tris.is_empty() { - set_wire_aabb(w, aabb); - } - } - return wires; - } + return wires; } } } - // No baked block (e.g. a table created in-app) — synthesise coloured - // geometry from the rows + TableStyle so fills/colours/borders/margins - // are honoured instead of the monochrome fallback. + let table_anno = 1.0; let mut wires = crate::entities::table::tessellate_table( - tab, + table, document, sel, entity_color, @@ -909,7 +900,7 @@ pub(crate) fn tessellate_entity( table_anno, ); for insert in - crate::entities::table::block_cell_inserts(tab, document, table_anno) + crate::entities::table::block_cell_inserts(table, document, table_anno) { wires.extend(tessellate_entity( document, @@ -927,13 +918,10 @@ pub(crate) fn tessellate_entity( } if !wires.is_empty() { let aabb = entity_aabb(e); - for w in &mut wires { - w.aci = aci; - // Empty SDF-text cells keep their tight glyph-box AABB; only - // stroke/fill wires take the whole-table box as a broad-phase - // pick hint (matches the dim / mleader / baked-block paths). - if !w.points.is_empty() || !w.fill_tris.is_empty() { - set_wire_aabb(w, aabb); + for wire in &mut wires { + wire.aci = aci; + if !wire.points.is_empty() || !wire.fill_tris.is_empty() { + set_wire_aabb(wire, aabb); } } return wires; @@ -986,145 +974,63 @@ pub(crate) fn tessellate_entity( fill_tris_low: Vec::new(), }; - if let Some(cache) = block_cache { - // Xrefs render with the same hue but faded toward `bg_color` so - // the user can recognise external-reference geometry at a glance. - let is_xref = document - .block_records - .get(&ins.block_name) - .map(|br| br.flags.is_xref || br.flags.is_xref_overlay) - .unwrap_or(false); - if let Some(mut wires) = cache::block_cache::expand_insert( - cache, - ins, - h, - ins_color, - ins_pat_len, - ins_pat, - ins_lw_px, - ins_layer, - sel, - pslt_factor, - view_aabb, - world_per_pixel, - is_xref, - bg_color, - anno_scale, - ) { - // XCLIP: if this INSERT carries an enabled spatial filter, - // clip the expanded block geometry to the boundary polygon so - // only the portion inside the clip is drawn. - if let Some(sf) = pick::xclip::insert_spatial_filter(document, ins) { - let poly = pick::xclip::world_clip_polygon_f64(sf, ins); - pick::xclip::clip_wires(&mut wires, &poly); - // XCLIPFRAME 1/2 shows the boundary itself on screen - // (2 = shown but not plotted). - if document.header.xclip_frame != 0 && poly.len() >= 3 { - wires.push(pick::xclip::frame_wire( - &poly, - format!("{}_xclipframe", h.value()), - ins_color, - sel, - ins_lw_px, - )); - } - } - - // Per-INSERT attribute values. The block defn carries the - // AttributeDefinitions (templates) which expand_insert skips; - // the AttributeEntity instances live on the Insert itself in - // WCS and need their own tessellation so the user sees the - // values they actually filled in. See #20. - crate::entities::insert::append_insert_attribute_wires( - &mut wires, + let fallback_depths = rustc_hash::FxHashMap::default(); + let fallback_cache; + let cache = match block_cache { + Some(cache) if cache.defn(&ins.block_name).is_some() => cache, + _ => { + fallback_cache = cache::block_cache::BlockCache::build_for_block( document, - ins, - h, - sel, - ins_color, - ins_pat_len, - ins_pat, - ins_lw_px, - ins_layer, - bg_color, - is_xref, - pslt_factor, + &ins.block_name, anno_scale, + annotation_scale_handle, + true, + bg_color, + &fallback_depths, ); - wires.push(marker); - return wires; + &fallback_cache + } + }; + let is_xref = document + .block_records + .get(&ins.block_name) + .map(|record| record.flags.is_xref || record.flags.is_xref_overlay) + .unwrap_or(false); + let mut wires = cache::block_cache::expand_insert( + cache, + ins, + h, + ins_color, + ins_pat_len, + ins_pat, + ins_lw_px, + ins_layer, + sel, + pslt_factor, + view_aabb, + world_per_pixel, + is_xref, + bg_color, + anno_scale, + ) + .unwrap_or_default(); + + if let Some(filter) = pick::xclip::insert_spatial_filter(document, ins) { + let transform = crate::scene::render_graph::insert_transform(document, ins); + let polygon = + pick::xclip::world_clip_polygon_for_transform(filter, &transform); + pick::xclip::clip_wires(&mut wires, &polygon); + if document.header.xclip_frame != 0 && polygon.len() >= 3 { + wires.push(pick::xclip::frame_wire( + &polygon, + format!("{}_xclipframe", h.value()), + ins_color, + sel, + ins_lw_px, + )); } } - // Cache miss / unavailable: fall back to the original explode path. - // The block_cache primary path covers all typical Inserts; this - // branch only fires for pathological cache failures. - let br = document.block_records.get(&ins.block_name); - let is_xref = br - .map(|br| br.flags.is_xref || br.flags.is_xref_overlay) - .unwrap_or(false); - let mut wires: Vec = ins - .explode_from_document(document) - .iter() - .cloned() - .map(crate::modules::draw::modify::explode::normalize_insert_entity) - .flat_map(|sub| { - let (sub_color, sub_pattern_length, sub_pattern, sub_line_weight_px, sub_aci) = - view::render::render_style_for_block_sub( - document, - &sub, - ins_color, - ins_pat_len, - ins_pat, - ins_lw_px, - ins_layer, - ); - let sub_color = view::render::adapt_to_bg(sub_color, bg_color); - let sub_color = if is_xref && !sel { - cache::block_cache::fade_toward_bg(sub_color, bg_color) - } else { - sub_color - }; - let sub_aabb = entity_aabb(&sub); - let sub_pattern_length = sub_pattern_length * pslt_factor; - let sub_pattern = sub_pattern.map(|v| v * pslt_factor); - let mut wires = convert::tessellate::tessellate( - document, - h, - &sub, - sel, - sub_color, - sub_pattern_length, - sub_pattern, - sub_line_weight_px, - anno_scale, - annotation_scale_handle, - world_per_pixel, - bg_color, - false, - ); - for w in &mut wires { - w.name = h.value().to_string(); - w.aci = sub_aci; - // Keep the glyph-bounds AABB tessellate set on SDF text wires - // (their geometry is in `text_verts`, not `points`); clobbering - // it here left block text with an UNBOUNDED box. For every - // other wire use the sub-entity box, falling back to the wire's - // own world points when that box is degenerate/unimplemented - // (UNBOUNDED) — otherwise it never culls and stalls snapping on - // block-heavy drawings. - if w.text_verts.is_empty() { - let box_ = if sub_aabb == WireModel::UNBOUNDED_AABB { - wire_points_aabb(w) - } else { - sub_aabb - }; - set_wire_aabb(w, box_); - } - } - wires - }) - .collect(); crate::entities::insert::append_insert_attribute_wires( &mut wires, document, diff --git a/src/scene/convert/tessellate.rs b/src/scene/convert/tessellate.rs index 59203c886..ca198b3eb 100644 --- a/src/scene/convert/tessellate.rs +++ b/src/scene/convert/tessellate.rs @@ -1547,6 +1547,15 @@ pub(crate) fn arrow_from_block( doc: &CadDocument, handle: acadrust::types::Handle, dimasz: f32, +) -> ArrowKind { + arrow_from_block_with_deferred_hatch(doc, handle, dimasz, false) +} + +pub(crate) fn arrow_from_block_with_deferred_hatch( + doc: &CadDocument, + handle: acadrust::types::Handle, + dimasz: f32, + defer_hatch: bool, ) -> ArrowKind { if handle.is_null() { return arrow_from_block_name(None, dimasz); @@ -1557,7 +1566,7 @@ pub(crate) fn arrow_from_block( if let Some(arrow) = builtin_arrow_from_block_name(&record.name, dimasz) { return arrow; } - custom_arrow_from_block(doc, record, dimasz) + custom_arrow_from_block(doc, record, dimasz, defer_hatch) .unwrap_or_else(|| arrow_from_block_name(None, dimasz)) } @@ -1653,6 +1662,7 @@ fn custom_arrow_from_block( doc: &CadDocument, record: &acadrust::tables::BlockRecord, dimasz: f32, + defer_hatch: bool, ) -> Option { if record.is_layout() || record.is_model_space() @@ -1663,17 +1673,42 @@ fn custom_arrow_from_block( { return None; } - let base = block_base_point(doc, record); + + let depths = rustc_hash::FxHashMap::default(); + let graph = crate::scene::render_graph::RenderSceneGraph::new( + doc, + None, + None, + true, + &depths, + ); + let insert = acadrust::entities::Insert::new( + record.name.clone(), + acadrust::types::Vector3::ZERO, + ); let mut lines = Vec::new(); let mut fill = Vec::new(); - let mut stack = vec![record.name.clone()]; - for &entity_handle in &record.entity_handles { - let Some(entity) = doc.get_entity(entity_handle) else { - continue; - }; - collect_custom_arrow_entity(doc, entity, base, &mut lines, &mut fill, &mut stack, 0); - } - if lines.is_empty() && fill.is_empty() { + let mut deferred_hatch = false; + graph.walk_insert( + &insert, + record.handle, + |_, _| true, + |entity, context| { + if defer_hatch && matches!(entity, EntityType::Hatch(_)) { + deferred_hatch = true; + return; + } + let mut placed = entity.clone(); + placed.apply_transform(&context.transform); + append_custom_arrow_leaf( + doc, + &placed, + &mut lines, + &mut fill, + ); + }, + ); + if lines.is_empty() && fill.is_empty() && !deferred_hatch { None } else { Some(ArrowKind::Custom { @@ -1684,67 +1719,27 @@ fn custom_arrow_from_block( } } -fn block_base_point( - doc: &CadDocument, - record: &acadrust::tables::BlockRecord, -) -> acadrust::types::Vector3 { - doc.get_entity(record.block_entity_handle) - .and_then(|entity| match entity { - EntityType::Block(block) => Some(block.base_point), - _ => None, - }) - .unwrap_or(acadrust::types::Vector3::ZERO) -} - -fn collect_custom_arrow_entity( +fn append_custom_arrow_leaf( doc: &CadDocument, entity: &EntityType, - root_base: acadrust::types::Vector3, lines: &mut Vec<[f32; 3]>, fill: &mut Vec<[f32; 3]>, - stack: &mut Vec, - depth: usize, ) { - if depth >= 32 || entity.common().invisible { - return; - } match entity { EntityType::Block(_) | EntityType::BlockEnd(_) | EntityType::AttributeDefinition(_) | EntityType::Dimension(_) | EntityType::Leader(_) - | EntityType::MultiLeader(_) => return, - EntityType::Insert(insert) => { - if stack.iter().any(|name| name.eq_ignore_ascii_case(&insert.block_name)) { - return; - } - let Some(record) = doc.block_records.get(&insert.block_name) else { - return; - }; - let nested_base = block_base_point(doc, record); - let transform = insert.get_transform(); - let transformed_zero = transform.apply(acadrust::types::Vector3::ZERO); - let transformed_base = transform.apply(nested_base); - let correction = transformed_zero - transformed_base; - stack.push(insert.block_name.clone()); - for mut child in insert.explode_from_document(doc) { - child.as_entity_mut().translate(correction); - collect_custom_arrow_entity( - doc, - &child, - root_base, - lines, - fill, - stack, - depth + 1, - ); - } - stack.pop(); - return; - } + | EntityType::MultiLeader(_) + | EntityType::Insert(_) => return, EntityType::Hatch(hatch) => { - append_custom_hatch_fill(hatch, root_base, fill); + append_custom_hatch_geometry( + hatch, + acadrust::types::Vector3::ZERO, + lines, + fill, + ); return; } _ => {} @@ -1766,15 +1761,26 @@ fn collect_custom_arrow_entity( true, ); for wire in wires { - append_custom_wire_points(&wire.points, &wire.points_low, root_base, lines); - append_custom_fill_points(&wire.fill_tris, &wire.fill_tris_low, root_base, fill); + append_custom_wire_points( + &wire.points, + &wire.points_low, + acadrust::types::Vector3::ZERO, + lines, + ); + append_custom_fill_points( + &wire.fill_tris, + &wire.fill_tris_low, + acadrust::types::Vector3::ZERO, + fill, + ); } } -fn append_custom_hatch_fill( +fn append_custom_hatch_geometry( hatch: &acadrust::entities::Hatch, base: acadrust::types::Vector3, - out: &mut Vec<[f32; 3]>, + lines: &mut Vec<[f32; 3]>, + fill: &mut Vec<[f32; 3]>, ) { use lyon_tessellation::math::point; use lyon_tessellation::path::Path; @@ -1786,6 +1792,29 @@ fn append_custom_hatch_fill( return; }; + if matches!( + &model.pattern, + crate::scene::model::hatch_model::HatchPattern::Pattern(_) + ) { + let z = -base.z as f32; + for [start, end] in model.pattern_segments() { + if !lines.is_empty() && !lines.last().is_some_and(|point| point[0].is_nan()) { + lines.push([f32::NAN; 3]); + } + lines.push([ + (start[0] - base.x) as f32, + (start[1] - base.y) as f32, + z, + ]); + lines.push([ + (end[0] - base.x) as f32, + (end[1] - base.y) as f32, + z, + ]); + } + return; + } + let mut builder = Path::builder(); let mut ring: Vec<[f32; 2]> = Vec::new(); let mut finish_ring = |ring: &mut Vec<[f32; 2]>| { @@ -1825,7 +1854,7 @@ fn append_custom_hatch_fill( return; } let z = -base.z as f32; - out.extend( + fill.extend( geometry .indices .iter() diff --git a/src/scene/entity.rs b/src/scene/entity.rs index ee660c531..ddb837489 100644 --- a/src/scene/entity.rs +++ b/src/scene/entity.rs @@ -853,8 +853,7 @@ impl Scene { } // 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. + // block) — the scene graph emits only their laid-out copies. self.belongs_to_visible_block(handle, c.owner_handle, target_block) }) .flat_map(|(&handle, model)| { @@ -976,9 +975,8 @@ impl Scene { } else { self.bg_color }; - // Block-internal hatch fills: descend INSERTs owned by the requested - // target only and materialize their fills at world position. - models.extend(self.exploded_insert_hatch_models( + // Instanced/owned hatch leaves are produced by the shared scene graph. + models.extend(self.instanced_hatch_models( target_block, hatch_bg, true, @@ -987,31 +985,23 @@ impl Scene { all_visible, )); - // 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. + // Wide polyline bands remain on the wire path, including inside block + // instances; the graph does not reclassify them as hatch fills. 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. + /// Materialize hatch leaves reached through visible scene containers in + /// `layout_block`, with transforms and inherited styles already resolved. /// /// Shared by the on-screen hatch set (`synced_hatch_models`) and the /// paper/export hatch set (`paper_canvas_hatches`) so a plot draws - /// block-internal hatches identically to the viewport. Without this the - /// export path only saw top-level hatches, so any fill nested in a block - /// INSERT printed as bare monochrome outlines. + /// block-internal hatches identically to the viewport. /// /// `hatch_bg` adapts pure black/white leaf colours to the target /// background; `tint_selected` re-colours fills of a selected INSERT /// (screen highlight) and should be `false` for export. - pub(super) fn exploded_insert_hatch_models( + pub(super) fn instanced_hatch_models( &self, layout_block: Handle, hatch_bg: [f32; 4], @@ -1020,7 +1010,7 @@ impl Scene { annotation_scale_handle: Option, all_visible: bool, ) -> Vec { - self.exploded_insert_hatch_models_filtered( + self.instanced_hatch_models_filtered( layout_block, hatch_bg, tint_selected, @@ -1058,7 +1048,7 @@ impl Scene { .flatten() .copied() .collect(); - self.exploded_insert_hatch_models_filtered( + self.instanced_hatch_models_filtered( self.interaction_block_handle(), hatch_bg, true, @@ -1070,7 +1060,7 @@ impl Scene { ) } - fn exploded_insert_hatch_models_filtered( + fn instanced_hatch_models_filtered( &self, layout_block: Handle, hatch_bg: [f32; 4], @@ -1081,400 +1071,105 @@ impl Scene { targets: Option<&rustc_hash::FxHashSet>, include_preview_hidden: bool, ) -> Vec { - 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 = 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 = - 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 = - 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 { - 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 graph = crate::scene::render_graph::RenderSceneGraph::new( + &self.document, + frozen, + annotation_scale_handle, + all_visible, + depth_map.as_ref(), + ); + let mut hatch_block_memo = std::collections::HashMap::new(); + let mut models = Vec::new(); + graph.walk_root( + self.render_scene_root(layout_block), + |entity, context| { + if context.is_instanced() { + return true; } - 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); + let common = entity.common(); + if self.object_isolation.hides(common.handle) + || (!include_preview_hidden + && self.preview_hidden.contains(&common.handle)) + { + return false; } - } - out - } - for entity in self.document.entities() { - let contextual = crate::scene::annotative::entity_for_annotation_context( - &self.document, - entity, - annotation_scale_handle, - ); - let EntityType::Insert(ins) = contextual.as_ref() else { - continue; - }; - if targets.is_some_and(|targets| !targets.contains(&ins.common.handle)) { - continue; - } - let temporarily_hidden = self.object_isolation.hides(ins.common.handle) - || (!include_preview_hidden && self.preview_hidden.contains(&ins.common.handle)); - if ins.common.invisible - || temporarily_hidden - || 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 crate::scene::annotative::annotative_offscale_for( - &self.document, - &ins.common, - annotation_scale_handle, - all_visible, - ) { - 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 style inheritance sources (#221), kept raw and - // adapted at the leaf. The INSERT style feeds ByBlock; `l0` (the - // INSERT's layer style) feeds the layer-0 rule. Colour, ACI, - // linetype, and lineweight all chain through nested inserts. - let ins_style = - crate::scene::view::render::render_style_for(&self.document, entity); - let l0 = crate::scene::view::render::layer_render_style( - &self.document, - &ins.common.layer, - ); - let layer_aci = |layer: &str| -> u8 { - self.document - .layers - .get(layer) - .and_then(|layer| match &layer.color { - acadrust::types::Color::Index(index) => Some(*index), - _ => None, - }) - .unwrap_or(0) - }; - let l0_aci = layer_aci(&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 { - matches!(e, EntityType::Insert(ni) - if crate::scene::annotative::annotative_offscale_for( - &self.document, - &ni.common, - annotation_scale_handle, - all_visible, - )) - }; - type ResolvedStyle = ([f32; 4], f32, [f32; 8], f32, u8); - let mut stack: Vec<( - EntityType, - usize, - ResolvedStyle, - crate::scene::view::render::InheritStyle, - u8, - (f32, f32), - )> = explode_including_dims(ins, &self.document) - .into_iter() - .filter(|e| !offscale(e)) - .map(|e| { - crate::scene::annotative::entity_for_annotation_context( - &self.document, - &e, - annotation_scale_handle, - ) - .into_owned() - }) - .map(|e| { - ( - normalize(e), - 0usize, - ins_style, - l0, - l0_aci, - (base_depth, half_gap), - ) - }) - .collect(); - while let Some(( - sub, - depth, - sub_ins_style, - sub_l0, - sub_l0_aci, - (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 mut child_ins_style = - crate::scene::view::render::render_style_for_block_sub( - &self.document, - &nested_entity, - sub_ins_style.0, - sub_ins_style.1, - sub_ins_style.2, - sub_ins_style.3, - sub_l0, - ); - child_ins_style.4 = if !has_book_color - && nins.common.color == Color::ByBlock - { - sub_ins_style.4 - } else if !has_book_color - && on_l0 - && nins.common.color == Color::ByLayer - { - sub_l0_aci - } else { - child_ins_style.4 - }; - let child_l0 = if on_l0 { - sub_l0 - } else { - crate::scene::view::render::layer_render_style( - &self.document, - &nins.common.layer, - ) - }; - let child_l0_aci = if on_l0 { - sub_l0_aci - } else { - layer_aci(&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_annotation_context( - &self.document, - &e, - annotation_scale_handle, - ) - .into_owned(); - stack.push(( - normalize(e), - depth + 1, - child_ins_style, - child_l0, - child_l0_aci, - (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, including the pattern-stroke lineweight and - // effective ACI used by plot style tables. - let hatch_entity = EntityType::Hatch(dxf.clone()); - let mut style = crate::scene::view::render::render_style_for_block_sub( + if let Some(targets) = targets { + return matches!(entity, EntityType::Insert(_)) + && targets.contains(&common.handle); + } + match entity { + EntityType::Insert(insert) => { + crate::scene::render_graph::block_contains_hatch( &self.document, - &hatch_entity, - sub_ins_style.0, - sub_ins_style.1, - sub_ins_style.2, - sub_ins_style.3, - sub_l0, - ); - let has_book_color = - crate::scene::view::render::has_resolved_book_color( - &self.document, - &hatch_entity, - ); - let on_l0 = crate::scene::view::render::is_effective_layer_zero( - &dxf.common.layer, - ); - style.4 = if !has_book_color - && dxf.common.color == acadrust::types::Color::ByBlock - { - sub_ins_style.4 - } else if !has_book_color - && on_l0 - && dxf.common.color == acadrust::types::Color::ByLayer - { - sub_l0_aci - } else { - style.4 - }; - let color = style.0; - // Explicit ACI 7 solid fills inside blocks are often - // white masks. Keep the explicit white on a light - // layout; adapting it to paper turns the mask into a - // black rectangle unlike the viewport (#618). - let preserve_white_mask = dxf.is_solid - && matches!( - dxf.common.color, - acadrust::types::Color::Index(7) - ); - let color = if preserve_white_mask { - color - } else { - crate::scene::view::render::adapt_to_bg(color, hatch_bg) - }; - if let Some(mut model) = - Self::hatch_model_from_dxf(&dxf, color) - { - model.aci = style.4; - model.line_weight_px = style.3; - // 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); - } + &insert.block_name, + &mut hatch_block_memo, + ) } - // 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) - _ => {} + EntityType::Dimension(dimension) => { + let name = dimension.base().block_name.trim(); + !name.is_empty() + && crate::scene::render_graph::block_contains_hatch( + &self.document, + name, + &mut hatch_block_memo, + ) + } + _ => true, } - } - } + }, + |entity, context| { + if !context.is_instanced() { + return; + } + let EntityType::Hatch(source_hatch) = entity else { + return; + }; + let style = context.style_for(&self.document, entity); + let preserve_white_mask = source_hatch.is_solid + && matches!( + source_hatch.common.color, + acadrust::types::Color::Index(7) + ); + let color = if preserve_white_mask { + style.0 + } else { + crate::scene::view::render::adapt_to_bg(style.0, hatch_bg) + }; + + let mut placed = EntityType::Hatch(source_hatch.clone()); + placed.apply_transform(&context.transform); + let EntityType::Hatch(hatch) = placed else { + return; + }; + let Some(mut model) = Self::hatch_model_from_dxf(&hatch, color) else { + return; + }; + model.aci = style.4; + model.line_weight_px = style.3; + model.draw_depth = + context.draw_depth(source_hatch.common.handle, depth_map.as_ref()); + for clip in &context.clips { + let clip: Vec<[f32; 2]> = clip + .iter() + .map(|point| [point[0] as f32, point[1] as f32]) + .collect(); + let clipped = pick::xclip::clip_hatch_boundary( + &model.boundary, + model.world_origin, + &clip, + ); + if clipped.is_empty() { + return; + } + model.boundary = std::sync::Arc::new(clipped); + } + if tint_selected && self.selected.contains(&context.root_handle) { + model.color = [0.15, 0.55, 1.00, model.color[3]]; + } + models.push(model); + }, + ); models } @@ -1487,245 +1182,107 @@ impl Scene { annotation_scale_handle: Option, all_visible: bool, ) -> Vec { - let is_paper = self.current_layout != "Model"; - let bg_color: [f32; 4] = if is_paper { + let bg_color = if self.current_layout != "Model" { 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. + self.wipeout_models_for_block_graph( + target_block, + frozen, + annotation_scale_handle, + all_visible, + bg_color, + false, + ) + } + + pub(super) fn wipeout_models_for_block_graph( + &self, + target_block: Handle, + frozen: Option<&rustc_hash::FxHashSet>, + annotation_scale_handle: Option, + all_visible: bool, + bg_color: [f32; 4], + tint_insert_selection: bool, + ) -> Vec { let depth_map = self.draw_depth_map(); + let graph = crate::scene::render_graph::RenderSceneGraph::new( + &self.document, + frozen, + annotation_scale_handle, + all_visible, + depth_map.as_ref(), + ); let mut models = Vec::new(); - for entity in self.document.entities() { - let EntityType::Wipeout(wo) = entity else { - continue; - }; - if entity.common().invisible - || self.entity_temporarily_hidden(wo.common.handle) - { - 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, - target_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]; + graph.walk_root( + self.render_scene_root(target_block), + |entity, context| { + context.is_instanced() + || !self.entity_temporarily_hidden(entity.common().handle) + }, + |entity, context| { + let EntityType::Wipeout(source) = entity else { + return; + }; + let mut wipeout = source.clone(); + if context.is_instanced() { + wipeout.insertion_point = + context.transform.apply(source.insertion_point); + wipeout.u_vector = + context.transform.apply_rotation(source.u_vector); + wipeout.v_vector = + context.transform.apply_rotation(source.v_vector); } + let (world_origin, mut boundary) = + Self::wipeout_boundary_2d(&wipeout); + for clip in &context.clips { + let clip: Vec<[f32; 2]> = clip + .iter() + .map(|point| [point[0] as f32, point[1] as f32]) + .collect(); + boundary = pick::xclip::clip_hatch_boundary( + &boundary, + world_origin, + &clip, + ); + } + if boundary.len() < 3 { + return; + } + let selection_handle = + if tint_insert_selection && context.is_instanced() { + context.root_handle + } else { + source.common.handle + }; + let color = if self.selected.contains(&selection_handle) { + [0.15, 0.55, 1.00, 0.35] + } else { + bg_color + }; models.push(HatchModel { boundary: Arc::new(boundary), boundary_wcs: None, pattern: model::hatch_model::HatchPattern::Solid, name: "WIPEOUT_FILL".into(), - color: fill_color, + color, aci: 0, line_weight_px: 1.0, 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]), + world_origin, + draw_depth: context + .draw_depth(source.common.handle, depth_map.as_ref()), }); - } - } - // 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_annotation_context( - &self.document, - entity, - annotation_scale_handle, - ); - let EntityType::Insert(ins) = contextual.as_ref() else { - continue; - }; - let c = &ins.common; - if c.invisible - || self.entity_temporarily_hidden(c.handle) - || 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, target_block) { - continue; - } - if crate::scene::annotative::annotative_offscale_for( - &self.document, - c, - annotation_scale_handle, - all_visible, - ) { - continue; - } - self.collect_block_wipeouts( - &ins.get_transform(), - &ins.block_name, - 0, - frozen, - bg_color, - &depth_map, - &mut models, - annotation_scale_handle, - all_visible, - ); - } + }, + ); 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)] - pub(super) fn collect_block_wipeouts( - &self, - xform: &acadrust::types::Transform, - block_name: &str, - depth: usize, - frozen: Option<&rustc_hash::FxHashSet>, - bg_color: [f32; 4], - depth_map: &HashMap, - models: &mut Vec, - annotation_scale_handle: Option, - all_visible: bool, - ) { - 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(source) = self.document.get_entity(eh) else { - continue; - }; - let contextual = crate::scene::annotative::entity_for_annotation_context( - &self.document, - source, - annotation_scale_handle, - ); - let e = contextual.as_ref(); - 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) - || crate::scene::annotative::annotative_offscale_for( - &self.document, - c, - annotation_scale_handle, - all_visible, - ) - { - 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, - aci: 0, - line_weight_px: 1.0, - 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, - annotation_scale_handle, - all_visible, - ); - } - _ => {} - } - } - } - /// 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. + /// Wipeout fill boundary as small f32 offsets from the returned world_origin + /// (the insertion point, kept in f64). pub(super) fn wipeout_boundary_2d( wo: &acadrust::entities::Wipeout, ) -> ([f64; 2], Vec<[f32; 2]>) { diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 2643b2326..dfc5cbb67 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -12,6 +12,7 @@ pub mod creation_style; pub mod model; pub mod pick; pub mod pipeline; +pub(crate) mod render_graph; pub mod text; pub mod view; @@ -1699,11 +1700,9 @@ pub struct Scene { RefCell>)>>, frozen_image_cache: RefCell>)>>, frozen_mesh_cache: RefCell>)>>, - /// Cached block-INSERT hatches for hit-testing, keyed by geometry_epoch. - /// Building this explodes every model-space INSERT, so without the cache a - /// heavy block-instanced drawing re-explodes thousands of inserts on every - /// hover. The set is geometry-derived, so a camera move / hover never - /// invalidates it. + /// Cached block-instance hatches for hit-testing, keyed by geometry_epoch. + /// The set is geometry-derived, so a camera move or hover never invalidates + /// the shared graph result. insert_hatch_cache: RefCell>>)>>, /// Sheet "dressing" cache over the unified resident set: the paper sheet /// drops its own border wire and appends the printable-area guide. @@ -5772,6 +5771,7 @@ impl Scene { None, scale, self.annotation_all_visible(), + None, )); self.image_cache .borrow_mut() @@ -5787,164 +5787,82 @@ impl Scene { frozen: Option<&HashSet>, annotation_scale_handle: Option, all_visible: bool, + viewport: Option, ) -> Vec { let depth_map = self.draw_depth_map(); - let mut models: Vec = self.images - .iter() - .filter_map(|(handle, model)| { - let entity = self.document.get_entity(*handle)?; - let common = entity.common(); - if common.invisible - || self.entity_temporarily_hidden(*handle) - || self.layer_hidden(&common.layer) - || self.layer_frozen_in(&common.layer, frozen) - || !self.belongs_to_visible_block( - *handle, - common.owner_handle, - target_block, - ) - { - return None; - } - let mut m = model.clone(); - m.draw_depth = depth_map.get(&handle.value()).map_or(0.0, |d| d[0]); - Some(m) + let graph = render_graph::RenderSceneGraph::new( + &self.document, + frozen, + annotation_scale_handle, + all_visible, + depth_map.as_ref(), + ); + let mut models = Vec::new(); + let root = if self.block_edit_block.is_none() { + viewport.map(|viewport| render_graph::SceneRoot::Viewport { + paper_block: self.current_layout_block_handle(), + viewport, + model_block: target_block, }) - .collect(); - for source in self.document.entities() { - let contextual = crate::scene::annotative::entity_for_annotation_context( - &self.document, - source, - annotation_scale_handle, - ); - let EntityType::Insert(insert) = contextual.as_ref() else { - continue; - }; - let common = &insert.common; - if common.invisible - || self.entity_temporarily_hidden(common.handle) - || self.layer_hidden(&common.layer) - || self.layer_frozen_in(&common.layer, frozen) - || crate::scene::annotative::annotative_offscale_for( - &self.document, - common, - annotation_scale_handle, - all_visible, - ) - || !self.belongs_to_visible_block( - common.handle, - common.owner_handle, - target_block, - ) - { - continue; - } - self.collect_block_images( - &insert.block_name, - &insert.get_transform(), - 0, - frozen, - annotation_scale_handle, - all_visible, - &depth_map, - &mut models, - ); + } else { + None } + .unwrap_or_else(|| self.render_scene_root(target_block)); + graph.walk_root( + root, + |entity, context| { + context.is_instanced() + || !self.entity_temporarily_hidden(entity.common().handle) + }, + |entity, context| { + let handle = entity.common().handle; + let Some(model) = self.images.get(&handle) else { + return; + }; + let mut placed = model.clone(); + if context.is_instanced() { + for (corner, low) in + placed.corners.iter_mut().zip(&mut placed.corners_low) + { + let point = acadrust::types::Vector3::new( + corner[0] as f64 + low[0] as f64, + corner[1] as f64 + low[1] as f64, + corner[2] as f64 + low[2] as f64, + ); + let point = context.transform.apply(point); + *corner = [point.x as f32, point.y as f32, point.z as f32]; + *low = [ + (point.x - corner[0] as f64) as f32, + (point.y - corner[1] as f64) as f32, + (point.z - corner[2] as f64) as f32, + ]; + } + for vertex in &mut placed.verts { + let point = acadrust::types::Vector3::new( + vertex.pos[0] as f64 + vertex.pos_low[0] as f64, + vertex.pos[1] as f64 + vertex.pos_low[1] as f64, + vertex.pos[2] as f64 + vertex.pos_low[2] as f64, + ); + let point = context.transform.apply(point); + vertex.pos = [ + point.x as f32, + point.y as f32, + point.z as f32, + ]; + vertex.pos_low = [ + (point.x - vertex.pos[0] as f64) as f32, + (point.y - vertex.pos[1] as f64) as f32, + (point.z - vertex.pos[2] as f64) as f32, + ]; + } + } + placed.draw_depth = context.draw_depth(handle, depth_map.as_ref()); + models.push(placed); + }, + ); models } - #[allow(clippy::too_many_arguments)] - fn collect_block_images( - &self, - block_name: &str, - transform: &acadrust::types::Transform, - depth: usize, - frozen: Option<&HashSet>, - annotation_scale_handle: Option, - all_visible: bool, - depth_map: &HashMap, - out: &mut Vec, - ) { - if depth > 32 { - return; - } - let Some(record) = self.document.block_records.get(block_name) else { - return; - }; - for handle in record.entity_handles.iter().copied() { - let Some(source) = self.document.get_entity(handle) else { - continue; - }; - let contextual = crate::scene::annotative::entity_for_annotation_context( - &self.document, - source, - annotation_scale_handle, - ); - let entity = contextual.as_ref(); - let common = entity.common(); - if common.invisible - || self.layer_hidden(&common.layer) - || self.layer_frozen_in(&common.layer, frozen) - || crate::scene::annotative::annotative_offscale_for( - &self.document, - common, - annotation_scale_handle, - all_visible, - ) - { - continue; - } - if let EntityType::Insert(insert) = entity { - let nested = insert.get_transform().then(transform); - self.collect_block_images( - &insert.block_name, - &nested, - depth + 1, - frozen, - annotation_scale_handle, - all_visible, - depth_map, - out, - ); - continue; - } - let Some(model) = self.images.get(&handle) else { - continue; - }; - let mut placed = model.clone(); - for (corner, low) in placed.corners.iter_mut().zip(&mut placed.corners_low) { - let point = acadrust::types::Vector3::new( - corner[0] as f64 + low[0] as f64, - corner[1] as f64 + low[1] as f64, - corner[2] as f64 + low[2] as f64, - ); - let point = transform.apply(point); - *corner = [point.x as f32, point.y as f32, point.z as f32]; - *low = [ - (point.x - corner[0] as f64) as f32, - (point.y - corner[1] as f64) as f32, - (point.z - corner[2] as f64) as f32, - ]; - } - for vertex in &mut placed.verts { - let point = acadrust::types::Vector3::new( - vertex.pos[0] as f64 + vertex.pos_low[0] as f64, - vertex.pos[1] as f64 + vertex.pos_low[1] as f64, - vertex.pos[2] as f64 + vertex.pos_low[2] as f64, - ); - let point = transform.apply(point); - vertex.pos = [point.x as f32, point.y as f32, point.z as f32]; - vertex.pos_low = [ - (point.x - vertex.pos[0] as f64) as f32, - (point.y - vertex.pos[1] as f64) as f32, - (point.z - vertex.pos[2] as f64) as f32, - ]; - } - placed.draw_depth = depth_map.get(&handle.value()).map_or(0.0, |value| value[0]); - out.push(placed); - } - } - /// Images owned by the active paper layout block only. The full-canvas /// sheet viewport uses this so model-block images don't bleed onto the /// paper sheet (mirrors `paper_canvas_hatches`). @@ -5955,6 +5873,7 @@ impl Scene { None, self.paper_annotation_scale_handle(), self.annotation_all_visible(), + None, )) } @@ -6020,6 +5939,20 @@ impl Scene { .unwrap_or_else(|| self.model_space_block_handle()) } + fn render_scene_root(&self, block: Handle) -> render_graph::SceneRoot { + let role = if self.block_edit_block == Some(block) { + render_graph::BlockRootRole::DefinitionEdit + } else if block == self.model_space_block_handle() { + render_graph::BlockRootRole::ModelSpace + } else { + render_graph::BlockRootRole::PaperSpace + }; + render_graph::SceneRoot::Block(render_graph::BlockRoot { + record: block, + role, + }) + } + fn interaction_block_handle(&self) -> Handle { if self.block_edit_block.is_none() && self.active_viewport.is_some() { self.model_space_block_handle() @@ -6318,6 +6251,7 @@ impl Scene { Some(frozen), scale, all_visible, + Some(viewport), )); self.frozen_image_cache .borrow_mut() @@ -6434,184 +6368,110 @@ impl Scene { if self.block_meshes.is_empty() { return Vec::new(); } + let depth_map = self.draw_depth_map(); + let graph = render_graph::RenderSceneGraph::new( + &self.document, + frozen, + annotation_scale_handle, + all_visible, + depth_map.as_ref(), + ); let mut out = Vec::new(); - for source in self.document.entities() { - let contextual = crate::scene::annotative::entity_for_annotation_context( - &self.document, - source, - annotation_scale_handle, - ); - let e = contextual.as_ref(); - if e.common().owner_handle != layout_block { - continue; - } - if let EntityType::Insert(ins) = e { - // INSERT on an off/frozen (or invisible) layer hides the whole - // instance, block-internal solids included — including a layer - // frozen only in the requesting content viewport. - if !self.mesh_entity_visible(ins.common.handle) - || self.layer_frozen_in(&ins.common.layer, frozen) - || crate::scene::annotative::annotative_offscale_for( - &self.document, - &ins.common, - annotation_scale_handle, - all_visible, - ) - { - continue; + graph.walk_root( + self.render_scene_root(layout_block), + |entity, context| { + context.is_instanced() + || !self.entity_temporarily_hidden(entity.common().handle) + }, + |entity, context| { + if !context.is_instanced() { + return; } - // Colour inheritance sources for block-internal solids (#221). - let bg = self.current_bg(); - let ins_color = crate::scene::view::render::adapt_to_bg( - crate::scene::view::render::render_style_for(&self.document, e).0, - bg, - ); - let l0 = crate::scene::view::render::adapt_to_bg( - crate::scene::view::render::layer_render_style( - &self.document, - &ins.common.layer, - ) - .color, - bg, - ); - let inherit = BlockMeshInherit { - insert_color: ins_color, - layer0_color: l0, - insert_material: - crate::scene::model::material_model::resolve_material_with_base( - &self.document, - e, - ins_color, - None, - self.material_base_dir.as_deref(), - ), - layer0_material: - crate::scene::model::material_model::resolve_layer_material_with_base( - &self.document, - &ins.common.layer, - l0, - self.material_base_dir.as_deref(), - ), + let handle = entity.common().handle; + let Some(set) = self.block_meshes.get(&handle) else { + return; }; - let start = out.len(); - self.expand_block_meshes( - &ins.block_name, - &ins.get_transform(), - 0, - Some(inherit), - &mut out, - frozen, - annotation_scale_handle, - all_visible, - ); - // Tag the instanced meshes with the parent INSERT handle so the - // hover / selection highlight (keyed on the mesh name) tints the - // block, not the inner solid's own handle which nothing selects. - let name = ins.common.handle.value().to_string(); - for set in &mut out[start..] { - for m in &mut set.lods { - m.name = name.clone(); + let inherit = self.mesh_inherit_for_path(&context.insert_path); + let own_alpha = + set.lods.first().map(|mesh| mesh.color[3]).unwrap_or(1.0); + let mut transformed = + transform_block_mesh_lod_set(set, &context.transform); + if let Some(color) = self.block_mesh_override_color( + entity, + handle, + inherit.as_ref(), + own_alpha, + ) { + for mesh in &mut transformed.lods { + mesh.color = color; } - } - } - } - out - } - - /// Recursively emit transformed instances of a block's solid meshes, - /// composing nested-INSERT transforms. (#123) - fn expand_block_meshes( - &self, - block_name: &str, - accum: &acadrust::types::Transform, - depth: usize, - // Block-child colour inheritance sources, bg-adapted: - // `Some` only on the render path; pick paths pass `None`. Carries both - // colour and AcDbMaterial inheritance for ByBlock/layer-0 children. - inherit: Option, - out: &mut Vec, - frozen: Option<&HashSet>, - annotation_scale_handle: Option, - all_visible: bool, - ) { - if depth > 16 { - return; - } - let Some(br) = self.document.block_records.get(block_name) else { - return; - }; - let handles: Vec = br.entity_handles.clone(); - for h in handles { - let Some(source) = self.document.get_entity(h) else { - continue; - }; - let contextual = crate::scene::annotative::entity_for_annotation_context( - &self.document, - source, - annotation_scale_handle, - ); - let e = contextual.as_ref(); - // A block-internal solid / nested INSERT on an off/frozen layer - // (or flagged invisible) must not render, same as a top-level one. - if !self.mesh_definition_entity_visible(h) - || self.layer_frozen_in(&e.common().layer, frozen) - { - continue; - } - if crate::scene::annotative::annotative_offscale_for( - &self.document, - e.common(), - annotation_scale_handle, - all_visible, - ) { - continue; - } - if let EntityType::Insert(ins) = e { - let composed = ins.get_transform().then(accum); - let child = inherit - .as_ref() - .map(|parent| self.chain_mesh_inherit(ins, parent)); - self.expand_block_meshes( - &ins.block_name, - &composed, - depth + 1, - child, - out, - frozen, - annotation_scale_handle, - all_visible, - ); - } else if let Some(set) = self.block_meshes.get(&h) { - // The solid's own transparency (baked into the cached colour). - let own_alpha = set.lods.first().map(|m| m.color[3]).unwrap_or(1.0); - let mut ts = transform_block_mesh_lod_set(set, accum); - // Re-resolve colour against the INSERT context: a block-internal - // solid that is ByBlock or on layer "0" + ByLayer can't be - // coloured at cache-build time (no insert context there). (#221) - if let Some(c) = - self.block_mesh_override_color(e, h, inherit.as_ref(), own_alpha) - { - for lod in &mut ts.lods { - lod.color = c; - } - if let Some(material) = ts.material.as_mut() { + if let Some(material) = transformed.material.as_mut() { if material.handle.is_none() { - material.diffuse = c; + material.diffuse = color; } } } if let Some(material) = - self.block_mesh_override_material(e, inherit.as_ref()) + self.block_mesh_override_material(entity, inherit.as_ref()) { material.apply_to_with_face_overrides( - &mut ts, + &mut transformed, &self.document, self.material_base_dir.as_deref(), ); } - out.push(ts); - } + let root_name = context.root_handle.value().to_string(); + for mesh in &mut transformed.lods { + mesh.name = root_name.clone(); + } + out.push(transformed); + }, + ); + out + } + + fn mesh_inherit_for_path( + &self, + path: &[DxfInsert], + ) -> Option { + let first = path.first()?; + let entity = EntityType::Insert(first.clone()); + let bg = self.current_bg(); + let insert_color = crate::scene::view::render::adapt_to_bg( + crate::scene::view::render::render_style_for(&self.document, &entity).0, + bg, + ); + let layer0_color = crate::scene::view::render::adapt_to_bg( + crate::scene::view::render::layer_render_style( + &self.document, + &first.common.layer, + ) + .color, + bg, + ); + let mut inherit = BlockMeshInherit { + insert_color, + layer0_color, + insert_material: + crate::scene::model::material_model::resolve_material_with_base( + &self.document, + &entity, + insert_color, + None, + self.material_base_dir.as_deref(), + ), + layer0_material: + crate::scene::model::material_model::resolve_layer_material_with_base( + &self.document, + &first.common.layer, + layer0_color, + self.material_base_dir.as_deref(), + ), + }; + for insert in &path[1..] { + inherit = self.chain_mesh_inherit(insert, &inherit); } + Some(inherit) } /// Background colour for the current layout (model vs paper). @@ -6811,93 +6671,7 @@ impl Scene { /// Per-Insert hatch models in the current layout, keyed by the Insert /// handle so a click on a block-internal hatch can select the parent - /// Insert (AutoCAD behaviour: sub-entities of a block aren't directly - /// selectable; the click resolves to the Insert). - /// Whether a block definition (recursively) contains any Hatch. Memoised in - /// `memo` across calls; lets the pick path skip exploding solid-only blocks. - fn block_has_hatch( - &self, - block_name: &str, - memo: &mut std::collections::HashMap, - ) -> bool { - if let Some(&v) = memo.get(block_name) { - return v; - } - // Seed `false` first so a cyclic block reference terminates. - memo.insert(block_name.to_string(), false); - let result = self - .document - .block_records - .get(block_name) - .map(|br| { - br.entity_handles - .iter() - .any(|&h| match self.document.get_entity(h) { - Some(EntityType::Hatch(_)) => true, - Some(EntityType::Insert(ins)) => { - self.block_has_hatch(&ins.block_name, memo) - } - // A dimension bakes its geometry into a per-instance `*D` - // block; a custom filled arrowhead lives there as a nested - // Insert(hatch). Recurse so the fill explosion knows to - // descend the dimension (see `explode_including_dims`). - Some(EntityType::Dimension(dim)) => { - !dim.base().block_name.trim().is_empty() - && self.block_has_hatch(&dim.base().block_name, memo) - } - _ => false, - }) - }) - .unwrap_or(false); - memo.insert(block_name.to_string(), result); - result - } - - /// Transitively true when a block (or a block it nests) contains a wide - /// LwPolyline / Polyline2D — one carrying a non-zero width. Gate for the - /// block-explode wide-fill pass, mirroring [`Self::block_has_hatch`] so a - /// solid-only block is never exploded just to look for width bands. (#222) - fn block_has_wide_poly( - &self, - block_name: &str, - memo: &mut std::collections::HashMap, - ) -> bool { - if let Some(&v) = memo.get(block_name) { - return v; - } - // Seed `false` first so a cyclic block reference terminates. - memo.insert(block_name.to_string(), false); - let result = self - .document - .block_records - .get(block_name) - .map(|br| { - br.entity_handles - .iter() - .any(|&h| match self.document.get_entity(h) { - Some(EntityType::LwPolyline(p)) => { - p.constant_width > 1e-9 - || p.vertices - .iter() - .any(|v| v.start_width > 1e-9 || v.end_width > 1e-9) - } - Some(EntityType::Polyline2D(p)) => { - p.start_width > 1e-9 - || p.end_width > 1e-9 - || p.vertices - .iter() - .any(|v| v.start_width > 1e-9 || v.end_width > 1e-9) - } - Some(EntityType::Insert(ins)) => { - self.block_has_wide_poly(&ins.block_name, memo) - } - _ => false, - }) - }) - .unwrap_or(false); - memo.insert(block_name.to_string(), result); - result - } + /// Insert; block-owned leaves resolve to their owning instance. pub fn insert_hatches_for_click(&self) -> Arc>> { let interaction_block = self.interaction_block_handle(); @@ -6931,13 +6705,26 @@ impl Scene { let layer_hidden = |layer: &str| self.layer_hidden(layer) || self.interaction_layer_frozen(layer); let mut out: HashMap> = HashMap::default(); - // Exploding an INSERT to find block-internal hatches is expensive, so - // skip blocks that contain no hatch at all (the common case for solid- - // only blocks). The hatch-presence test is memoised across inserts. + // Skip block subtrees that contain no hatch at all. The presence test + // is memoised across instances. let mut hatch_memo: std::collections::HashMap = std::collections::HashMap::new(); let annotation_scale_handle = self.displayed_annotation_scale_handle(); let all_visible = self.annotation_all_visible(); + let depth_map = self.draw_depth_map(); + let frozen: HashSet = self + .interaction_viewport_frozen_layers() + .into_iter() + .flatten() + .copied() + .collect(); + let graph = render_graph::RenderSceneGraph::new( + &self.document, + (!frozen.is_empty()).then_some(&frozen), + annotation_scale_handle, + all_visible, + depth_map.as_ref(), + ); for entity in self.document.entities() { let contextual = crate::scene::annotative::entity_for_annotation_context( &self.document, @@ -6966,33 +6753,52 @@ impl Scene { ) { continue; } - if !self.block_has_hatch(&ins.block_name, &mut hatch_memo) { + if !render_graph::block_contains_hatch( + &self.document, + &ins.block_name, + &mut hatch_memo, + ) { continue; } - for sub in ins - .explode_from_document(&self.document) - .into_iter() - .map(|sub| { - crate::scene::annotative::entity_for_annotation_context( - &self.document, - &sub, - annotation_scale_handle, - ) - .into_owned() - }) - .map(crate::modules::draw::modify::explode::normalize_insert_entity) - { - let EntityType::Hatch(dxf) = sub else { - continue; - }; - if dxf.common.invisible || layer_hidden(&dxf.common.layer) { - continue; - } - let color = self.render_style(&EntityType::Hatch(dxf.clone())).0; - if let Some(model) = Self::hatch_model_from_dxf(&dxf, color) { + graph.walk_insert( + ins, + ins.common.handle, + |_, _| true, + |entity, context| { + let EntityType::Hatch(source) = entity else { + return; + }; + let mut placed = EntityType::Hatch(source.clone()); + placed.apply_transform(&context.transform); + let EntityType::Hatch(hatch) = placed else { + return; + }; + let color = crate::scene::view::render::adapt_to_bg( + context.style_for(&self.document, entity).0, + self.current_bg(), + ); + let Some(mut model) = Self::hatch_model_from_dxf(&hatch, color) + else { + return; + }; + for clip in &context.clips { + let clip: Vec<[f32; 2]> = clip + .iter() + .map(|point| [point[0] as f32, point[1] as f32]) + .collect(); + let clipped = pick::xclip::clip_hatch_boundary( + &model.boundary, + model.world_origin, + &clip, + ); + if clipped.is_empty() { + return; + } + model.boundary = Arc::new(clipped); + } out.entry(ins.common.handle).or_default().push(model); - } - } + }, + ); } let arc = Arc::new(out); *self.insert_hatch_cache.borrow_mut() = diff --git a/src/scene/model/material_model.rs b/src/scene/model/material_model.rs index 0179ef805..625b1471e 100644 --- a/src/scene/model/material_model.rs +++ b/src/scene/model/material_model.rs @@ -124,7 +124,7 @@ impl MeshMaterial { description: String::new(), diffuse: color, ambient: [color[0], color[1], color[2]], - specular: [1.0; 3], + specular: [0.08; 3], gloss: 0.5, reflectivity: 0.0, self_illumination: 0.0, @@ -169,18 +169,11 @@ impl MeshMaterial { let bump_map = MeshTextureMap::from_dwg(&material.bump_map, base_dir); let refraction_map = MeshTextureMap::from_dwg(&material.refraction_map, base_dir); let normal_map = MeshTextureMap::from_dwg(&material.normal_map, base_dir); - // A material whose diffuse channel explicitly references an external - // texture is incomplete when that file cannot be resolved. Its stored - // diffuse RGB is commonly only a red/magenta placeholder (for example - // VILLA_COMPLETE's Plaster material), not the intended appearance. - // Keep the material's other physical properties, but use the entity's - // fully resolved Color/ByLayer/ByBlock value for the missing diffuse - // channel. - let diffuse = if diffuse_map.has_content() && diffuse_map.image.is_none() { - entity_color - } else { - material_color(&material.diffuse_color, entity_color) - }; + // The stored diffuse component remains authoritative when an external + // image is unavailable. Replacing it with the entity colour would lose + // a decoded material value and make the result depend on a guess about + // the missing asset's intended appearance. + let diffuse = material_color(&material.diffuse_color, entity_color); let ambient = material_color(&material.ambient_color, entity_color); let specular = material_color(&material.specular_color, [1.0; 4]); let opacity = material.opacity_percent.clamp(0.0, 1.0) as f32; diff --git a/src/scene/model/mesh_model.rs b/src/scene/model/mesh_model.rs index c59197ca3..c0678a20b 100644 --- a/src/scene/model/mesh_model.rs +++ b/src/scene/model/mesh_model.rs @@ -84,8 +84,8 @@ pub enum CurvedGen { phi_min: f32, phi_max: f32, }, - /// Torus: the two extreme circles of the tube (outer/inner), clipped to the - /// revolution window the face covers. + /// Torus: view-dependent tube silhouette, clipped to both parametric + /// windows the face covers. Torus { center: [f32; 3], center_low: [f32; 3], @@ -97,6 +97,9 @@ pub enum CurvedGen { phi_min: f32, phi_span: f32, full: bool, + theta_min: f32, + theta_span: f32, + theta_full: bool, }, } diff --git a/src/scene/paper.rs b/src/scene/paper.rs index 3e0e371a3..e70d1ef8d 100644 --- a/src/scene/paper.rs +++ b/src/scene/paper.rs @@ -462,16 +462,15 @@ impl Scene { } // Hatch fills nested inside a block INSERT are owned by the block // record, so the loop above — which only keeps hatches owned by - // `layout_block` — never sees them. Explode the layout's visible - // INSERTs and materialize their fills at world position, exactly as the - // viewport does, so the export carries the block's colours instead of - // bare outlines. (No selection tint on export.) + // `layout_block` — never sees them. Walk the layout's visible block + // instances and materialize their fills at world position, so export + // carries the block's colours instead of bare outlines. let hatch_bg = if self.current_layout != "Model" { self.paper_bg_color } else { self.bg_color }; - let exploded = self.exploded_insert_hatch_models( + let instanced = self.instanced_hatch_models( layout_block, hatch_bg, false, @@ -479,7 +478,7 @@ impl Scene { annotation_scale_handle, all_visible, ); - models.extend(exploded); + models.extend(instanced); Arc::new(models) } @@ -554,7 +553,7 @@ impl Scene { } models.push(hatch); } - models.extend(self.exploded_insert_hatch_models( + models.extend(self.instanced_hatch_models( block, self.paper_bg_color, false, @@ -565,8 +564,8 @@ impl Scene { models } - /// Plot-only wipeout masks for a specific block. Nested inserts follow the - /// same recursive collector as the on-screen model path. + /// Plot-only wipeout masks for a specific block. Nested instances follow + /// the same scene graph as the on-screen model path. pub(super) fn plot_wipeouts_for_block( &self, block: Handle, @@ -575,96 +574,14 @@ impl Scene { all_visible: bool, highlight_selection: bool, ) -> Vec { - let depth_map = self.draw_depth_map(); - let mut models = Vec::new(); - for entity in self.document.entities() { - let EntityType::Wipeout(wipeout) = entity else { - continue; - }; - let common = &wipeout.common; - if common.invisible - || self.entity_temporarily_hidden(common.handle) - || self - .document - .layers - .get(&common.layer) - .map(|layer| layer.flags.off || layer.flags.frozen) - .unwrap_or(false) - || self.layer_frozen_in(&common.layer, frozen) - || !self.belongs_to_visible_block(common.handle, common.owner_handle, block) - { - continue; - } - let (world_origin, boundary) = Self::wipeout_boundary_2d(wipeout); - if boundary.len() < 3 { - continue; - } - models.push(HatchModel { - boundary: Arc::new(boundary), - boundary_wcs: None, - pattern: model::hatch_model::HatchPattern::Solid, - name: "WIPEOUT_FILL".into(), - color: if highlight_selection && self.selected.contains(&common.handle) { - [0.15, 0.55, 1.00, 0.35] - } else { - self.paper_bg_color - }, - aci: 0, - line_weight_px: 1.0, - angle_offset: 0.0, - scale: 1.0, - world_origin, - draw_depth: depth_map - .get(&common.handle.value()) - .map_or(0.0, |depth| depth[0]), - }); - } - for entity in self.document.entities() { - let contextual = crate::scene::annotative::entity_for_annotation_context( - &self.document, - entity, - annotation_scale_handle, - ); - let EntityType::Insert(insert) = contextual.as_ref() else { - continue; - }; - let common = &insert.common; - if common.invisible - || self.entity_temporarily_hidden(common.handle) - || self - .document - .layers - .get(&common.layer) - .map(|layer| layer.flags.off || layer.flags.frozen) - .unwrap_or(false) - || self.layer_frozen_in(&common.layer, frozen) - || crate::scene::annotative::annotative_offscale_for( - &self.document, - common, - annotation_scale_handle, - all_visible, - ) - || !self.belongs_to_visible_block(common.handle, common.owner_handle, block) - { - continue; - } - self.collect_block_wipeouts( - &insert.get_transform(), - &insert.block_name, - 0, - frozen, - if highlight_selection && self.selected.contains(&common.handle) { - [0.15, 0.55, 1.00, 0.35] - } else { - self.paper_bg_color - }, - &depth_map, - &mut models, - annotation_scale_handle, - all_visible, - ); - } - models + self.wipeout_models_for_block_graph( + block, + frozen, + annotation_scale_handle, + all_visible, + self.paper_bg_color, + highlight_selection, + ) } /// Paper-layout wipeout fills (paper hit-testing / export). Same rationale as diff --git a/src/scene/pick/xclip.rs b/src/scene/pick/xclip.rs index f20e29395..e49b999c3 100644 --- a/src/scene/pick/xclip.rs +++ b/src/scene/pick/xclip.rs @@ -12,7 +12,7 @@ use acadrust::entities::Insert; use acadrust::objects::{ObjectType, SpatialFilter}; -use acadrust::types::{Handle, Vector3}; +use acadrust::types::{Handle, Transform, Vector3}; use acadrust::CadDocument; use crate::scene::model::wire_model::WireModel; @@ -62,25 +62,23 @@ fn dict_entry(doc: &CadDocument, dict: Handle, key: &str) -> Option { /// insert was later rescaled the stored `M⁻¹` still places the clip correctly.) /// Two points describe a rectangle (opposite corners); three or more an /// explicit polygon. -pub fn world_clip_polygon( - sf: &SpatialFilter, - ins: &Insert, -) -> Vec<[f32; 2]> { - world_clip_polygon_f64(sf, ins) - .into_iter() - .map(|[x, y]| [x as f32, y as f32]) - .collect() -} - -/// f64 variant of [`world_clip_polygon`]. The boundary stays in absolute world -/// coordinates so clipping at UTM scale (~5.7e6) is precise — the f32 version -/// quantizes each vertex by ~0.5 m, which warps the clip region and breaks both -/// the clipped render and ZOOM Extents. -pub fn world_clip_polygon_f64( +/// Build the clip boundary in absolute f64 world coordinates so clipping at +/// large coordinate values remains precise. +#[cfg(test)] +fn world_clip_polygon_f64( sf: &SpatialFilter, ins: &Insert, ) -> Vec<[f64; 2]> { - let xform = ins.get_transform(); + world_clip_polygon_for_transform(sf, &ins.get_transform()) +} + +/// Build the clip boundary with the exact transform used by the corresponding +/// block instance. Callers that resolve block base points or parent instances +/// pass their composed transform here so geometry and clipping share one space. +pub fn world_clip_polygon_for_transform( + sf: &SpatialFilter, + xform: &Transform, +) -> Vec<[f64; 2]> { let inv_block = &sf.inverse_block_transform; let local: Vec<[f64; 2]> = if sf.boundary_points.len() == 2 { let a = sf.boundary_points[0]; @@ -764,13 +762,13 @@ mod tests { ins.set_x_scale(0.1); ins.set_y_scale(0.1); - let poly = world_clip_polygon(&sf, &ins); + let poly = world_clip_polygon_f64(&sf, &ins); // vert (580,4528) → ×1000 → (580000,4528000) → ×0.1 + insert → // (639668, 4516955). - let xs: Vec = poly.iter().map(|p| p[0]).collect(); - let ys: Vec = poly.iter().map(|p| p[1]).collect(); - let minx = xs.iter().cloned().fold(f32::MAX, f32::min); - let miny = ys.iter().cloned().fold(f32::MAX, f32::min); + let xs: Vec = poly.iter().map(|p| p[0]).collect(); + let ys: Vec = poly.iter().map(|p| p[1]).collect(); + let minx = xs.iter().cloned().fold(f64::MAX, f64::min); + let miny = ys.iter().cloned().fold(f64::MAX, f64::min); assert!((minx - 639668.0).abs() < 1.0, "minx={minx}"); assert!((miny - 4516955.0).abs() < 1.0, "miny={miny}"); } diff --git a/src/scene/pipeline/mesh_gpu.rs b/src/scene/pipeline/mesh_gpu.rs index 0e2a5f656..b4d19032f 100644 --- a/src/scene/pipeline/mesh_gpu.rs +++ b/src/scene/pipeline/mesh_gpu.rs @@ -406,6 +406,16 @@ struct MaterialMapParams { blends1: [f32; 4], /// Presence bits for the same four maps. present1: [u32; 4], + /// Tiling modes for diffuse, specular, reflection and opacity. + tiling0: [u32; 4], + /// Tiling modes for bump, refraction, normal and reserved. + tiling1: [u32; 4], + /// Two-sided, normal-map method, global-illumination and final-gather modes. + render_modes: [u32; 4], + /// Advanced-data-present, anonymous and two reserved source-state bits. + source_state: [u32; 4], + /// Color-bleed scale and reserved render values. + indirect: [f32; 4], } fn upload_rgba_texture( @@ -490,7 +500,7 @@ pub fn create_material_bind_group( ); let sampler = |label: &'static str, tiling: u8| { let address = match tiling { - 1 => wgpu::AddressMode::Repeat, + 0 | 1 => wgpu::AddressMode::Repeat, 4 => wgpu::AddressMode::MirrorRepeat, _ => wgpu::AddressMode::ClampToEdge, }; @@ -525,6 +535,21 @@ pub fn create_material_bind_group( let bump_sampler = sampler("mesh.material.bump_sampler", tiling(4)); let refraction_sampler = sampler("mesh.material.refraction_sampler", tiling(5)); let normal_sampler = sampler("mesh.material.normal_sampler", tiling(6)); + let channel_flags = material.map_or(0x7f, |material| material.channel_flags as u32); + let channel_present = |channel: usize, bit: u32| { + material.is_some_and(|material| { + let maps = [ + &material.diffuse_map, + &material.specular_map, + &material.reflection_map, + &material.opacity_map, + &material.bump_map, + &material.refraction_map, + &material.normal_map, + ]; + channel_flags & bit != 0 && maps[channel].image.is_some() + }) as u32 + }; let params = MaterialMapParams { blends0: material.map_or([0.0; 4], |material| { [ @@ -534,12 +559,12 @@ pub fn create_material_bind_group( material.opacity_map.blend_factor, ] }), - present0: material.map_or([0; 4], |material| { + present0: material.map_or([0; 4], |_material| { [ - material.diffuse_map.image.is_some() as u32, - material.specular_map.image.is_some() as u32, - material.reflection_map.image.is_some() as u32, - material.opacity_map.image.is_some() as u32, + channel_present(0, 0x01), + channel_present(1, 0x02), + channel_present(2, 0x04), + channel_present(3, 0x08), ] }), blends1: material.map_or([0.0; 4], |material| { @@ -552,12 +577,48 @@ pub fn create_material_bind_group( }), present1: material.map_or([0; 4], |material| { [ - material.bump_map.image.is_some() as u32, - material.refraction_map.image.is_some() as u32, - material.normal_map.image.is_some() as u32, + channel_present(4, 0x10), + channel_present(5, 0x20), + material.normal_map.image.is_some() as u32 + * (material.normal_map_method == 0) as u32, 0, ] }), + tiling0: material.map_or([1; 4], |material| { + [ + material.diffuse_map.tiling as u32, + material.specular_map.tiling as u32, + material.reflection_map.tiling as u32, + material.opacity_map.tiling as u32, + ] + }), + tiling1: material.map_or([1; 4], |material| { + [ + material.bump_map.tiling as u32, + material.refraction_map.tiling as u32, + material.normal_map.tiling as u32, + 1, + ] + }), + render_modes: material.map_or([1, 0, 0, 0], |material| { + [ + material.two_sided as u32, + material.normal_map_method as u32, + material.global_illumination as u32, + material.final_gather as u32, + ] + }), + source_state: material.map_or([0; 4], |material| { + [ + material.advanced_data_present as u32, + material.is_anonymous as u32, + 0, + 0, + ] + }), + indirect: material.map_or([1.0, 0.0, 0.0, 0.0], |material| { + [material.color_bleed_scale, 0.0, 0.0, 0.0] + }), }; let params_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("mesh.material.params"), @@ -656,51 +717,45 @@ pub fn create_material_bind_group( } #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] -struct MaterialBatchKey([u32; 16]); +struct MaterialBatchKey([u32; 40]); fn material_key( material: Option<&crate::scene::model::material_model::MeshMaterial>, color: [f32; 4], ) -> MaterialBatchKey { + let mut key = [0u32; 40]; + key[2..6].copy_from_slice(&color.map(f32::to_bits)); let Some(material) = material else { - return MaterialBatchKey([ - 0, - 0, - color[0].to_bits(), - color[1].to_bits(), - color[2].to_bits(), - color[3].to_bits(), - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - ]); + return MaterialBatchKey(key); }; let handle = material.handle.map_or(0, |handle| handle.value()); - MaterialBatchKey([ - handle as u32, - (handle >> 32) as u32, - material.diffuse[0].to_bits(), - material.diffuse[1].to_bits(), - material.diffuse[2].to_bits(), - material.diffuse[3].to_bits(), - material.specular[0].to_bits(), - material.specular[1].to_bits(), - material.specular[2].to_bits(), - material.gloss.to_bits(), - material.reflectivity.to_bits(), - material.self_illumination.to_bits(), - material.luminance.to_bits(), - material.refraction_index.to_bits(), - material.diffuse_map.projection as u32, - material.diffuse_map.tiling as u32, - ]) + key[0] = handle as u32; + key[1] = (handle >> 32) as u32; + key[6..10].copy_from_slice(&material.diffuse.map(f32::to_bits)); + key[10..13].copy_from_slice(&material.ambient.map(f32::to_bits)); + key[13..16].copy_from_slice(&material.specular.map(f32::to_bits)); + key[16] = material.gloss.to_bits(); + key[17] = material.reflectivity.to_bits(); + key[18] = material.self_illumination.to_bits(); + key[19] = material.translucence.to_bits(); + key[20] = material.refraction_index.to_bits(); + key[21] = material.luminance.to_bits(); + key[22] = material.two_sided as u32; + key[23] = material.illumination_model as u32; + key[24] = material.channel_flags as u32; + key[25] = material.mode as u32; + key[26] = material.indirect_bump_scale.to_bits(); + key[27] = material.reflectance_scale.to_bits(); + key[28] = material.transmittance_scale.to_bits(); + key[29] = material.luminance_mode as u32; + key[30] = material.normal_map_method as u32; + key[31] = material.normal_map_strength.to_bits(); + key[32] = material.is_anonymous as u32; + key[33] = material.global_illumination as u32; + key[34] = material.final_gather as u32; + key[35] = material.color_bleed_scale.to_bits(); + key[36] = material.advanced_data_present as u32; + MaterialBatchKey(key) } fn morton_axis(mut value: u32) -> u64 { @@ -736,6 +791,7 @@ struct MeshBatchPart<'a> { set: &'a MeshLodSet, mesh: &'a MeshModel, display_mesh: &'a MeshModel, + uv_mesh: &'a MeshModel, material: Option<&'a crate::scene::model::material_model::MeshMaterial>, color: [f32; 4], indices: Vec, @@ -772,6 +828,7 @@ fn build_instanced_chunk( let material = first.material; let color = first.color; let has_normals = mesh.normals.len() == mesh.verts.len(); + let bounds = mesh_bounds(mesh); let (material_params, specular, ambient, advanced, flags) = material_vertex_params(material); let vertex = |index: usize, edge: bool| { @@ -799,7 +856,15 @@ fn build_instanced_chunk( let uvs = if edge { [[0.0; 2]; 7] } else { - material_uvs(material, position, normal) + material_uvs( + material, + position, + normal, + bounds, + position, + normal, + bounds, + ) }; MeshVertex { position, @@ -856,7 +921,7 @@ fn build_instanced_chunk( ]); } } - let transparent = color[3] < 0.999; + let transparent = material_is_transparent(material, color); let face_indices = first.include_faces.then_some(first.indices.as_slice()); let opaque_indices = if transparent { &[][..] @@ -932,11 +997,80 @@ fn build_instanced_chunk( )) } +fn mesh_bounds(mesh: &MeshModel) -> [f32; 6] { + let mut bounds = [ + f32::INFINITY, + f32::INFINITY, + f32::INFINITY, + f32::NEG_INFINITY, + f32::NEG_INFINITY, + f32::NEG_INFINITY, + ]; + for (index, high) in mesh.verts.iter().copied().enumerate() { + let low = mesh.verts_low.get(index).copied().unwrap_or([0.0; 3]); + for axis in 0..3 { + let value = high[axis] + low[axis]; + bounds[axis] = bounds[axis].min(value); + bounds[axis + 3] = bounds[axis + 3].max(value); + } + } + bounds +} + +fn material_uses_model_mapper( + material: Option<&crate::scene::model::material_model::MeshMaterial>, +) -> bool { + material.is_some_and(|material| { + [ + &material.diffuse_map, + &material.specular_map, + &material.reflection_map, + &material.opacity_map, + &material.bump_map, + &material.refraction_map, + &material.normal_map, + ] + .into_iter() + .any(|map| map.image.is_some() && map.auto_transform & 4 != 0) + }) +} + +fn material_is_transparent( + material: Option<&crate::scene::model::material_model::MeshMaterial>, + color: [f32; 4], +) -> bool { + color[3] < 0.999 + || material.is_some_and(|material| { + material.translucence > 0.0 + || (material.channel_flags as u32 & 0x08 != 0 + && material.opacity_map.image.is_some()) + }) +} + fn material_map_uv( map: &crate::scene::model::material_model::MeshTextureMap, - position: [f32; 3], - normal: [f32; 3], + local_position: [f32; 3], + local_normal: [f32; 3], + local_bounds: [f32; 6], + model_position: [f32; 3], + model_normal: [f32; 3], + model_bounds: [f32; 6], ) -> [f32; 2] { + let (mut position, normal, bounds) = if map.auto_transform & 4 != 0 { + (model_position, model_normal, model_bounds) + } else { + (local_position, local_normal, local_bounds) + }; + if map.auto_transform & 2 != 0 { + for axis in 0..3 { + let extent = bounds[axis + 3] - bounds[axis]; + position[axis] = if extent.is_finite() && extent.abs() > f32::EPSILON { + (position[axis] - bounds[axis]) / extent + } else { + 0.0 + }; + } + } let m = &map.transform; let p = [ position[0] * m[0] + position[1] * m[1] + position[2] * m[2] + m[3], @@ -975,20 +1109,24 @@ fn material_map_uv( fn material_uvs( material: Option<&crate::scene::model::material_model::MeshMaterial>, - position: [f32; 3], - normal: [f32; 3], + local_position: [f32; 3], + local_normal: [f32; 3], + local_bounds: [f32; 6], + model_position: [f32; 3], + model_normal: [f32; 3], + model_bounds: [f32; 6], ) -> [[f32; 2]; 7] { let Some(material) = material else { return [[0.0; 2]; 7]; }; [ - material_map_uv(&material.diffuse_map, position, normal), - material_map_uv(&material.specular_map, position, normal), - material_map_uv(&material.reflection_map, position, normal), - material_map_uv(&material.opacity_map, position, normal), - material_map_uv(&material.bump_map, position, normal), - material_map_uv(&material.refraction_map, position, normal), - material_map_uv(&material.normal_map, position, normal), + material_map_uv(&material.diffuse_map, local_position, local_normal, local_bounds, model_position, model_normal, model_bounds), + material_map_uv(&material.specular_map, local_position, local_normal, local_bounds, model_position, model_normal, model_bounds), + material_map_uv(&material.reflection_map, local_position, local_normal, local_bounds, model_position, model_normal, model_bounds), + material_map_uv(&material.opacity_map, local_position, local_normal, local_bounds, model_position, model_normal, model_bounds), + material_map_uv(&material.bump_map, local_position, local_normal, local_bounds, model_position, model_normal, model_bounds), + material_map_uv(&material.refraction_map, local_position, local_normal, local_bounds, model_position, model_normal, model_bounds), + material_map_uv(&material.normal_map, local_position, local_normal, local_bounds, model_position, model_normal, model_bounds), ] } @@ -998,7 +1136,7 @@ fn material_vertex_params( let Some(material) = material else { return ( [0.5, 0.0, 0.0, 0.0], - [1.0, 1.0, 1.0, 1.0], + [0.08, 0.08, 0.08, 1.0], [0.3, 0.3, 0.3, 0.0], [1.0; 4], [0, 127, 0, 0], @@ -1089,11 +1227,11 @@ pub fn build_mesh_batch_filtered( }) { continue; } - let mesh = set + let source_mesh = set .instance_source .as_ref() - .and_then(|source| source.lods.iter().find(|mesh| !mesh.indices.is_empty())) - .unwrap_or(display_mesh); + .and_then(|source| source.lods.iter().find(|mesh| !mesh.indices.is_empty())); + let mesh = source_mesh.unwrap_or(display_mesh); let triangle_count = display_mesh.indices.len() / 3; let has_face_materials = display_mesh.triangle_material_handles.len() == triangle_count @@ -1119,6 +1257,7 @@ pub fn build_mesh_batch_filtered( set, mesh, display_mesh, + uv_mesh: mesh, material: set.material.as_ref(), color, indices: display_mesh.indices.clone(), @@ -1166,6 +1305,7 @@ pub fn build_mesh_batch_filtered( set, mesh, display_mesh, + uv_mesh: mesh, material, color, indices, @@ -1211,6 +1351,7 @@ pub fn build_mesh_batch_filtered( let eligible = storage_instancing && part.set.instance_transform.is_some() && part.set.instance_source.is_some() + && !material_uses_model_mapper(part.material) && part.mesh.verts.len() <= max_verts && part.indices.len() / 3 <= max_tris && part @@ -1336,6 +1477,9 @@ pub fn build_mesh_batch_filtered( let has_normals = mesh.normals.len() == mesh.verts.len(); let (material_params, specular, ambient, advanced, flags) = material_vertex_params(material); + let uv_mesh = part.uv_mesh; + let local_bounds = mesh_bounds(uv_mesh); + let model_bounds = mesh_bounds(mesh); let edge_color = set .visual_style .as_ref() @@ -1346,7 +1490,25 @@ pub fn build_mesh_batch_filtered( } else { [0.0, 1.0, 0.0] }; - let uv = material_uvs(material, mesh.verts[vi], normal); + let local_position = uv_mesh + .verts + .get(vi) + .copied() + .unwrap_or(mesh.verts[vi]); + let local_normal = uv_mesh + .normals + .get(vi) + .copied() + .unwrap_or(normal); + let uv = material_uvs( + material, + local_position, + local_normal, + local_bounds, + mesh.verts[vi], + normal, + model_bounds, + ); MeshVertex { position: mesh.verts[vi], normal, @@ -1368,7 +1530,7 @@ pub fn build_mesh_batch_filtered( }; // A solid whose baked colour is not fully opaque routes into the // transparent index stream so it is drawn last, without depth writes. - let is_transp = part_color[3] < 0.999; + let is_transp = material_is_transparent(material, part_color); let mesh_tris = part.indices.len() / 3; if part.include_faces { total_tris += mesh_tris as u64; diff --git a/src/scene/pipeline/mod.rs b/src/scene/pipeline/mod.rs index 2bbb009f2..403751fc0 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -2109,7 +2109,7 @@ impl Pipeline { } CurvedGen::Torus { center, center_low, axis, u_dir, v_dir, major, minor, - phi_min, phi_span, full, + phi_min, phi_span, full, theta_min, theta_span, theta_full, } => { let ctr = lo(*center, *center_low); let (axis, u, v) = (d3(*axis), d3(*u_dir), d3(*v_dir)); @@ -2136,12 +2136,17 @@ impl Pipeline { let cur = [th, th + std::f64::consts::PI]; for k in 0..2 { let t = cur[k]; - let p = ring + (radial * t.cos() + axis * t.sin()) * minor; - if let Some(pp) = prev[k] { + let theta_offset = + (t - *theta_min as f64).rem_euclid(std::f64::consts::TAU); + let on_face = *theta_full || theta_offset <= *theta_span as f64; + let p = on_face.then(|| { + ring + (radial * t.cos() + axis * t.sin()) * minor + }); + if let (Some(pp), Some(p)) = (prev[k], p) { verts.push(mk(pp)); verts.push(mk(p)); } - prev[k] = Some(p); + prev[k] = p; } } } @@ -2280,7 +2285,7 @@ impl Pipeline { WireGpu::from_batch(device, face3d_wires, depth_map, self.wire_const_bgl.as_ref()); // Fill buffer split: 3D quads + PolyfaceMesh / PolygonMesh face // tris go to `chunks_3d` (gated by `keep_3d_mesh_fills`); - // 2D fills (text-LOD greek, MultiLeader background) go to + // 2D fills (text-LOD greek, MultiLeader background, dimension arrows) go to // `chunks_2d`. The 3-D wireframe additionally removes only legacy // planar SOLID interiors; HATCH is handled by a separate pass. let keep_3d_mesh_fills = !wireframe_only; @@ -2289,11 +2294,11 @@ impl Pipeline { }; let has_any_2d_fill = all_wires .iter() - .any(|w| !w.fill_tris.is_empty() && w.points.is_empty() && !solid_fill_hidden(w)); + .any(|w| !w.fill_tris.is_empty() && !w.fill_is_3d && !solid_fill_hidden(w)); let has_any_3d_fill = !face3d_wires.is_empty() || all_wires .iter() - .any(|w| !w.fill_tris.is_empty() && !w.points.is_empty()); + .any(|w| !w.fill_tris.is_empty() && w.fill_is_3d); let has_fills = has_any_2d_fill || (keep_3d_mesh_fills && has_any_3d_fill); if !has_fills { self.gpu_face3d_fill = None; diff --git a/src/scene/render_graph.rs b/src/scene/render_graph.rs new file mode 100644 index 000000000..18eeb358a --- /dev/null +++ b/src/scene/render_graph.rs @@ -0,0 +1,709 @@ +//! Shared render-scene traversal. +//! +//! The drawing database stays authoritative. This layer only resolves the +//! hierarchy and per-instance context needed by render backends: space roots, +//! nested block references, transforms, arrays, visibility, style inheritance, +//! draw order, and clip boundaries. Leaf entities remain responsible for +//! producing their normal wire, hatch, image, wipeout, or mesh model. + +use acadrust::entities::Insert; +use acadrust::types::{Color, Transform, Vector3}; +use acadrust::{CadDocument, EntityType, Handle}; +use rustc_hash::{FxHashMap, FxHashSet}; + +use crate::scene::view::render::{ + has_resolved_book_color, is_effective_layer_zero, layer_render_style, + render_style_for, render_style_for_block_sub, InheritStyle, +}; + +pub type ResolvedStyle = ([f32; 4], f32, [f32; 8], f32, u8); + +/// A block record used as a render root. Model space, paper space, and an +/// ordinary definition opened for editing share the same ownership mechanism; +/// only their runtime role differs. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct BlockRoot { + pub record: Handle, + pub role: BlockRootRole, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum BlockRootRole { + ModelSpace, + PaperSpace, + DefinitionEdit, +} + +/// Semantic root of one render traversal. A viewport is a projection edge from +/// its paper-space owner to model-space content, not another storage container. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SceneRoot { + Block(BlockRoot), + Viewport { + paper_block: Handle, + viewport: Handle, + model_block: Handle, + }, +} + +impl SceneRoot { + pub fn content_block(self) -> Handle { + match self { + Self::Block(root) => root.record, + Self::Viewport { model_block, .. } => model_block, + } + } +} + +#[derive(Clone, Copy, Debug)] +pub struct BlockStyle { + pub insert: ResolvedStyle, + pub layer0: InheritStyle, + pub layer0_aci: u8, +} + +#[derive(Clone, Copy, Debug)] +pub struct InsertStyleSpec { + own: BlockStyle, + color_byblock: bool, + color_bylayer: bool, + linetype_byblock: bool, + linetype_bylayer: bool, + lineweight_byblock: bool, + lineweight_bylayer: bool, + layer0: bool, +} + +impl InsertStyleSpec { + pub fn new(document: &CadDocument, insert: &Insert) -> Self { + let entity = EntityType::Insert(insert.clone()); + let has_book_color = has_resolved_book_color(document, &entity); + let linetype = &insert.common.linetype; + Self { + own: BlockStyle::for_entity(document, &entity), + color_byblock: !has_book_color && insert.common.color == Color::ByBlock, + color_bylayer: !has_book_color && insert.common.color == Color::ByLayer, + linetype_byblock: linetype.eq_ignore_ascii_case("byblock"), + linetype_bylayer: linetype.is_empty() || linetype.eq_ignore_ascii_case("bylayer"), + lineweight_byblock: matches!( + insert.common.line_weight, + acadrust::types::LineWeight::ByBlock + ), + lineweight_bylayer: matches!( + insert.common.line_weight, + acadrust::types::LineWeight::ByLayer + | acadrust::types::LineWeight::Default + ), + layer0: is_effective_layer_zero(&insert.common.layer), + } + } + + pub fn resolve(self, parent: BlockStyle) -> BlockStyle { + let mut insert = self.own.insert; + if self.color_byblock { + insert.0 = parent.insert.0; + insert.4 = parent.insert.4; + } else if self.layer0 && self.color_bylayer { + insert.0 = parent.layer0.color; + insert.4 = parent.layer0_aci; + } + if self.linetype_byblock { + insert.1 = parent.insert.1; + insert.2 = parent.insert.2; + } else if self.layer0 && self.linetype_bylayer { + insert.1 = parent.layer0.pat_len; + insert.2 = parent.layer0.pat; + } + if self.lineweight_byblock { + insert.3 = parent.insert.3; + } else if self.layer0 && self.lineweight_bylayer { + insert.3 = parent.layer0.lw_px; + } + BlockStyle { + insert, + layer0: if self.layer0 { + parent.layer0 + } else { + self.own.layer0 + }, + layer0_aci: if self.layer0 { + parent.layer0_aci + } else { + self.own.layer0_aci + }, + } + } +} + +impl BlockStyle { + pub fn for_entity(document: &CadDocument, entity: &EntityType) -> Self { + Self { + insert: render_style_for(document, entity), + layer0: layer_render_style(document, &entity.common().layer), + layer0_aci: layer_aci(document, &entity.common().layer), + } + } + + pub fn for_nested( + document: &CadDocument, + insert: &Insert, + parent: Self, + ) -> Self { + InsertStyleSpec::new(document, insert).resolve(parent) + } + + pub fn for_owned( + document: &CadDocument, + entity: &EntityType, + parent: Option, + ) -> Self { + let on_layer0 = is_effective_layer_zero(&entity.common().layer); + let insert = parent + .map(|style| style.resolve(document, entity)) + .unwrap_or_else(|| render_style_for(document, entity)); + Self { + insert, + layer0: if on_layer0 { + parent + .map(|style| style.layer0) + .unwrap_or_else(|| layer_render_style(document, &entity.common().layer)) + } else { + layer_render_style(document, &entity.common().layer) + }, + layer0_aci: if on_layer0 { + parent + .map(|style| style.layer0_aci) + .unwrap_or_else(|| layer_aci(document, &entity.common().layer)) + } else { + layer_aci(document, &entity.common().layer) + }, + } + } + + pub fn resolve(self, document: &CadDocument, entity: &EntityType) -> ResolvedStyle { + let mut resolved = render_style_for_block_sub( + document, + entity, + self.insert.0, + self.insert.1, + self.insert.2, + self.insert.3, + self.layer0, + ); + let common = entity.common(); + let has_book_color = has_resolved_book_color(document, entity); + resolved.4 = if !has_book_color && common.color == Color::ByBlock { + self.insert.4 + } else if !has_book_color + && is_effective_layer_zero(&common.layer) + && common.color == Color::ByLayer + { + self.layer0_aci + } else { + resolved.4 + }; + resolved + } +} + +fn layer_aci(document: &CadDocument, layer: &str) -> u8 { + document + .layers + .get(layer) + .and_then(|layer| match &layer.color { + Color::Index(index) => Some(*index), + _ => None, + }) + .unwrap_or(0) +} + +#[derive(Clone, Debug)] +pub struct RenderContext { + pub transform: Transform, + pub root_handle: Handle, + pub parent_insert: Handle, + pub insert_path: Vec, + pub clips: Vec>, + pub block_style: Option, + pub depth_base: f32, + pub depth_scale: f32, + pub nesting_depth: usize, +} + +impl RenderContext { + fn direct(depth_base: f32) -> Self { + Self { + transform: Transform::identity(), + root_handle: Handle::NULL, + parent_insert: Handle::NULL, + insert_path: Vec::new(), + clips: Vec::new(), + block_style: None, + depth_base, + depth_scale: 1.0, + nesting_depth: 0, + } + } + + pub fn is_instanced(&self) -> bool { + !self.root_handle.is_null() + } + + pub fn style_for(&self, document: &CadDocument, entity: &EntityType) -> ResolvedStyle { + self.block_style + .map(|style| style.resolve(document, entity)) + .unwrap_or_else(|| render_style_for(document, entity)) + } + + pub fn draw_depth(&self, handle: Handle, depths: &FxHashMap) -> f32 { + if self.is_instanced() { + self.depth_base + + depths + .get(&handle.value()) + .map_or(0.0, |depth| depth[0]) + * self.depth_scale + } else { + depths + .get(&handle.value()) + .map_or(self.depth_base, |depth| depth[0]) + } + } +} + +pub struct RenderSceneGraph<'a> { + document: &'a CadDocument, + frozen_layers: Option<&'a FxHashSet>, + annotation_scale_handle: Option, + all_visible: bool, + depths: &'a FxHashMap, +} + +impl<'a> RenderSceneGraph<'a> { + pub fn new( + document: &'a CadDocument, + frozen_layers: Option<&'a FxHashSet>, + annotation_scale_handle: Option, + all_visible: bool, + depths: &'a FxHashMap, + ) -> Self { + Self { + document, + frozen_layers, + annotation_scale_handle, + all_visible, + depths, + } + } + + /// Walk direct root entities and every referenced block subtree. `visible` + /// can add session-only rules such as isolate/preview hiding; returning + /// false for an Insert removes its whole subtree. + pub fn walk_root(&self, root: SceneRoot, mut visible: V, mut leaf: F) + where + V: FnMut(&EntityType, &RenderContext) -> bool, + F: FnMut(&EntityType, &RenderContext), + { + let block = root.content_block(); + let Some(record) = self + .document + .block_records + .iter() + .find(|record| record.handle == block) + else { + return; + }; + for &handle in &record.entity_handles { + let Some(source) = self.document.get_entity(handle) else { + continue; + }; + let contextual = crate::scene::annotative::entity_for_annotation_context( + self.document, + source, + self.annotation_scale_handle, + ); + let entity = contextual.as_ref(); + let direct_depth = self + .depths + .get(&handle.value()) + .map_or(0.0, |depth| depth[0]); + let context = RenderContext::direct(direct_depth); + if !self.document_visible(entity) || !visible(entity, &context) { + continue; + } + if let EntityType::Insert(insert) = entity { + self.walk_insert_instances(insert, &context, &mut visible, &mut leaf); + } else { + leaf(entity, &context); + self.walk_owned_content(entity, &context, &mut visible, &mut leaf, &mut Vec::new()); + } + } + } + + /// Walk one synthetic or document-owned Insert. Used by entity renderers + /// whose content is itself a block reference. + pub fn walk_insert( + &self, + insert: &Insert, + root_handle: Handle, + mut visible: V, + mut leaf: F, + ) where + V: FnMut(&EntityType, &RenderContext) -> bool, + F: FnMut(&EntityType, &RenderContext), + { + let mut root_insert = insert.clone(); + root_insert.common.handle = root_handle; + let entity = EntityType::Insert(root_insert.clone()); + let depth_base = self + .depths + .get(&root_handle.value()) + .map_or(0.0, |depth| depth[0]); + let context = RenderContext::direct(depth_base); + if self.document_visible(&entity) && visible(&entity, &context) { + self.walk_insert_instances(&root_insert, &context, &mut visible, &mut leaf); + } + } + + + fn walk_insert_instances( + &self, + insert: &Insert, + parent: &RenderContext, + visible: &mut V, + leaf: &mut F, + ) where + V: FnMut(&EntityType, &RenderContext) -> bool, + F: FnMut(&EntityType, &RenderContext), + { + let insert_entity = EntityType::Insert(insert.clone()); + let block_style = parent + .block_style + .map(|style| BlockStyle::for_nested(self.document, insert, style)) + .unwrap_or_else(|| BlockStyle::for_entity(self.document, &insert_entity)); + let root_handle = if parent.root_handle.is_null() { + insert.common.handle + } else { + parent.root_handle + }; + let [depth_base, depth_scale] = if parent.root_handle.is_null() { + self.depths + .get(&insert.common.handle.value()) + .copied() + .unwrap_or([0.0, 1.0]) + } else { + let base = parent.draw_depth(insert.common.handle, self.depths); + let count = self + .document + .block_records + .get(&insert.block_name) + .map_or(1, |record| record.entity_handles.len().max(1)); + [base, parent.depth_scale / (count as f32 + 1.0)] + }; + + for offset in array_offsets(insert) { + let local = insert_instance_transform(self.document, insert, offset); + let transform = local.then(&parent.transform); + let mut context = parent.clone(); + context.transform = transform; + context.root_handle = root_handle; + context.parent_insert = insert.common.handle; + context.insert_path.push(insert.clone()); + context.block_style = Some(block_style); + context.depth_base = depth_base; + context.depth_scale = depth_scale; + context.nesting_depth += 1; + if let Some(filter) = crate::scene::pick::xclip::insert_spatial_filter( + self.document, + insert, + ) { + let polygon = crate::scene::pick::xclip::world_clip_polygon_for_transform( + filter, + &transform, + ); + if polygon.len() >= 3 { + context.clips.push(polygon); + } + } + let mut stack = context + .insert_path + .iter() + .map(|insert| insert.block_name.clone()) + .collect(); + self.walk_block( + &insert.block_name, + &context, + visible, + leaf, + &mut stack, + ); + } + } + + fn walk_block( + &self, + block_name: &str, + context: &RenderContext, + visible: &mut V, + leaf: &mut F, + stack: &mut Vec, + ) where + V: FnMut(&EntityType, &RenderContext) -> bool, + F: FnMut(&EntityType, &RenderContext), + { + if context.nesting_depth > 32 { + return; + } + let Some(record) = self.document.block_records.get(block_name) else { + return; + }; + for &handle in &record.entity_handles { + let Some(source) = self.document.get_entity(handle) else { + continue; + }; + let contextual = crate::scene::annotative::entity_for_annotation_context( + self.document, + source, + self.annotation_scale_handle, + ); + let entity = contextual.as_ref(); + if !self.document_visible(entity) || !visible(entity, context) { + continue; + } + match entity { + EntityType::Block(_) + | EntityType::BlockEnd(_) + | EntityType::AttributeDefinition(_) => {} + EntityType::Insert(nested) => { + if stack + .iter() + .any(|name| name.eq_ignore_ascii_case(&nested.block_name)) + { + continue; + } + stack.push(nested.block_name.clone()); + self.walk_insert_instances(nested, context, visible, leaf); + stack.pop(); + } + _ => { + leaf(entity, context); + self.walk_owned_content(entity, context, visible, leaf, stack); + } + } + } + } + + fn walk_owned_content( + &self, + entity: &EntityType, + context: &RenderContext, + visible: &mut V, + leaf: &mut F, + stack: &mut Vec, + ) where + V: FnMut(&EntityType, &RenderContext) -> bool, + F: FnMut(&EntityType, &RenderContext), + { + match entity { + EntityType::Dimension(dimension) => { + let block_name = dimension.base().block_name.trim(); + if block_name.is_empty() + || stack + .iter() + .any(|name| name.eq_ignore_ascii_case(block_name)) + { + return; + } + let placement = + Transform::from_translation(dimension.base().insertion_point) + .then(&context.transform); + let mut owned = context.clone(); + owned.transform = placement; + if owned.root_handle.is_null() { + owned.root_handle = entity.common().handle; + } + owned.block_style = Some(BlockStyle::for_owned( + self.document, + entity, + context.block_style, + )); + owned.nesting_depth += 1; + stack.push(block_name.to_string()); + self.walk_block(block_name, &owned, visible, leaf, stack); + stack.pop(); + } + EntityType::Table(table) => { + let Some(record) = table.block_record_handle.and_then(|handle| { + self.document + .block_records + .iter() + .find(|record| record.handle == handle) + }) else { + return; + }; + let mut insert = Insert::new(record.name.clone(), table.insertion_point); + insert.rotation = table + .horizontal_direction + .y + .atan2(table.horizontal_direction.x); + insert.common = table.common.clone(); + self.walk_insert_instances(&insert, context, visible, leaf); + } + EntityType::MultiLeader(multileader) + if matches!( + multileader.content_type, + acadrust::entities::LeaderContentType::Block + ) && multileader.context.has_block_contents => + { + let Some(record) = multileader.block_content_handle.and_then(|handle| { + self.document + .block_records + .iter() + .find(|record| record.handle == handle) + }) else { + return; + }; + let mut insert = Insert::new( + record.name.clone(), + multileader.context.block_content_location, + ); + insert.common = multileader.common.clone(); + insert.common.color = multileader.block_content_color.clone(); + insert.set_x_scale(multileader.block_scale.x); + insert.set_y_scale(multileader.block_scale.y); + insert.set_z_scale(multileader.block_scale.z); + insert.rotation = multileader.block_rotation; + insert.normal = multileader.context.block_content_normal; + self.walk_insert_instances(&insert, context, visible, leaf); + } + _ => {} + } + } + + fn document_visible(&self, entity: &EntityType) -> bool { + let common = entity.common(); + if common.invisible { + return false; + } + let layer = self.document.layers.get(&common.layer); + if layer + .map(|layer| layer.flags.off || layer.flags.frozen) + .unwrap_or(false) + { + return false; + } + if self.frozen_layers.is_some_and(|frozen| { + layer.is_some_and(|layer| frozen.contains(&layer.handle)) + }) { + return false; + } + !crate::scene::annotative::annotative_offscale_for( + self.document, + common, + self.annotation_scale_handle, + self.all_visible, + ) + } +} + +pub fn block_base_point(document: &CadDocument, block_name: &str) -> Vector3 { + document + .block_records + .get(block_name) + .and_then(|record| document.get_entity(record.block_entity_handle)) + .and_then(|entity| match entity { + EntityType::Block(block) => Some(block.base_point), + _ => None, + }) + .unwrap_or(Vector3::ZERO) +} + +pub fn insert_transform(document: &CadDocument, insert: &Insert) -> Transform { + let base = block_base_point(document, &insert.block_name); + Transform::from_translation(Vector3::new(-base.x, -base.y, -base.z)) + .then(&insert.get_transform()) +} + +pub fn array_offsets(insert: &Insert) -> Vec<[f64; 3]> { + if !insert.is_minsert() { + return vec![[0.0; 3]]; + } + let mut offsets = Vec::with_capacity(insert.instance_count()); + for row in 0..insert.row_count { + for column in 0..insert.column_count { + offsets.push([ + column as f64 * insert.column_spacing, + row as f64 * insert.row_spacing, + 0.0, + ]); + } + } + offsets +} + +pub fn insert_instance_transform( + document: &CadDocument, + insert: &Insert, + offset: [f64; 3], +) -> Transform { + let transform = insert_transform(document, insert); + if offset == [0.0; 3] { + transform + } else { + Transform::from_translation(Vector3::new(offset[0], offset[1], offset[2])) + .then(&transform) + } +} + +pub fn block_contains_hatch( + document: &CadDocument, + block_name: &str, + memo: &mut std::collections::HashMap, +) -> bool { + if let Some(&contains) = memo.get(block_name) { + return contains; + } + memo.insert(block_name.to_string(), false); + let contains = document + .block_records + .get(block_name) + .is_some_and(|record| { + record + .entity_handles + .iter() + .any(|&handle| match document.get_entity(handle) { + Some(EntityType::Hatch(_)) => true, + Some(EntityType::Insert(insert)) => { + block_contains_hatch(document, &insert.block_name, memo) + } + Some(EntityType::Dimension(dimension)) => { + let name = dimension.base().block_name.trim(); + !name.is_empty() && block_contains_hatch(document, name, memo) + } + Some(EntityType::Table(table)) => table + .block_record_handle + .and_then(|handle| { + document + .block_records + .iter() + .find(|record| record.handle == handle) + }) + .is_some_and(|record| { + block_contains_hatch(document, &record.name, memo) + }), + Some(EntityType::MultiLeader(multileader)) => multileader + .block_content_handle + .and_then(|handle| { + document + .block_records + .iter() + .find(|record| record.handle == handle) + }) + .is_some_and(|record| { + block_contains_hatch(document, &record.name, memo) + }), + _ => false, + }) + }); + memo.insert(block_name.to_string(), contains); + contains +} diff --git a/src/shaders/mesh.wgsl b/src/shaders/mesh.wgsl index 9ab66db2b..35fd01cd5 100644 --- a/src/shaders/mesh.wgsl +++ b/src/shaders/mesh.wgsl @@ -37,6 +37,11 @@ struct MaterialMaps { present0: vec4, blends1: vec4, present1: vec4, + tiling0: vec4, + tiling1: vec4, + render_modes: vec4, + source_state: vec4, + indirect: vec4, }; @group(1) @binding(0) var diffuse_map: texture_2d; @@ -186,8 +191,20 @@ fn vs_edge( return out; } +fn map_weight(uv: vec2, tiling: u32) -> f32 { + if (tiling == 2u) { + return select( + 0.0, + 1.0, + all(uv >= vec2(0.0)) && all(uv <= vec2(1.0)), + ); + } + return 1.0; +} + @fragment fn fs_main(in: VertexOut) -> @location(0) vec4 { + let two_sided = maps.render_modes.x != 0u; var n: vec3; if (u.flat_shade > 0.5) { // Per-triangle face normal: derivatives of the interpolated @@ -197,7 +214,6 @@ fn fs_main(in: VertexOut) -> @location(0) vec4 { } else { n = normalize(in.normal); } - // Build a derivative tangent frame from the generated material UVs. This // works for planar/box/cylindrical/spherical AcDbMaterial projections and // does not require ACIS to carry explicit texture-coordinate vertices. @@ -218,7 +234,9 @@ fn fs_main(in: VertexOut) -> @location(0) vec4 { let mapped = normalize( tangent * sampled.x + bitangent * sampled.y + n * sampled.z ); - let strength = maps.blends1.z * max(in.advanced.x, 0.0); + let strength = maps.blends1.z + * map_weight(in.uv_normal, maps.tiling1.z) + * max(in.advanced.x, 0.0); n = normalize(mix(n, mapped, clamp(strength, 0.0, 1.0))); } else if (maps.present1.x != 0u) { let height = dot( @@ -226,7 +244,12 @@ fn fs_main(in: VertexOut) -> @location(0) vec4 { vec3(0.2126, 0.7152, 0.0722), ); let slope = tangent * dpdx(height) + bitangent * dpdy(height); - n = normalize(n - slope * maps.blends1.x * max(in.advanced.y, 0.0) * 4.0); + n = normalize( + n - slope + * maps.blends1.x + * map_weight(in.uv_bump, maps.tiling1.x) + * 4.0 + ); } // Native AcDbLight / Sun lighting. When a drawing has no active lights, @@ -236,9 +259,9 @@ fn fs_main(in: VertexOut) -> @location(0) vec4 { let l1 = normalize(vec3(-0.7, 0.3, 0.4)); // fill (left) let l2 = normalize(vec3( 0.2, -0.6, -0.8)); // back/under var direct_light = vec3( - 0.45 * abs(dot(n, l0)) - + 0.30 * abs(dot(n, l1)) - + 0.25 * abs(dot(n, l2)) + 0.45 * select(max(dot(n, l0), 0.0), abs(dot(n, l0)), two_sided) + + 0.30 * select(max(dot(n, l1), 0.0), abs(dot(n, l1)), two_sided) + + 0.25 * select(max(dot(n, l2), 0.0), abs(dot(n, l2)), two_sided) ); var key_light = l0; if (u.lighting.x > 0.5) { @@ -281,9 +304,12 @@ fn fs_main(in: VertexOut) -> @location(0) vec4 { } } let strength = max(direction_intensity.w, 0.0) * attenuation; - direct_light += color_hotspot.rgb - * max(dot(n, light_vector), 0.0) - * strength; + let response = select( + max(dot(n, light_vector), 0.0), + abs(dot(n, light_vector)), + two_sided, + ); + direct_light += color_hotspot.rgb * response * strength; if (index == 0u) { key_light = light_vector; } @@ -298,26 +324,36 @@ fn fs_main(in: VertexOut) -> @location(0) vec4 { ); let fresnel = fresnel0 + (1.0 - fresnel0) * pow(1.0 - abs(dot(n, view)), 5.0); - let specular_strength = clamp(0.08 + in.material.y + fresnel, 0.0, 1.5); - var specular_color = in.specular.rgb; + let specular_strength = clamp(1.0 + in.material.y + fresnel, 0.0, 1.5); + var albedo = in.color.rgb; + if (maps.present0.x != 0u) { + let texel = textureSample(diffuse_map, diffuse_sampler, in.uv_diffuse).rgb; + let blend = clamp( + maps.blends0.x * map_weight(in.uv_diffuse, maps.tiling0.x), + 0.0, + 1.0, + ); + albedo = mix(albedo, texel, blend); + } + var specular_color = select(in.specular.rgb, albedo, in.flags.x == 1u); if (maps.present0.y != 0u) { let texel = textureSample(specular_map, specular_sampler, in.uv_specular).rgb; - specular_color = mix(specular_color, texel, clamp(maps.blends0.y, 0.0, 1.0)); + let blend = clamp( + maps.blends0.y * map_weight(in.uv_specular, maps.tiling0.y), + 0.0, + 1.0, + ); + specular_color = mix(specular_color, texel, blend); } let half_response = select( - abs(dot(n, half_vec)), max(dot(n, half_vec), 0.0), - u.lighting.x > 0.5, + abs(dot(n, half_vec)), + two_sided, ); let specular = specular_color * pow(half_response, gloss_exp) * specular_strength * max(in.advanced.z, 0.0); - var albedo = in.color.rgb; - if (maps.present0.x != 0u) { - let texel = textureSample(diffuse_map, diffuse_sampler, in.uv_diffuse).rgb; - albedo = mix(albedo, texel, clamp(maps.blends0.x, 0.0, 1.0)); - } let ambient_light = albedo * clamp(in.ambient.rgb, vec3(0.0), vec3(1.0)); var lit = ambient_light + albedo * clamp(direct_light, vec3(0.0), vec3(2.0)) + specular; if (maps.present0.z != 0u) { @@ -329,7 +365,14 @@ fn fs_main(in: VertexOut) -> @location(0) vec4 { lit = mix( lit, reflected, - clamp(maps.blends0.z * in.material.y * max(in.advanced.z, 0.0), 0.0, 1.0), + clamp( + maps.blends0.z + * map_weight(in.uv_reflection, maps.tiling0.z) + * in.material.y + * max(in.advanced.z, 0.0), + 0.0, + 1.0, + ), ); } if (maps.present1.y != 0u) { @@ -341,15 +384,29 @@ fn fs_main(in: VertexOut) -> @location(0) vec4 { lit = mix( lit, transmitted, - clamp(maps.blends1.y * in.ambient.a * max(in.advanced.w, 0.0), 0.0, 1.0), + clamp( + maps.blends1.y + * map_weight(in.uv_refraction, maps.tiling1.y) + * in.ambient.a + * max(in.advanced.w, 0.0), + 0.0, + 1.0, + ), ); } - let emission = clamp(in.material.z + in.material.w, 0.0, 1.0); - let rgb = mix(lit, albedo, emission); + var rgb = mix(lit, albedo, clamp(in.material.z, 0.0, 1.0)); + if (in.flags.w == 1u) { + rgb = lit + albedo * max(in.material.w, 0.0); + } var material_alpha = in.color.a; if (maps.present0.w != 0u) { let opacity = textureSample(opacity_map, opacity_sampler, in.uv_opacity).r; - material_alpha *= mix(1.0, opacity, clamp(maps.blends0.w, 0.0, 1.0)); + let blend = clamp( + maps.blends0.w * map_weight(in.uv_opacity, maps.tiling0.w), + 0.0, + 1.0, + ); + material_alpha *= mix(1.0, opacity, blend); } material_alpha *= 1.0 - clamp(in.ambient.a * max(in.advanced.w, 0.0), 0.0, 0.95); let alpha = select(1.0, material_alpha, u.transparency_enable > 0.5);